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 連線等事件。

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 秒一次,忽略即可

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-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。

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(帶了回 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 }(預設不含封存的)

⚠️ 沒有「讀訊息」的端點。Bot 只收得到針對它的指令觸發,讀不到房裡的對話——這是刻意的。

5.5 房間解析的錯誤碼

什麼時候
404房間不存在/已刪除/這顆 bot 沒有被授權(不透露房間存不存在)
403房間已封存(唯讀,寫入端點才會擋)

6. 限制一覽

項目
Bot 發言限速30 則/分鐘(per bot;超過回 429)
訊息內容長度同真人主欄上限
指令 payload序列化後 ≤ 8192 bytes
註冊指令≤ 32 項;name ≤ 24 字;descriptionusage ≤ 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. 容易寫錯的六件事

  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 做冪等:重連、重送、以及「處理到一半當掉」都可能讓你看到同一則事件兩次。

9. 已知行為

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