Bot 跑在你自己的機器上、不需要公開 URL——它主動連出來取事件,處理完再用 inbound 端點把結果送回房間。使用者在房裡打 /plm show 123,你的 bot 就會收到一則 command 事件。
0. 開始之前
- 管理員在後台建立 bot,取得 handle(例如
plm)與一把 token。 - 管理員把 bot 授權給要用的房間(未授權的房,bot 讀不到也寫不到)。
- Bot 啟動後:
PUT /api/bot/commands註冊子指令 → 開一條 outbound 連線等事件。
0.1 你會從管理員拿到什麼
| 項目 | 說明 |
|---|---|
| handle | bot 在房裡的指令前綴,例如 plm ⇒ 使用者打 /plm show 123 |
| token | 32 bytes 亂數(base64url 字串)。明碼只在簽發的當下出現一次——伺服器只存 SHA-256,事後連管理員也調不出來,弄丟只能重簽一把 |
| 授權房間 | 這顆 bot 讀得到、寫得到哪幾間房 |
- Token 可以設到期日,也可以不設(永不過期);管理員可隨時撤銷單一 token,或把整顆 bot 停用。
- bot 停用中不能簽發新 token,既有的 token 也一律 401——「換一把新的」不是停用狀態的解法。
- 一顆 bot 可以同時有多把 token(正式機與測試機各一),但限速算在 bot 身上、不是 token ⇒ 多簽幾把不會有多份配額(見 6)。
- 你不需要事先知道房間 id,也不必請管理員抄給你:見 5.4。
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 秒一次(逐字就是 `:hb\n\n`,冒號後沒有空白)
event: auth-invalid
data: {"reason":"token-invalid"}
- 每一則
update都帶id: <seq>,所以你只要把最後一則的id記下來,重連時放進Last-Event-ID就能接續。 - 🔥 不能用
EventSource(瀏覽器的、Node 的都一樣):這個端點只吃Authorization: Bearer,而EventSource依規範送不了自訂標頭,也沒有票券機制可換(Node 24 甚至沒有這個 global)。⇒ 用fetch拿串流自己解析,Last-Event-ID自己維護,範例見 7.2。 - 🔥 手寫 parser 一定要能吃下心跳與未知事件型別:
:hb沒有data:,若你無條件JSON.parse(data)就會在連上後第一個 15 秒炸掉。最惡毒的是失敗形狀——讀取迴圈死了但 TCP 連線還開著,於是伺服器仍然認為你在線(不會給使用者「已排入佇列」的提示),事件照推、你全部收不到,而且不會自動重連。 - 🔥
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。
串接會用到的欄位:
| 欄位 | 說明 |
|---|---|
id | 訊息 id(ULID) |
roomId | 落在哪一間房 |
seq | 房內序號(單調遞增) |
authorType | 恆為 bot |
authorName | bot 的顯示名(管理員設的,不是 handle) |
content | 落庫後的內容(前後空白已去除) |
createdAt | epoch 毫秒 |
物件上還有一批前端渲染用的欄位(replyPreview、stepsCount、durationMs…),bot 通常用不到。
5.2 發言(討論串)
POST /api/bot/threads/:threadId/messages
{ "content": "…" }
⇒ 收到的 command 事件裡 threadId 不是 null 時,就用這條回(回到同一個 scope)。回傳與 5.1 相同形狀的訊息物件。
5.3 開一條新討論串
POST /api/bot/rooms/:roomId/threads
{ "content": "部署失敗了", "title": "2026-08-03 部署" }
title可省略(伺服器自動命名)。- 伺服器會在單一交易內先插一則主欄訊息當 root,再以它開串。
- 回
{ thread, messageId }:thread是完整的討論串物件(含id與title——省略title時伺服器自動命名的結果也在裡面,不必再打一次 GET),messageId是那則被當成起點的主欄訊息 id。 - ⚠️ 不接受
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 }(預設不含封存的) |
🔥 roomId 從哪來——三個來源,都不需要寫死在設定檔裡:
GET /api/bot/rooms(或GET /api/bot/me)列出目前授權的每一間房,每一筆都帶id。啟動時打一次就有全部的 id。- 每一則
command事件的 payload 都帶roomId(見 3)⇒ 回話直接用事件裡的值,不必查表。 POST /api/bot/rooms/:roomId/threads回的thread.id就是之後往那條串發言要用的:threadId。
⚠️ 房間 id 在 Aigora 的畫面上不會顯示,網址上的是房號(slug)不是 id——別請使用者去複製網址給你。
⚠️ 授權是會變的(管理員隨時可增減房間),把 id 寫死會在撤權或換房時安靜地開始回 404 ⇒ 一律以 GET /api/bot/rooms 為準。
⚠️ 沒有「讀訊息」的端點。Bot 只收得到針對它的指令觸發,讀不到房裡的對話——這是刻意的。
5.5 房間解析的錯誤碼
| 碼 | 什麼時候 |
|---|---|
| 404 | 房間不存在/已刪除/這顆 bot 沒有被授權(不透露房間存不存在) |
| 403 | 房間已封存(唯讀,寫入端點才會擋) |
6. 限制一覽
| 項目 | 值 |
|---|---|
| Bot 發言限速 | 30 則/分鐘(60 秒滑動窗,算在 bot 身上——多把 token 不會有多份配額;超過回 429) |
| 訊息內容長度 | 1–8000 字(前後空白會先去除;空字串或超過都回 400) |
| 討論串標題 | 1–100 字(title 可省略=伺服器自動命名) |
| 指令 payload | 序列化後 ≤ 8192 bytes |
指令 args(使用者側) | 最多 32 項、每項 ≤ 512 字。使用者的輸入在送出前以空白切分(連續空白與換行都算分隔)⇒ 超過就是使用者當場看到 400、事件不入列;要還原原文請自己 args.join(' ') |
| 註冊指令 | ≤ 32 項;name ≤ 24 字;description/usage ≤ 200 字 |
| outbound 單批 | ≤ 100 則 |
| long-poll hold | 預設 25 秒、上限 60 秒 |
| SSE 心跳 | 15 秒 |
| 事件保留 | 24 小時 |
7. 完整範例(兩種傳輸各一支,可直接跑)
兩支都是零依賴的單檔 Node 程式(Node 18 以上;在 Node 24 實測),做的事一樣:啟動時自報身分並註冊子指令 → 收 command → 依原本的 scope 回話。選一種傳輸就好,不要同時跑兩支(同一顆 bot 只能有一條 outbound 連線,會互踢)。
BOT_TOKEN=<管理員給你的 token> node bot-longpoll.mjs
7.1 long-poll 版
// Aigora bot client — long-poll 版(最簡但完整:收指令 → 回話)
//
// BOT_TOKEN=<管理員給你的 token> node bot-longpoll.mjs
//
// 零依賴,Node 18 以上即可(本檔在 Node 24 實測)。AIGORA_BASE 可覆蓋站點網址。
const BASE = process.env.AIGORA_BASE ?? 'https://aigora.proty.pe';
const TOKEN = process.env.BOT_TOKEN;
if (!TOKEN) {
console.error('缺 BOT_TOKEN 環境變數');
process.exit(1);
}
const AUTH = { authorization: `Bearer ${TOKEN}` };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** 已處理過的 invocationId——同一則事件可能收到兩次(重連、或處理到一半當掉重來)。
* 正式環境請換成會落地的存放(檔案/DB),行程重啟後才擋得住重複。 */
const handled = new Set();
/** 「下一則我要的 seq」。⚠️ long-poll 的游標是「下一則」,SSE 是「最後一則」,差一。
* 正式環境要持久化:不然重啟後從 1 開始,會把伺服器上還沒被 ack 的舊事件重收一遍
* (靠 invocationId 冪等擋掉,但白跑一趟)。 */
let offset = Number(process.env.AIGORA_OFFSET ?? 1);
async function api(method, path, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: body === undefined ? AUTH : { ...AUTH, 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${await res.text()}`);
return res.json();
}
/** 回話一律回到觸發時的同一個 scope:threadId 是 null 就回主欄,否則回那條討論串。 */
async function reply(ev, text) {
const path = ev.threadId
? `/api/bot/threads/${ev.threadId}/messages`
: `/api/bot/rooms/${ev.roomId}/messages`;
const msg = await api('POST', path, { content: text });
console.log(`→ 已回 ${msg.id}`);
}
/** args[0] 是子指令,其餘是參數。使用者的輸入是以空白切分後送來的,
* 要還原成一句話就自己 join 回來。 */
async function handle(ev) {
const [sub, ...rest] = ev.args;
console.log(`← ${ev.actorName}:${ev.args.join(' ')}`);
if (sub === 'ping') return reply(ev, 'pong 🏓');
if (sub === 'echo') return reply(ev, `${ev.actorName} 說:${rest.join(' ')}`);
return reply(ev, `我不認得「${sub ?? ''}」。可用:ping、echo`);
}
async function main() {
// ① 我是誰、被授權哪些房——順手驗證 token 有效。roomId 從這裡拿,不要寫死。
const me = await api('GET', '/api/bot/me');
console.log(`我是 /${me.handle}(${me.name}),授權 ${me.rooms.length} 間房:` +
me.rooms.map((r) => `${r.title}=${r.id}`).join('、'));
// ② 註冊子指令(整份取代;每次啟動送一次就好,只影響使用者的 / 自動完成提示)
await api('PUT', '/api/bot/commands', [
{ name: 'ping', description: '測試連線', usage: `/${me.handle} ping` },
{ name: 'echo', description: '把你說的話回一遍', usage: `/${me.handle} echo <文字>` },
]);
// ③ long-poll 迴圈:伺服器沒事件時會把連線 hold 住,有事件立刻回,最長 timeout 毫秒。
for (;;) {
let res;
try {
res = await fetch(`${BASE}/api/bot/updates?offset=${offset}&timeout=25000`, { headers: AUTH });
} catch (err) {
console.error(`連線失敗(${err.message}),5 秒後重試`);
await sleep(5000);
continue;
}
if (res.status === 401) {
console.error('401:token 已失效(撤銷/到期)或 bot 已停用——重試沒有意義,換一把再啟動');
return;
}
if (res.status === 409) {
console.error('409 replaced:同一顆 bot 另開了一條連線,這條退場');
return;
}
if (!res.ok) {
console.error(`HTTP ${res.status},5 秒後重試`);
await sleep(5000);
continue;
}
const { updates } = await res.json();
for (const u of updates) {
// payload 是 JSON 字串(不是物件),要自己 parse
const ev = JSON.parse(u.payload); // { roomId, threadId, invocationId, actorName, args }
if (!handled.has(ev.invocationId)) {
await handle(ev);
handled.add(ev.invocationId);
}
// 推進游標=下一次請求時 ack 掉它(伺服器會永久刪除 seq < offset 的列)。
// ⚠️ 只在真的處理完才推進:推進即刪除,中途當掉就拿不回來了。
offset = u.seq + 1;
}
// 單批上限 100;滿批代表還有更多。這個迴圈成功時不睡 ⇒ 下一圈立刻再拉,
// 不會卡在「每 25 秒才前進 100 則」。若你在這裡加了 sleep,就必須先判斷滿批。
}
}
await main();
7.2 SSE 版(常駐連線)
差別只有三處:游標語意(Last-Event-ID =「我最後收到的」,與 long-poll 的 offset 差一)、沒有 ack(所以多了一段選用的定期清列)、以及要自己解析串流——EventSource 用不了,見 2.3。
// Aigora bot client — SSE 版(常駐連線;收指令 → 回話 → 斷線自動補回)
//
// BOT_TOKEN=<管理員給你的 token> node bot-sse.mjs
//
// 零依賴,Node 18 以上即可(本檔在 Node 24 實測)。
//
// ⚠️ **不能用瀏覽器/Node 內建的 `EventSource`**:這個端點只吃
// `Authorization: Bearer`,而 EventSource 依規範送不了自訂標頭(也沒有票券可換)。
// 所以這裡用 fetch 拿串流自己解析——`Last-Event-ID` 也就要自己維護。
const BASE = process.env.AIGORA_BASE ?? 'https://aigora.proty.pe';
const TOKEN = process.env.BOT_TOKEN;
if (!TOKEN) {
console.error('缺 BOT_TOKEN 環境變數');
process.exit(1);
}
const AUTH = { authorization: `Bearer ${TOKEN}` };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const handled = new Set(); // invocationId;正式環境請落地存放
/** 「我最後收到的 seq」。⚠️ 與 long-poll 的 offset 差一:這個值不含在下一批裡。
* 0=從頭開始。正式環境要持久化。 */
let lastEventId = Number(process.env.AIGORA_LAST_EVENT_ID ?? 0);
async function api(method, path, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: body === undefined ? AUTH : { ...AUTH, 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${await res.text()}`);
return res.json();
}
async function reply(ev, text) {
const path = ev.threadId
? `/api/bot/threads/${ev.threadId}/messages`
: `/api/bot/rooms/${ev.roomId}/messages`;
const msg = await api('POST', path, { content: text });
console.log(`→ 已回 ${msg.id}`);
}
async function handle(ev) {
const [sub, ...rest] = ev.args;
console.log(`← ${ev.actorName}:${ev.args.join(' ')}`);
if (sub === 'ping') return reply(ev, 'pong 🏓');
if (sub === 'echo') return reply(ev, `${ev.actorName} 說:${rest.join(' ')}`);
return reply(ev, `我不認得「${sub ?? ''}」。可用:ping、echo`);
}
/** 解析一個 SSE 事件區塊(以空行分隔的那一段)。
* 回 'auth-invalid' 代表收到終止事件、呼叫端必須停止且**不要重連**。 */
async function onEventBlock(block) {
let name = 'message';
let data = '';
let id = null;
for (const line of block.split('\n')) {
if (line === '' || line.startsWith(':')) continue; // `:hb` 是心跳(每 15 秒),忽略
if (line.startsWith('event:')) name = line.slice(6).trim();
else if (line.startsWith('data:')) data += (data ? '\n' : '') + line.slice(5).replace(/^ /, '');
else if (line.startsWith('id:')) id = line.slice(3).trim();
}
if (name === 'auth-invalid') {
console.error(`auth-invalid(${data}):憑證失效——伺服器即將收線,**不要自動重連**`);
return 'auth-invalid';
}
if (name !== 'update') return; // 未知事件型別:忽略而不是當錯誤(協定可能會加新的)
const u = JSON.parse(data); // { seq, type, payload, createdAt }
const ev = JSON.parse(u.payload);
if (!handled.has(ev.invocationId)) {
await handle(ev);
handled.add(ev.invocationId);
}
// 游標推進到「我收到的最後一則」。id: 與 payload 裡的 seq 是同一個值。
lastEventId = Number(id ?? u.seq);
}
/** 讀一條已建立的串流直到結束。回 'auth-invalid' 或 'eof'。 */
async function pump(body) {
const decoder = new TextDecoder();
let buf = '';
for await (const chunk of body) {
// ⚠️ 一則事件可能被切在兩個 chunk 之間 ⇒ 沒讀到空行之前不能當成完整事件。
buf += decoder.decode(chunk, { stream: true });
let i;
while ((i = buf.indexOf('\n\n')) !== -1) {
const block = buf.slice(0, i);
buf = buf.slice(i + 2);
if ((await onEventBlock(block)) === 'auth-invalid') return 'auth-invalid';
}
}
return 'eof';
}
async function main() {
const me = await api('GET', '/api/bot/me');
console.log(`我是 /${me.handle}(${me.name}),授權 ${me.rooms.length} 間房:` +
me.rooms.map((r) => `${r.title}=${r.id}`).join('、'));
await api('PUT', '/api/bot/commands', [
{ name: 'ping', description: '測試連線', usage: `/${me.handle} ping` },
{ name: 'echo', description: '把你說的話回一遍', usage: `/${me.handle} echo <文字>` },
]);
/* SSE 沒有「確認」這個動作 ⇒ 送出過的事件會留到 24 小時 TTL 才清。
偶爾打一次 long-poll 帶上游標,就能讓伺服器把已處理的列清掉(timeout=0=不 hold)。
這是選用的,不做也不會壞。 */
const ack = setInterval(() => {
if (lastEventId > 0) {
fetch(`${BASE}/api/bot/updates?offset=${lastEventId + 1}&timeout=0`, { headers: AUTH })
.catch(() => {}); // 清列失敗無妨,下次再說
}
}, 60_000);
ack.unref?.();
for (;;) {
let res;
try {
res = await fetch(`${BASE}/api/bot/stream`, {
headers: {
...AUTH,
accept: 'text/event-stream',
// 0 就不要帶:沒帶=從頭給(含目前佇列裡全部還在的事件)
...(lastEventId > 0 ? { 'last-event-id': String(lastEventId) } : {}),
},
});
} catch (err) {
console.error(`連線失敗(${err.message}),5 秒後重試`);
await sleep(5000);
continue;
}
if (res.status === 401) {
console.error('401:token 已失效或 bot 已停用——重試沒有意義,換一把再啟動');
break;
}
if (!res.ok) {
console.error(`HTTP ${res.status},5 秒後重試`);
await sleep(5000);
continue;
}
console.log(`已連上(從 seq > ${lastEventId} 開始補)`);
const why = await pump(res.body);
if (why === 'auth-invalid') break;
/* EOF 有兩種來源,位元上分不出來:
① 網路斷了 ⇒ 應該重連(帶著 lastEventId,漏掉的會補回來)
② 你自己另開了一條連線把這條頂掉 ⇒ 重連會把新的那條再頂掉,兩邊互踢
所以「一顆 bot 只跑一個行程」是這條路的前提。 */
console.error('連線結束,1 秒後重連');
await sleep(1000);
}
clearInterval(ack);
}
await main();
8. 容易寫錯的七件事
- 游標差一:long-poll 的
offset是「下一則」,SSE 的Last-Event-ID是「最後一則」。兩者混用會漏掉或重複收一則。 - 滿批不代表結束:
updates.length === 100時要立刻再拉,不要進入下一輪等待——否則 backlog 大時每 25 秒才前進 100 則。 - 只在真的處理完才推進
offset:推進即刪除,中途當掉就拿不回來了。 - 收到
auth-invalid不要自動重連:那不是網路問題,是鑰匙失效了。 - 回錯 scope:
threadId不是null時要回討論串端點;打到房間端點會被 409 拒絕(不會靜默寫到主欄)。 - 用
invocationId做冪等:重連、重送、以及「處理到一半當掉」都可能讓你看到同一則事件兩次。 - SSE 的心跳與未知事件要忽略、不能當錯誤(見 2.3):這是實務上最常見的一種「看起來完全正常但其實已經聾了」——連線在、伺服器照推、房裡也沒有任何提示,只有你的 client 什麼都不做。自我檢查:把你的 parser 餵一段
:hb空行區塊,它應該安靜地繼續等下一則。
9. 已知行為
走 long-poll 的 bot,在兩次 poll 之間的空檔被觸發時,房裡會多出一則「已排入佇列,Bot 連線後即會處理(保留 24 小時)」的系統訊息——即使你的 bot 幾十毫秒後就回來了。這是「觸發當下有沒有連線在等」的字面判定;走 SSE(常駐連線)不會有這個現象。
10. 錯誤碼一覽
錯誤回應一律是 { "error": "<碼>", "message": "<人話>" }——唯一的例外是 404 room not found,它只有 error(刻意不多說一個字)。
| 碼 | error | 哪裡 | 什麼時候 |
|---|---|---|---|
| 400 | invalid content | 三條寫入端點 | content 不是字串/去空白後為空/超過 8000 字 |
| 400 | invalid title | 開一條新討論串 | 有給 title 但不是 1–100 字 |
| 400 | invalid offset`/`invalid timeout | long-poll | 參數不是 0 以上的整數 |
| 400 | invalid since | SSE | Last-Event-ID/?since 不是 0 以上的整數 |
| 400 | invalid commands | 註冊指令 | 見 §4;message 會指出第幾項、哪個欄位 |
| 401 | unauthorized | 全部端點 | token 無效/已撤銷/已到期,或 bot 已被停用 |
| 403 | room archived | 寫入端點 | 房間或其所屬工作區已封存(唯讀;讀取端點照樣放行) |
| 404 | room not found | 吃 :roomId 的端點 | 房不存在/正在刪除/這顆 bot 沒被授權——三者刻意同一個回應(不透露房間存不存在) |
| 404 | thread not found | 串內發言 | 串不存在/串在未授權的房——刻意同一個回應 |
| 409 | replaced | long-poll | 你自己開了第二條 outbound 連線(一顆 bot 只能有一條) |
| 409 | main-scope-only | 主欄發言 | body 帶了 threadId(討論串請用 §5.2 的端點) |
| 409 | root-not-accepted | 開一條新討論串 | body 帶了 rootMessageId(root 一律由伺服器自建) |
| 409 | thread-deleted | 串內發言 | 那條串正在刪除收尾——重試沒有意義,不要重送 |
| 429 | rate limited | 三條寫入端點 | 超過 30 則/分鐘(見 §6) |
error是給程式判斷的、message是給人看的——message的文案會變(裡面還帶 bot 名字),請只依error與狀態碼分支。- 5xx 不在此列:那代表伺服器出錯而不是你送錯,可以退避後重試。
⚠️ 使用者那一側還有一個 400 payload too large:使用者打的指令組成 payload 後超過 8192 bytes 時,事件根本不會入列(使用者當場看到錯誤),你的 bot 什麼都不會收到——不是你漏收。