打造 Slack AI 機器人:NestJS + GPT-4o + Google Calendar 整合實戰
記錄如何用 NestJS 建立一個能理解自然語言的 Slack 機器人,整合 GPT-4o 做意圖分類、Google Calendar API 做行程管理,以及處理 Slack Events API 的各種眉角。
為什麼做這個
團隊日常溝通都在 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 存取。流程:
- 使用者第一次觸發行程功能 → bot 回覆 OAuth 授權連結
- 連結中帶有
state=<slackUserId> - 使用者完成 Google OAuth → callback 把 tokens 存入 MongoDB
- 下次觸發時,用已存的 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 很直觀。真正有趣的部分是:
- GPT 作為 intent router:用 Tool Calling 強制結構化輸出,讓意圖分類變得可靠
- Thread 歷史注入:無需額外的對話狀態儲存,直接用 Slack 的 thread 作為「記憶」
- OAuth 串聯:Slack user ID 作為 state,串接多個 OAuth 流程
如果你也想做類似的整合,建議先把 Slack Events API 的基本流程跑通(URL verification + 去重 + 3秒超時),再逐步加上 GPT 和其他服務的整合。
關於作者

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