← 返回部落格
·9 min 閱讀·專案紀錄

打造 Slack AI 機器人:NestJS + GPT-4o + Google Calendar 整合實戰

記錄如何用 NestJS 建立一個能理解自然語言的 Slack 機器人,整合 GPT-4o 做意圖分類、Google Calendar API 做行程管理,以及處理 Slack Events API 的各種眉角。

Slack BotNestJSGPT-4oGoogle Calendar自動化
GP Wang
GP Wang
GWP4 STUDIO 創辦人 · 軟體 / 資料工程師

為什麼做這個

團隊日常溝通都在 Slack 上。常見的場景:

  • 「幫我查一下明天下午有什麼會議」
  • 「幫我建一個週五 3 點的 sync meeting」
  • 「這段 code 怎麼改比較好?」(@機器人 + 貼程式碼)

這些操作如果每次都要切換到 Google Calendar 或另外打開 ChatGPT,其實很打斷工作流程。如果在 Slack 裡直接完成呢?

於是我用 NestJS 做了一個 Slack bot,整合了 GPT-4o(對話 + 意圖分類)和 Google Calendar(行程建立 / 查詢)。

架構概覽

Slack Events API
  │ HTTP POST /slack/events
  ▼
NestJS Controller
  │
  ├─→ 去重檢查 (Cache Manager)
  │
  ├─→ 取得對話歷史 (Slack Web API)
  │
  ├─→ 意圖分類 (GPT-4o Tool Calling)
  │     ├── type 1: 建立行程 → Google Calendar API
  │     ├── type 2: 查詢行程 → Google Calendar API
  │     └── type 3: 一般對話 → GPT-4o 回覆
  │
  └─→ 回傳結果 (Slack Web API → postMessage)

Slack Events API 的處理

URL Verification

Slack 在你設定 Event Subscription URL 時,會先發一個 challenge 請求。你必須原封不動回傳 challenge 字串:

@Post('events')
async handleSlackEvent(@Body() body: any) {
  // Slack URL verification
  if (body.challenge) {
    return { challenge: body.challenge };
  }

  await this.slackService.handleEvent(body);
  return { ok: true };
}

事件類型分流

我們監聽兩種事件:

  • app_mention:在頻道中被 @提及
  • message(DM):私訊機器人
async handleEvent(payload: any) {
  const event = payload.event;

  // 過濾機器人自己發的訊息,避免無限迴圈
  if (event.bot_profile) return;

  if (event.type === 'app_mention') {
    await this.handleMention(event);
  } else if (event.type === 'message' && event.channel_type === 'im') {
    await this.handleDirectMessage(event);
  }
}

重要:去重機制

Slack 的 Events API 採用 at-least-once delivery——同一個事件可能送達多次。如果不做去重,機器人可能對同一句話回覆兩三次。

用 NestJS 的 Cache Manager 做 in-memory 去重:

const dedupeKey = `${event.event_ts}:${event.channel}`;
const cached = await this.cacheManager.get(dedupeKey);
if (cached) return; // 已處理過,跳過

// 處理事件...

// 標記為已處理,10 分鐘後過期
await this.cacheManager.set(dedupeKey, true, 600);

event_ts 是 Slack 為每個事件分配的唯一時間戳,搭配 channel 就是一個可靠的去重鍵。

對話上下文:Thread 歷史注入

Slack bot 如果沒有「記憶」,每次回覆都像第一次對話,使用者體驗很差。

當有人在 thread 中 @機器人時,我們取整個 thread 的歷史:

async handleMention(event: any) {
  // 取 thread 歷史(最多 50 則)
  const threadHistory = await this.slackClient.conversations.replies({
    channel: event.channel,
    ts: event.thread_ts || event.ts,
    limit: 50,
  });

  const context = JSON.stringify(threadHistory.messages);

  const systemPrompt = `以下是 Slack 對話串的歷史紀錄:
${context}

請根據最新的訊息回覆使用者。用繁體中文回答。`;

  const reply = await this.gptService.invokeGpt(systemPrompt, event.text);
  // 發送回覆到同一個 thread...
}

把整段 thread 歷史塞進 system prompt,GPT 就能理解對話脈絡並給出連貫的回覆。

意圖分類:GPT 作為 Router

在 DM 模式下,使用者的訊息可能是:

  • 要建立行程
  • 要查詢行程
  • 只是一般聊天

用 GPT-4o 的 Tool Calling 來做結構化的意圖分類:

async checkCalendarIntent(userMessage: string) {
  const tools = [{
    type: 'function',
    function: {
      name: 'calendar_result',
      description: '判斷使用者意圖並提取相關資訊',
      parameters: {
        type: 'object',
        properties: {
          type: {
            type: 'integer',
            description: '1=建立行程, 2=查詢行程, 3=非行程相關',
          },
          events: {
            type: 'array',
            items: {
              type: 'object',
              properties: {
                title: { type: 'string' },
                startTime: { type: 'string', description: 'ISO 8601' },
                endTime: { type: 'string', description: 'ISO 8601' },
              },
            },
          },
          query: {
            type: 'object',
            properties: {
              startDate: { type: 'string' },
              endDate: { type: 'string' },
              keyword: { type: 'string' },
            },
          },
        },
        required: ['type'],
      },
    },
  }];

  return this.gptService.invokeGptWithJson(systemPrompt, userMessage, tools);
}

GPT 不只判斷「這是什麼意圖」,還同時提取結構化資訊(會議標題、時間等)。一次 API call 完成分類 + 資訊擷取。

Google Calendar 整合

Per-User OAuth

每個 Slack 使用者需要各自授權 Google Calendar 存取。流程:

  1. 使用者第一次觸發行程功能 → bot 回覆 OAuth 授權連結
  2. 連結中帶有 state=<slackUserId>
  3. 使用者完成 Google OAuth → callback 把 tokens 存入 MongoDB
  4. 下次觸發時,用已存的 tokens 直接操作 Calendar
// 檢查是否已授權
const tokens = await this.userService.getUserAccessToken(slackUserId);
if (!tokens) {
  // 還沒授權,回傳授權連結
  const authUrl = `https://oauth.gwp4.com/auth/google?state=${slackUserId}`;
  await this.sendMessage(channel, `請先授權 Google Calendar:${authUrl}`);
  return;
}

// 已授權,建立 OAuth client 並操作 Calendar
const oauth2Client = new google.auth.OAuth2();
oauth2Client.setCredentials(tokens);
const calendar = google.calendar({ version: 'v3', auth: oauth2Client });

建立事件

async createEvent(auth: OAuth2Client, event: CalendarEvent) {
  const calendar = google.calendar({ version: 'v3', auth });
  return calendar.events.insert({
    calendarId: 'primary',
    requestBody: {
      summary: event.title,
      start: { dateTime: event.startTime, timeZone: 'Asia/Taipei' },
      end: { dateTime: event.endTime, timeZone: 'Asia/Taipei' },
    },
  });
}

實際使用體驗

情境一:建立會議

使用者:幫我建一個明天下午 3 點到 4 點的 weekly sync
Bot:✅ 已建立行程「weekly sync」
     時間:2026-04-21 15:00 ~ 16:00

情境二:查詢行程

使用者:這週五有什麼會議?
Bot:📅 2026-04-25 的行程:
     - 10:00 Product Review
     - 14:00 Sprint Planning
     - 16:00 1:1 with Manager

情境三:一般對話(在 thread 中)

使用者:@bot 這個 SQL query 怎麼優化?
        SELECT * FROM orders WHERE created_at > '2026-01-01'
        ORDER BY total DESC LIMIT 100;

Bot:建議加上 (created_at, total DESC) 的複合索引...

踩過的坑

坑 1:Slack 3 秒超時

Slack Events API 要求你在 3 秒內回傳 HTTP 200。如果你的處理邏輯超過 3 秒(GPT 呼叫通常需要 2-5 秒),Slack 會認為失敗並重送事件。

解法:立刻回 200,把實際處理放到背景:

@Post('events')
async handleSlackEvent(@Body() body: any) {
  if (body.challenge) return { challenge: body.challenge };

  // 不 await,讓它背景執行
  this.slackService.handleEvent(body).catch(err => {
    this.logger.error('Event handling failed', err);
  });

  return { ok: true }; // 立刻回覆 Slack
}

坑 2:Bot 自己觸發事件

Bot 發訊息到頻道,Slack 也會送 message 事件。如果不過濾,bot 會對自己的訊息又回覆,形成無限迴圈。

坑 3:Token Refresh

Google OAuth 的 access_token 會過期(通常 1 小時)。需要用 refresh_token 自動換新。在 googleapis SDK 中設定好 refresh_token,SDK 會自動處理。

部署

整個 bot 以 Docker 容器部署,加入既有的 Docker network(和 MongoDB 共用網路):

services:
  gpt-bot:
    build: ./gpt-bot
    environment:
      - SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN}
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - MONGODB_URI=mongodb://root:root@mongodb:27017
    networks:
      - common_network

networks:
  common_network:
    external: true

機密資訊透過環境變數注入,不寫入 docker-compose.yml 或映像中。

結語

Slack bot 的開發核心其實不在 Slack 本身——Events API 很直觀。真正有趣的部分是:

  1. GPT 作為 intent router:用 Tool Calling 強制結構化輸出,讓意圖分類變得可靠
  2. Thread 歷史注入:無需額外的對話狀態儲存,直接用 Slack 的 thread 作為「記憶」
  3. OAuth 串聯:Slack user ID 作為 state,串接多個 OAuth 流程

如果你也想做類似的整合,建議先把 Slack Events API 的基本流程跑通(URL verification + 去重 + 3秒超時),再逐步加上 GPT 和其他服務的整合。

關於作者

GP Wang
GP Wang

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。

了解更多 →