Aigora Bot 協定

v0.78.0 給要寫 bot client 的人

Bot 跑在你自己的機器上、不需要公開 URL——它主動連出來取事件,處理完再用 inbound 端點把結果送回房間。使用者在房裡打 /plm show 123,你的 bot 就會收到一則 command 事件。

0. 開始之前

  1. 管理員在後台建立 bot,取得 handle(例如 plm)與一把 token
  2. 管理員把 bot 授權給要用的房間(未授權的房,bot 讀不到也寫不到)。
  3. Bot 啟動後:PUT /api/bot/commands 註冊子指令 → 開一條 outbound 連線等事件。

0.1 你會從管理員拿到什麼

項目說明
handlebot 在房裡的指令前綴,例如 plm ⇒ 使用者打 /plm show 123
token32 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
SSELast-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什麼時候
400invalid offset / invalid timeout參數不是非負整數
401unauthorizedtoken 失效(撤銷/停用/到期)。重連前先換一把有效的 token
409replaced你的另一條連線把這條取代了(一顆 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-pollSSE
你自己開了第二條連線舊的立刻回 409 replaced舊的直接 EOF
token 撤銷/bot 停用/token 到期401 unauthorizedauth-invalid 事件後 EOF
房間授權被撤銷不收線——連線不綁房,逐則事件會自己被過濾掉

2.5 事件保留

  • 未取走的事件保留 24 小時,之後由每日清理刪除。
  • 「送出成功」不是刪除的理由——只有三條:long-poll 的 offset ack、24 小時 TTL、以及送出前判定「這則已經不能送」(例如那間房的授權在事件入列之後被撤銷了)。
  • client 處理到一半當掉不會遺失:沒有推進 offset 之前,重連還拿得到同一批。

3. command 事件

目前 type 只有一種:commandpayloadJSON 字串(要自己 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 字),不可重複
descriptionusage字串,各 ≤ 200

任一不合 ⇒ 400,訊息會指出第幾項、哪個欄位

註冊成功後,該 bot 當下有授權的每一間房都會即時收到通知,前端的 / 自動完成立刻更新。使用者打 /plm 就會看到你註冊的子指令與說明。

指令一律是 /<handle> <子指令> [args…]不能註冊頂層指令——這樣不會與內建指令(aiclearcompactmodelstopusagewhohelp)或其他 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
authorNamebot 的顯示名(管理員設的,不是 handle)
content落庫後的內容(前後空白已去除)
createdAtepoch 毫秒

物件上還有一批前端渲染用的欄位(replyPreviewstepsCountdurationMs…),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完整的討論串物件(含 idtitle——省略 title 時伺服器自動命名的結果也在裡面,不必再打一次 GET),messageId 是那則被當成起點的主欄訊息 id。
  • ⚠️ 不接受 rootMessageId(帶了回 409 root-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 從哪來——三個來源,都不需要寫死在設定檔裡

  1. GET /api/bot/rooms(或 GET /api/bot/me)列出目前授權的每一間房,每一筆都帶 id。啟動時打一次就有全部的 id。
  2. 每一則 command 事件的 payload 都帶 roomId(見 3)⇒ 回話直接用事件裡的值,不必查表。
  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 字;descriptionusage ≤ 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. 容易寫錯的七件事

  1. 游標差一:long-poll 的 offset 是「下一則」,SSE 的 Last-Event-ID 是「最後一則」。兩者混用會漏掉或重複收一則。
  2. 滿批不代表結束updates.length === 100 時要立刻再拉,不要進入下一輪等待——否則 backlog 大時每 25 秒才前進 100 則。
  3. 只在真的處理完才推進 offset:推進即刪除,中途當掉就拿不回來了。
  4. 收到 auth-invalid 不要自動重連:那不是網路問題,是鑰匙失效了。
  5. 回錯 scopethreadId 不是 null 時要回討論串端點;打到房間端點會被 409 拒絕(不會靜默寫到主欄)。
  6. invocationId 做冪等:重連、重送、以及「處理到一半當掉」都可能讓你看到同一則事件兩次。
  7. SSE 的心跳與未知事件要忽略、不能當錯誤(見 2.3):這是實務上最常見的一種「看起來完全正常但其實已經聾了」——連線在、伺服器照推、房裡也沒有任何提示,只有你的 client 什麼都不做。自我檢查:把你的 parser 餵一段 :hb 空行區塊,它應該安靜地繼續等下一則。

9. 已知行為

走 long-poll 的 bot,在兩次 poll 之間的空檔被觸發時,房裡會多出一則「已排入佇列,Bot 連線後即會處理(保留 24 小時)」的系統訊息——即使你的 bot 幾十毫秒後就回來了。這是「觸發當下有沒有連線在等」的字面判定;走 SSE(常駐連線)不會有這個現象。

10. 錯誤碼一覽

錯誤回應一律是 { "error": "<碼>", "message": "<人話>" }——唯一的例外是 404 room not found,它只有 error(刻意不多說一個字)。

error哪裡什麼時候
400invalid content三條寫入端點content 不是字串/去空白後為空/超過 8000 字
400invalid title開一條新討論串有給 title 但不是 1–100 字
400invalid offset`/`invalid timeoutlong-poll參數不是 0 以上的整數
400invalid sinceSSELast-Event-ID?since 不是 0 以上的整數
400invalid commands註冊指令見 §4;message 會指出第幾項、哪個欄位
401unauthorized全部端點token 無效/已撤銷/已到期,或 bot 已被停用
403room archived寫入端點房間或其所屬工作區已封存(唯讀;讀取端點照樣放行)
404room not found:roomId 的端點房不存在/正在刪除/這顆 bot 沒被授權——三者刻意同一個回應(不透露房間存不存在)
404thread not found串內發言串不存在/串在未授權的房——刻意同一個回應
409replacedlong-poll你自己開了第二條 outbound 連線(一顆 bot 只能有一條)
409main-scope-only主欄發言body 帶了 threadId(討論串請用 §5.2 的端點)
409root-not-accepted開一條新討論串body 帶了 rootMessageId(root 一律由伺服器自建)
409thread-deleted串內發言那條串正在刪除收尾——重試沒有意義,不要重送
429rate limited三條寫入端點超過 30 則/分鐘(見 §6)
  • error 是給程式判斷的、message 是給人看的——message 的文案會變(裡面還帶 bot 名字),請只依 error 與狀態碼分支。
  • 5xx 不在此列:那代表伺服器出錯而不是你送錯,可以退避後重試。

⚠️ 使用者那一側還有一個 400 payload too large:使用者打的指令組成 payload 後超過 8192 bytes 時,事件根本不會入列(使用者當場看到錯誤),你的 bot 什麼都不會收到——不是你漏收。