Bot 跑在你自己的機器上、不需要公開 URL——它主動連出來取事件,處理完再用 inbound 端點把結果送回房間。使用者在房裡打 /plm show 123,你的 bot 就會收到一則 command 事件。
0. 開始之前
- 管理員在後台建立 bot,取得 handle(例如
plm)與一把 token。 - 管理員把 bot 授權給要用的房間(未授權的房,bot 讀不到也寫不到)。
- Bot 啟動後:
PUT /api/bot/commands註冊子指令 → 開一條 outbound 連線等事件。
1. 認證
所有 /api/bot/* 端點都要帶:
Authorization: Bearer <bot token>
- 驗不過一律 401。token 被撤銷、bot 被停用、或 token 到期,效果相同。
- Token 是憑證:外洩等同整顆 bot 被接管。管理員可隨時撤銷單一 token。
⚠️ outbound 連線在 token 失效的當下就會被伺服器主動收線,不是等到下次握手。
2. Outbound:取得事件
兩種傳輸共用同一個佇列,選一種就好。同一顆 bot 同時只能有一條 outbound 連線——開第二條會把第一條踢掉。
2.1 序號與游標
每則事件有一個 per-bot 的 seq(從 1 起算、單調遞增、永不重複)。兩種傳輸的游標語意差一,這是最容易寫錯的地方:
| 傳輸 | 參數 | 意思 | 省略時 |
|---|---|---|---|
| long-poll | ?offset=N | 「下一則我要的 seq」(含 N 本身) | 1 |
| SSE | Last-Event-ID: N 或 ?since=N | 「我最後收到的 seq」(不含 N) | 0 |
⇒ long-poll 收到最後一則 seq=7 之後,下一次要送 ?offset=8;SSE 斷線重連時要送 Last-Event-ID: 7。
2.2 long-poll:GET /api/bot/updates
GET /api/bot/updates?offset=8&timeout=25000
Authorization: Bearer <token>
| 參數 | 說明 |
|---|---|
offset | 非負整數。省略=1。它同時是 ack:伺服器會把 seq < offset 的列永久刪除。 |
timeout | 毫秒。省略=25000;超過 60000 會被夾住(不是錯誤)。 |
200
{ "updates": [ { "seq": 8, "type": "command", "payload": "{…}", "createdAt": 1754200000000 } ] }
- 佇列空 ⇒ 伺服器 hold 住連線,有新事件就立刻回;等滿
timeout則回{"updates":[]}。 - 🔥 單批上限 100 則。回傳筆數等於 100 就代表還有更多 ⇒ 應立刻用新的
offset再拉一次,不要進入下一輪 long-poll 等待。 - 下一次的
offset= 這次回應裡最後一則的seq+ 1。
| 碼 | body error | 什麼時候 |
|---|---|---|
| 400 | invalid offset / invalid timeout | 參數不是非負整數 |
| 401 | unauthorized | token 失效(撤銷/停用/到期)。重連前先換一把有效的 token |
| 409 | replaced | 你的另一條連線把這條取代了(一顆 bot 只能有一條) |
2.3 SSE:GET /api/bot/stream
GET /api/bot/stream
Authorization: Bearer <token>
Last-Event-ID: 7
回應是 text/event-stream。三種輸出:
event: update
id: 8
data: {"seq":8,"type":"command","payload":"{…}","createdAt":1754200000000}
: hb ← 心跳,每 15 秒一次,忽略即可
event: auth-invalid
data: {"reason":"token-invalid"}
- 每一則
update都帶id: <seq>⇒ 用標準EventSource的話Last-Event-ID會自動維護。 - 🔥
auth-invalid是終止事件、刻意不帶id:(不污染游標)。收到它代表憑證失效,伺服器接著就會關閉連線。收到後不要自動重連——沒換 token 之前重連只會在握手時拿 401。 - 連線期間伺服器會自動補齊 backlog(超過 100 則也會分批連續推完),你不必自己分頁。
⚠️ SSE 不會 ack:走 SSE 的事件會留到 24 小時 TTL 才清除。這是刻意的——斷線重連時用 Last-Event-ID 就能補回沒收到的。若你想讓伺服器提早清掉已處理的列,可以偶爾打一次 long-poll 帶上你的游標(offset 會 ack 掉它之前的全部)。
2.4 連線會被伺服器收線的三種情況
| 情況 | long-poll | SSE |
|---|---|---|
| 你自己開了第二條連線 | 舊的立刻回 409 replaced | 舊的直接 EOF |
| token 撤銷/bot 停用/token 到期 | 401 unauthorized | auth-invalid 事件後 EOF |
| 房間授權被撤銷 | 不收線——連線不綁房,逐則事件會自己被過濾掉 | |
2.5 事件保留
- 未取走的事件保留 24 小時,之後由每日清理刪除。
- 「送出成功」不是刪除的理由——只有三條:long-poll 的
offsetack、24 小時 TTL、以及送出前判定「這則已經不能送」(例如那間房的授權在事件入列之後被撤銷了)。 - ⇒ client 處理到一半當掉不會遺失:沒有推進
offset之前,重連還拿得到同一批。
3. command 事件
目前 type 只有一種:command。payload 是 JSON 字串(要自己 JSON.parse):
{
"roomId": "01J…",
"threadId": null,
"invocationId": "01J…",
"actorName": "小明",
"args": ["show", "123"]
}
| 欄位 | 說明 |
|---|---|
roomId | 觸發所在的房間 id(回話要用它) |
threadId | 討論串 id;主欄觸發時是 null ⇒ 回話要回到同一個 scope |
invocationId | 這次觸發的唯一 id(ULID)。做冪等用 |
actorName | 觸發者在這間房的有效顯示名(不是全域帳號名) |
args | 子指令與參數。/plm show 123 ⇒ ["show","123"] |
這些欄位全部由伺服器組裝,使用者無法偽造。整包序列化後上限 8192 bytes,超過的觸發在入列前就被擋掉(使用者會看到 400)。
4. 註冊指令:PUT /api/bot/commands
整份取代(送什麼就是什麼;送 [] 等於清空)。
[
{ "name": "show", "description": "顯示一張票", "usage": "/plm show <id>" },
{ "name": "search", "description": "搜尋票", "usage": "/plm search <關鍵字>" }
]
| 規則 | 值 |
|---|---|
| 項數上限 | 32 |
name | ^[a-z0-9][a-z0-9_-]{0,23}$(小寫英數開頭,其餘可含 _/-,1–24 字),不可重複 |
description/usage | 字串,各 ≤ 200 字 |
任一不合 ⇒ 400,訊息會指出第幾項、哪個欄位。
註冊成功後,該 bot 當下有授權的每一間房都會即時收到通知,前端的 / 自動完成立刻更新。使用者打 /plm 就會看到你註冊的子指令與說明。
指令一律是 /<handle> <子指令> [args…],不能註冊頂層指令——這樣不會與內建指令(ai/clear/compact/model/stop/usage/who/help)或其他 bot 撞名。
5. Inbound:回話與查詢
5.1 發言(主欄)
POST /api/bot/rooms/:roomId/messages
{ "content": "票 #123:修好了" }
回傳落庫後的訊息物件,房裡的人即時看得到。
⚠️ 帶 threadId 會被整筆拒絕(409 main-scope-only)——討論串請用 5.2。
5.2 發言(討論串)
POST /api/bot/threads/:threadId/messages
{ "content": "…" }
⇒ 收到的 command 事件裡 threadId 不是 null 時,就用這條回(回到同一個 scope)。
5.3 開一條新討論串
POST /api/bot/rooms/:roomId/threads
{ "content": "部署失敗了", "title": "2026-08-03 部署" }
title可省略(伺服器自動命名)。- 伺服器會在單一交易內先插一則主欄訊息當 root,再以它開串。
- 回
{ thread, messageId }。 - ⚠️ 不接受
rootMessageId(帶了回 409root-not-accepted)——root 一律由伺服器自己插。
5.4 查詢
| 端點 | 回什麼 |
|---|---|
GET /api/bot/me | { id, handle, name, hasAvatar, rooms } |
GET /api/bot/rooms | { rooms }——授權的房,含所屬工作區 |
GET /api/bot/rooms/:roomId/threads?includeArchived=1 | { threads }(預設不含封存的) |
⚠️ 沒有「讀訊息」的端點。Bot 只收得到針對它的指令觸發,讀不到房裡的對話——這是刻意的。
5.5 房間解析的錯誤碼
| 碼 | 什麼時候 |
|---|---|
| 404 | 房間不存在/已刪除/這顆 bot 沒有被授權(不透露房間存不存在) |
| 403 | 房間已封存(唯讀,寫入端點才會擋) |
6. 限制一覽
| 項目 | 值 |
|---|---|
| Bot 發言限速 | 30 則/分鐘(per bot;超過回 429) |
| 訊息內容長度 | 同真人主欄上限 |
| 指令 payload | 序列化後 ≤ 8192 bytes |
| 註冊指令 | ≤ 32 項;name ≤ 24 字;description/usage ≤ 200 字 |
| outbound 單批 | ≤ 100 則 |
| long-poll hold | 預設 25 秒、上限 60 秒 |
| SSE 心跳 | 15 秒 |
| 事件保留 | 24 小時 |
7. 最小可用 client(TypeScript 虛擬碼)
const BASE = 'https://aigora.proty.pe';
const H = { Authorization: `Bearer ${process.env.BOT_TOKEN}` };
// 1) 註冊指令(每次啟動都送一次,整份取代)
await fetch(`${BASE}/api/bot/commands`, {
method: 'PUT',
headers: { ...H, 'content-type': 'application/json' },
body: JSON.stringify([{ name: 'show', description: '顯示一張票', usage: '/plm show <id>' }]),
});
// 2) long-poll 迴圈
let offset = 1; // 有做持久化的話從上次存的接
for (;;) {
const res = await fetch(`${BASE}/api/bot/updates?offset=${offset}&timeout=25000`, { headers: H });
if (res.status === 401) { /* 憑證失效:換 token,別急著重試 */ break; }
if (res.status === 409) { /* 有另一條連線接手了:這條退場 */ break; }
if (!res.ok) { await sleep(5000); continue; } // 5xx/網路問題:退避後重試
const { updates } = await res.json();
for (const u of updates) {
const p = JSON.parse(u.payload); // { roomId, threadId, invocationId, actorName, args }
await handle(p); // ⚠️ 用 invocationId 做冪等
offset = u.seq + 1; // 推進游標=ack 掉這則
}
// 🔥 滿批代表還有更多:立刻再拉,不要等下一輪 long-poll
if (updates.length === 100) continue;
}
// 3) 回話——回到同一個 scope
async function reply(p, text: string) {
const url = p.threadId
? `${BASE}/api/bot/threads/${p.threadId}/messages`
: `${BASE}/api/bot/rooms/${p.roomId}/messages`;
await fetch(url, { method: 'POST', headers: { ...H, 'content-type': 'application/json' },
body: JSON.stringify({ content: text }) });
}
8. 容易寫錯的六件事
- 游標差一:long-poll 的
offset是「下一則」,SSE 的Last-Event-ID是「最後一則」。兩者混用會漏掉或重複收一則。 - 滿批不代表結束:
updates.length === 100時要立刻再拉,不要進入下一輪等待——否則 backlog 大時每 25 秒才前進 100 則。 - 只在真的處理完才推進
offset:推進即刪除,中途當掉就拿不回來了。 - 收到
auth-invalid不要自動重連:那不是網路問題,是鑰匙失效了。 - 回錯 scope:
threadId不是null時要回討論串端點;打到房間端點會被 409 拒絕(不會靜默寫到主欄)。 - 用
invocationId做冪等:重連、重送、以及「處理到一半當掉」都可能讓你看到同一則事件兩次。
9. 已知行為
走 long-poll 的 bot,在兩次 poll 之間的空檔被觸發時,房裡會多出一則「已排入佇列,Bot 連線後即會處理(保留 24 小時)」的系統訊息——即使你的 bot 幾十毫秒後就回來了。這是「觸發當下有沒有連線在等」的字面判定;走 SSE(常駐連線)不會有這個現象。