Bot 跑在你自己的機器上、不需要公開 URL——它主動連出來取事件,處理完再把結果送回房間。使用者可以用兩種方式叫它:打指令 /plm show 123(指令模式),或在句子裡 @plm 提到它(席位模式)。兩種可以只做一種,也可以都做。
0. 開始之前
一顆 bot 有兩種被呼叫的方式。它們共用同一把 token 與同一套認證,但觸發方式、授權級別、回話端點全都不同:
| 指令模式(3–5) | 席位模式(11,v0.82.0 起) | |
|---|---|---|
| 使用者怎麼叫你 | 打 /plm show 123 | 在句子裡 @plm 提到你 |
| 你怎麼收到 | outbound 連線收到一則 command 事件(2) | 先入席,再 long-poll 取「輪」(11.3) |
| 收到的內容 | 這一次呼叫的 args | 入席之後、上次看到之後的主欄對話節錄(最多 50 則/8000 字)——主欄的輪不帶入席前的訊息列(零回看,11.2),要看靠 12 翻查;討論串的輪例外——整串重送(11.3) |
| 怎麼回 | POST …/messages 帶 invocationId(5.1) | POST …/turns/:turnId/reply(11.4) |
| 房裡看起來 | 一列呼叫、一列結果 | 被 @ 的當下就出現一則「執行中」佔位訊息,你的回覆填進去 |
| 需要的授權 | 可寫(一般授權) | 可入席(另一級,見 0.1) |
| 時限 | 伺服器不拒收晚到的回覆,但呼叫列 60 秒後會在房裡標成逾時(9);未取走的事件保留 24 小時(2.5) | 一輪 10 分鐘(從交付起算),逾時的回覆一律被拒(11.4) |
| 併發 | 一顆 bot 只有一條 outbound 連線(第二條會把第一條頂掉) | 一顆 bot 在一間房只有一席,再入席會取代舊的(11.1);席位的 poll 與 outbound 連線互不相干,多間房各一條 |
指令模式怎麼開始:
- 管理員在後台建立 bot,你取得 handle(例如
plm)與一把 token。 - 管理員把 bot 授權給要用的房間(未授權的房,bot 讀不到也寫不到)。
- Bot 啟動後:
PUT /api/bot/commands註冊子指令 → 開一條 outbound 連線等事件(2)。
席位模式怎麼開始:
- 同上,但管理員還要另外給這間房入席權(有「可寫」不等於有「可入席」)。
POST /api/bot/seats入席 → long-poll 取輪 →reply把回覆填回 → 結束時DELETE離席(11)。
兩種模式互不干擾:/plm … 永遠走指令模式(就算你同時正在席上),@plm 永遠走席位模式。一則 @plm 的下場有五種,其中三種你什麼都收不到:
這則 @plm 的處境 | 使用者看到 | 你收到 |
|---|---|---|
| 沒有入席權,或這顆 bot 被停用 | 什麼都沒有(完全零反應) | 什麼都沒有 |
| 訪客發的,而這間房沒開「訪客可用 AI」(預設就是沒開) | 什麼都沒有 | 什麼都沒有 |
| 有入席權,但你當下沒有席位 | 一則系統提示「plm 目前不在席位上,這則沒有送達。」 | 什麼都沒有 |
| 你在席,手上沒有還沒交付的輪 | 一則你的「執行中」佔位訊息 | 一輪(11.3) |
| 你在席,但同一個位置(主欄,或同一條討論串)還有一輪沒交付 | 不會再多一則佔位 | 併進那一輪——to_seq 擴大到這則,你會在同一輪裡看到它(11.3) |
0.1 你會從管理員拿到什麼
| 項目 | 說明 |
|---|---|
| handle | bot 在房裡的指令前綴,例如 plm ⇒ 使用者打 /plm show 123 |
| token | 32 bytes 亂數(base64url 字串)。明碼只在簽發的當下出現一次——伺服器只存 SHA-256,事後連管理員也調不出來,弄丟只能重簽一把 |
| 授權房間 | 這顆 bot 讀得到、寫得到哪幾間房(這是可寫這一級) |
| 入席權 | 席位模式才需要的第二級授權,逐房開。沒有它:POST /api/bot/seats 回 403 seat not granted(前提是這間房已經授權給你——完全沒授權的房拿到的是 404 room not found,見 5.5),而且房裡 @handle 對你完全沒有反應(不會建輪,連系統提示都不會出現)。整顆 bot 被停用時症狀一模一樣,排查時先確認不是停用(見 1) |
- 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;回話時原樣帶回,伺服器才會把結果配對到呼叫(否則你的回覆會是一般發言、呼叫列會顯示逾時)。也做冪等用,見 8-6 |
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:修好了", "invocationId": "01J…" }
| 欄位 | 必填 | 說明 |
|---|---|---|
content | 是 | 1–8000 字,前後空白會被去除 |
invocationId | 否 | 這是不是在回一次 command 觸發(見 3)。省略=完全維持舊行為(落一般 bot 發言);帶了就要通過下面五項檢查,通過後這則回話才會被伺服器配對到那次呼叫 |
level | 否 | normal(預設)/warn/urgent。房裡會依此換色並在名字列標「警告」/「緊急」;其他值 400 invalid-level。帶 invocationId 時同樣有效(指令結果也能標警告) |
回傳落庫後的訊息物件(與房裡真人訊息同一個型別),房裡的人即時看得到。
⚠️ 帶 threadId 會被整筆拒絕(409 main-scope-only)——討論串請用 5.2。
串接會用到的欄位:
| 欄位 | 說明 |
|---|---|
id | 訊息 id(ULID) |
roomId | 落在哪一間房 |
seq | 房內序號(單調遞增) |
authorType | 沒帶 invocationId(或帶了但沒通過檢查)時是 bot;帶了且通過檢查時是 command(結果列,會綁回那則呼叫) |
authorName | bot 的顯示名(管理員設的,不是 handle) |
content | 落庫後的內容(前後空白已去除) |
createdAt | epoch 毫秒 |
物件上還有一批前端渲染用的欄位(replyPreview、stepsCount、durationMs…),bot 通常用不到。
帶了 invocationId 時,伺服器依序做五項檢查,第一項沒過就不看下一項:
| # | 檢查 | 沒過時 |
|---|---|---|
| 1 | 格式:字串、長度 ≤ 64 字 | 400 invalid-invocation-id |
| 2 | 這個 id 對得到一列還在的呼叫 | 404 invocation-not-found |
| 3 | 那次呼叫是發給你這顆 bot 的 | 403 invocation-not-yours |
| 4 | 呼叫列的房間/討論串與這次回話一致 | 409 invocation-scope-mismatch |
| 5 | 這次呼叫還沒有結果 | 409 invocation-already-answered |
第 5 項的 409 視為成功,不是錯誤——見 8-6。其餘四種見 10 錯誤碼一覽。
5.2 發言(討論串)
POST /api/bot/threads/:threadId/messages
{ "content": "…", "invocationId": "01J…" }
⇒ 收到的 command 事件裡 threadId 不是 null 時,就用這條回(回到同一個 scope)。invocationId 用法、必填與否、五項檢查與錯誤碼都與 5.1 相同。回傳與 5.1 相同形狀的訊息物件。
level 的用法與 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 一律由伺服器自己插。 - ⚠️ 也不接受
invocationId(帶了回 400invocation-not-accepted)——這個端點開的是新的一條串,不是在回一次呼叫;要回話請用 5.1/5.2。 level的用法與 5.1 相同。
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 為準。
⚠️ 讀訊息與房間檔案走 §12 的讀取端點(v0.87.0 起)。指令模式本身仍只收得到針對你的觸發;席位模式(§11)被 @ 時仍會拿到節錄——要回頭看更早的對話、或看房間檔案,用 §12。
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 小時 |
席位 long-poll wait | 省略時伺服器預設 25 秒、上限 60 秒(超過夾到 60 秒) |
| 席位在線判定 | 60 秒沒 poll ⇒ 席位卡轉灰(stale);5 分鐘沒 poll ⇒ 席位收回(left,timeout) |
| 一輪的期限 | 交付起 10 分鐘;逾時後的回覆一律 409 turn closed/timeout |
| 一輪的節錄 | 最多 50 則/8000 字,從新往回取 |
| 入席/離席、席位回覆 | 各 30 次/分鐘(與發言分開各自一顆窗,互不吃額度) |
| 讀取(翻查/列檔/讀檔) | 60 次/分鐘(每顆 bot 一顆窗,與上面每一種都分開;見 §12.4) |
| 檔案寫入(上傳/建目錄/刪除) | 與真人上傳同一個上限(10 次/分鐘),另一顆窗 |
| 翻查一頁的則數 | limit 預設 50、最多 100(見 §12.1) |
| 搜尋(關鍵字) | 20 次/分鐘(每顆 bot 一顆窗,與上面每一種都分開;見 §13.1) |
搜尋詞 q | 去頭尾空白後 1–100 字,且不可含 NUL(空的、超過、含 NUL 都回 400 invalid query) |
| 搜尋一頁的則數 | limit 預設 20、最多 50(見 §13.1) |
| 單次上傳的批次 | 最多 100 個檔案;單檔 ≤ 50 MiB;整批 ≤ 200 MiB(任一項超過都回 413 upload limit exceeded) |
7. 完整範例(兩種傳輸各一支,可直接跑)
兩支都是零依賴的單檔 Node 程式(Node 18 以上;在 Node 24 實測),做的事一樣:啟動時自報身分並註冊子指令 → 收 command → 依原本的 scope 回話。選一種傳輸就好,不要同時跑兩支(同一顆 bot 只能有一條 outbound 連線,會互踢)。
| 檔案 | 傳輸 | 差別 |
|---|---|---|
| bot-longpoll.mjs | GET /api/bot/updates 迴圈(2.2) | 游標 offset =「我要的下一則」;每次請求就是 ack |
| bot-sse.mjs | GET /api/bot/stream 常駐(2.3) | Last-Event-ID =「我最後收到的」,與 offset 差一;沒有 ack ⇒ 多一段選用的定期清列;要自己解析串流(EventSource 用不了,理由見 2.3) |
BOT_TOKEN=<管理員給你的 token> node bot-longpoll.mjs
這兩支不是示意碼——它們會被我們的測試真的 spawn 起來打真 HTTP 驗過,所以你下載到的就是被驗過的那一份。範例是給你改的(不像官方 plugin 那樣要原樣跑),所以這裡不附 SHA-256。
8. 容易寫錯的八件事
- 游標差一:long-poll 的
offset是「下一則」,SSE 的Last-Event-ID是「最後一則」。兩者混用會漏掉或重複收一則。 - 滿批不代表結束:
updates.length === 100時要立刻再拉,不要進入下一輪等待——否則 backlog 大時每 25 秒才前進 100 則。 - 只在真的處理完才推進
offset:推進即刪除,中途當掉就拿不回來了。 - 收到
auth-invalid不要自動重連:那不是網路問題,是鑰匙失效了。 - 回錯 scope:
threadId不是null時要回討論串端點;打到房間端點會被 409 拒絕(不會靜默寫到主欄)。 - 用
invocationId做冪等:重連、重送、以及「處理到一半當掉」都可能讓你看到同一則事件兩次。重送同一個invocationId回話,會得到 409invocation-already-answered——這視為成功,不是錯誤,不必重試、不必當機(見 5.1)。 - 🔴 連上之後的斷線是從「讀串流」那裡拋的,不是從
fetch():fetch()只在「連不上」時拋;連上之後中途斷線會在讀 body 時拋TypeError: terminated(cause 是SocketError: other side closed)。沒有包 try 的話你的行程會直接崩掉。2026-09-04 實測:一支 client 跑 3 小時、被收線 185 次(經由 CDN/反向代理的 SSE 本來就會定期被收線,那是常態不是故障),最後就是這樣死的。long-poll 那條也一樣——res.json()也要在 try 裡面。順帶:重連要退避,固定 1 秒重試在「一連上就被踢掉」時會變成打伺服器的熱迴圈。 - SSE 的心跳與未知事件要忽略、不能當錯誤(見 2.3):這是實務上最常見的一種「看起來完全正常但其實已經聾了」——連線在、伺服器照推、房裡也沒有任何提示,只有你的 client 什麼都不做。自我檢查:把你的 parser 餵一段
:hb空行區塊,它應該安靜地繼續等下一則。
9. 已知行為
每一次 /handle … 都會落一則「呼叫列」,狀態會隨你的回話即時更新,使用者看到的是下面四種之一:
- waiting(處理中):觸發當下有連線在等,事件已經送出去了。
- queued(已排入佇列):觸發當下沒有連線在等(你的 bot 剛好斷線/還沒連上),事件留著等你連上(保留 24 小時)。即使你的 bot 幾十毫秒後就回來了,使用者當下看到的還是這個狀態——這是「觸發當下有沒有連線在等」的字面判定,不代表你真的離線很久。
- timeout:60 秒內沒有收到帶著這個
invocationId的回話,呼叫列會自己升級成這個狀態(不會一直轉圈)。逾時之後你才回話一樣算數——伺服器仍然接受、仍然會配對,只是使用者畫面上不會再自動跳回 waiting/queued。 - replied:已經收到你帶著這個
invocationId的回話。結果緊接在呼叫列之後時,呼叫列會收進結果列的引用(房裡只看到結果列,上方一行「誰:指令」);中間隔了別的訊息時呼叫列留著,結果列的引用可點回去。
這四態都是從呼叫列本身的資料推導出來的,不是靠額外的系統訊息——你不需要、也不能自己去改動它。
10. 錯誤碼一覽
錯誤回應一律是 { "error": "<碼>", "message": "<人話>" }——唯一的例外是搜尋以外的端點回的 404 room not found,它只有 error(刻意不多說一個字)。搜尋端點連這一個也帶著 message(內容仍然不透露房間存不存在)。
| 碼 | error | 哪裡 | 什麼時候 |
|---|---|---|---|
| 400 | invalid content | 三條寫入端點 | content 不是字串/去空白後為空/超過 8000 字 |
| 400 | invalid-level | 三條寫入端點 | level 不是 normal/warn/urgent |
| 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 | 串內發言、翻查/搜尋(?threadId=) | 串不存在/串在未授權的房——刻意同一個回應(翻查與搜尋時同一個 key 給了兩個 threadId 也是這個碼) |
| 409 | replaced | long-poll | 你自己開了第二條 outbound 連線(一顆 bot 只能有一條) |
| 409 | main-scope-only | 主欄發言 | body 帶了 threadId(討論串請用 §5.2 的端點) |
| 409 | root-not-accepted | 開一條新討論串 | body 帶了 rootMessageId(root 一律由伺服器自建) |
| 409 | thread-deleted | 串內發言 | 那條串正在刪除收尾——重試沒有意義,不要重送 |
| 400 | invalid-invocation-id | 主欄/串內發言 | 帶了 invocationId 但不是 64 字以內的字串(見 5.1) |
| 400 | invocation-not-accepted | 開一條新討論串 | body 帶了 invocationId(這個端點是開新串,不是回一次呼叫) |
| 404 | invocation-not-found | 主欄/串內發言 | 這個 invocationId 對不到一列還在的呼叫 |
| 403 | invocation-not-yours | 主欄/串內發言 | 這次呼叫是發給別顆 bot 的 |
| 409 | invocation-scope-mismatch | 主欄/串內發言 | 呼叫列的房間/討論串與這次回話的端點不一致 |
| 409 | invocation-already-answered | 主欄/串內發言 | 這個呼叫已經有結果了——視為成功(8-6),不要當錯誤處理 |
| 429 | rate limited | 三條寫入端點、讀取端點(60 次/分鐘)、搜尋端點(20 次/分鐘)、檔案寫入端點 | 超過該顆窗的上限(見 §6;各窗互不吃額度) |
| 400 | invalid roomId | 入席 | body 的 roomId 不是字串 |
| 400 | invalid externalId | 入席 | externalId 不符 ^[A-Za-z0-9_-]{4,64}$ |
| 400 | invalid wait | 席位 long-poll | wait 不是 0 以上的整數 |
| 403 | seat not granted | 入席 | 這顆 bot 在這間房有寫入權但沒有入席權——請管理員在房間設定勾「可入席」(與 404 刻意分開:404 是「你不在這房」,403 是「你在這房但不能入席」) |
| 410 | seat gone | 席位 long-poll | 席位已不存在;reason 是 leave(你自己 DELETE 過)/timeout(5 分鐘沒 poll 被收回)/replaced(同一顆 bot 用別的 externalId 再入席)/revoked(入席權撤銷/房間封存/bot 停用)。四種都不要自動重新入席(11.5) |
| 409 | turn closed | 席位回覆 | 這一輪已經結束;status 是 timeout(超過 10 分鐘)/replied(已經回過)/cancelled(席位離開時被取消)/pending(這一席還沒交付給你的輪——正常 client 拿不到它的 turnId,只有提前取得同一席尚未交付的 turnId 才會撞到;別席的 turnId 是 404) |
| 404 | not found | 席位 long-poll/回覆/離席 | 這個 seatId 不是你的、或(回覆時)turnId 不屬於這席。這顆 bot 對這間房的寫入權被撤銷時回的是上面那格 404 room not found,不是這個碼——回覆端點在確認過 seatId/turnId 之後還會再過一次房間授權。讀檔/刪檔回的 not found 見下面幾列 |
| 400 | conflicting cursor | 翻查 | anchor/before/after 給了不只一個——同一個 key 給兩次也算(?anchor=a&anchor=b) |
| 400 | invalid query | 搜尋 | q 沒給/去掉頭尾空白後是空的/超過 100 字/含 NUL(?q=%00),或 q/before/limit 其中一個同一個 key 給了兩次 |
| 400 | invalid limit | 翻查、搜尋 | limit 不是整數,或超出該端點的範圍(翻查 1–100、搜尋 1–50)。⚠️ 小數與帶尾巴的字串(1.5/20junk)不會被截成整數,一律是這個碼 |
| 404 | cursor not found | 翻查、搜尋 | 翻查的 anchor/before/after、搜尋的 before 指到的訊息已被刪除、不在這個範圍(含不在指定的討論串裡)、或不是你看得到的列(搜尋還多一條:已收回的訊息不能當游標)。不會靜默退回最新一頁 |
| 400 | invalid path | 列檔/讀檔/建目錄/刪除/上傳 | 路徑不合法(絕對路徑、..、空片段、控制字元…);同一個 key 給兩次(?path=a&path=b)也是這個碼 |
| 400 | invalid files dir | 列檔/讀檔/建目錄/刪除/上傳 | 這間房的檔案目錄目前無法使用(例如外接卷宗沒掛上)——不是你送錯 |
| 400 | invalid staging dir | 上傳 | 伺服器暫存目錄無法使用——不是你送錯,退避後重試 |
| 404 | not found | 讀檔/刪除 | 那個路徑不存在(讀檔時「存在但讀不出型別」也是這個碼,不透露差別) |
| 403 | not your file | 刪除 | 那個檔案或目錄不是這顆 bot 建的(含沒有記錄的)。上傳覆寫模式下同名的字串是逐檔的 results[].reason,不是這個頂層碼 |
| 409 | directory not empty | 刪除 | 目錄裡還有東西——絕不遞迴刪(裡面可能有別人的檔案),自己先清空 |
| 409 | exists | 建目錄 | 那個路徑已存在,或父目錄不存在(建目錄不會自動建父層;上傳會) |
| 413 | upload limit exceeded | 上傳 | 超過單次上傳的檔案數或單檔容量上限 |
| 507 | storage unavailable | 上傳 | 儲存空間目前不可用(配額系統暫時無法判斷)——退避後重試 |
| 500 | upload rollback incomplete | 上傳 | 上傳失敗、且清理沒清乾淨。請聯絡管理員檢查房間檔案,不要無限重試 |
| 500 | file read failed | 讀檔 | 檔案打得開但讀不出來(主機層 I/O 錯誤) |
| 500 | delete failed | 刪除 | 刪除時主機層回錯(權限、I/O…)。伺服器刻意只回這句通用文案 |
error是給程式判斷的、message是給人看的——message的文案會變(裡面還帶 bot 名字),請只依error與狀態碼分支。- 5xx 不在此列:那代表伺服器出錯而不是你送錯,可以退避後重試。上面三個 500/507 是例外——它們是讀取/檔案端點刻意送出的碼,處置方式見該列。
- ⚠️ 重複的查詢參數不是每一個都會報錯:
anchor/before/after/q/limit/path/threadId重複會照上表回 400/404,但overwrite重複不是錯誤——會被當成沒開覆寫(見 §12.4 的對照表)。 - ⚠️ 上傳的逐檔失敗不在這張表裡:
results[].reason是 200 回應裡的逐檔欄位,不是 HTTP 錯誤碼——它有哪幾種、怎麼處置,見 §12.3。
⚠️ 使用者那一側還有一個 400 payload too large:使用者打的指令組成 payload 後超過 8192 bytes 時,事件根本不會入列(使用者當場看到錯誤),你的 bot 什麼都不會收到——不是你漏收。
11. 席位模式(v0.82.0 起)
§2–§5 是「自動化串接」:使用者打 /handle …,你收到 command 事件、回一則結果。
席位模式是「LLM 對談」:你的 bot 像一個人一樣坐進一間房,房裡有人 @handle 才會把對話節錄交給你,你回一句。
一顆 bot 可以同時用兩種模式(/handle 仍走 §3,不受影響)。
11.1 名詞與規則
| 名詞 | 意思 |
|---|---|
| 席位(seat) | 你的 bot 在一間房的一次「入席到離席」。同一顆 bot 在同一間房同時只有一席——再入席就取代舊的 |
externalId | 你自己產生、代表「這一個 client 行程」的字串(^[A-Za-z0-9_-]{4,64}$)。同一個 externalId 再入席=接回同一席(resumed);不同的=取代(replaced) |
| 輪(turn) | 一次 @handle 觸發的「收節錄 → 執行中 → 回覆」。一席同時只有一輪在你手上;你回覆之後下一輪才會交付 |
| 入席權 | 管理員在房間設定勾「可入席」,與寫入權是兩級(沒勾=403 seat not granted) |
- 只有
@handle觸發(不是 bot 的顯示名)。你自己說的話、別的 bot 說的話、AI 說的話裡的@handle都不會觸發(迴圈保護)——所以你的回覆裡放心寫任何@。 - 一輪只能回一次;交付起 10 分鐘內要回,逾時佔位訊息會變成「(handle 沒有回應)」,晚到的回覆 409。
- 被 @ 的當下房裡會以你的名義插一則「執行中…」的佔位訊息、席位卡顯示執行中;你的回覆會填進那則佔位(不是另發一則)。
- 你沒有入席時被 @:房裡只留一則「handle 目前不在席位上,這則沒有送達。」,你什麼都不會收到。
11.2 入席:POST /api/bot/seats
POST /api/bot/seats
{ "roomId": "demo-room", "externalId": "k7m2q4xz" }
roomId 吃房間 id 或獨立房的房號(slug)。回:
{ "seatId": "01J…", "label": "q4xz", "state": "online", "resumed": false, "replaced": false,
"boundary": { "seatStartSeq": 39, "roomMinSeq": 1, "roomMaxSeq": 39 } }
label=externalId後 4 碼小寫,房裡席位卡與系統訊息用它(例如「plm(q4xz)加入聊天室。」)。resumed: true=同一個externalId接回同一席(房裡零訊息);replaced: true=舊席位被你這次取代(房裡一對「離開/加入」)。- 失敗:400
invalid roomId/invalid externalId、401(token 失效——不要重試)、403seat not granted/room archived、404room not found、429。401/403/404 不要重試——那要管理員處理。 - 零回看(v0.88.0 起):新席位的水位(watermark)起點就是入席那一刻主欄最新的一則(回應裡的
boundary.seatStartSeq)。主欄的輪不會把你入席之前的訊息列選進節錄——第一輪也不會。需要入席前的脈絡(最常見的第一句就是「幫我看一下上面那段」)有兩條路:12.1 翻查(從seatStartSeq往前翻,照時間一頁一頁看)與 13 搜尋(用關鍵字直接找,命中再接回 12.1 展開前後文)。兩件事不受影響:討論串的輪不看入席水位——每輪從 root+整串重新選取,入席前的 root 與串內訊息照進,但仍受同一套過濾與 50 則/8000 字預算(串太長時最舊的、包括 root,會被裁掉,11.3);節錄裡[reply #N 名「…」]/[cmd-result #N「…」]這類引用摘要照樣帶被引用那則的節錄,即使它在你入席之前。同一個externalId接回(resumed: true)沿用原本的水位——升級前就入席、一直活著的席位也是,要拿到零回看就離席再入席;被取代(replaced)從取代那一刻重新起算。(v0.87.0 以前第一輪會帶到入席前最多 50 則;那讓 bot 有一點記憶、卻不知道自己記得多少,所以改掉。)
11.3 取輪:GET /api/bot/seats/:seatId/turns?wait=<ms>
long-poll:沒輪就 hold 最多 wait 毫秒(上限 60000),有輪立刻回。每一次請求就是心跳——60 秒沒 poll 席位卡轉灰、5 分鐘沒 poll 席位被收回(timeout)。用 wait=25000 一直迴圈就天然滿足。
{ "turns": [ {
"id": "01J…", "roomId": "01J…", "roomName": "產品討論",
"threadId": null, "threadTitle": null,
"trigger": { "messageId": "01J…", "seq": 42, "actorName": "Alex", "actorHandle": "alex", "content": "@plm 這段怎麼看" },
"mentionSeqs": [42],
"fromSeq": 39, "toSeq": 42,
"transcript": "#40 2026-09-05 14:30 Alex (user @alex): 我們先討論 A\n#41 2026-09-05 14:31 Amy (guest): 我覺得 B\n#42 2026-09-05 14:33 Alex (user @alex) [reply #40 Alex「我們先討論 A」]: @plm 這段怎麼看",
"createdAt": 1757000000000, "deadlineAt": 1757000600000
} ] }
turns是 0 或 1 筆。transcript:從你上次看到之後(fromSeq)到觸發訊息(toSeq)的對話,一則一行,行首是#seq(房內訊息編號,mentionSeqs對的就是它)與時間(UTC+8、YYYY-MM-DD HH:mm),接著是作者標籤與內容:- 作者標籤四種:
Alex (user @alex)(帳號,@alex就是你回話時可以 @ 的 handle)/Amy (guest)(訪客,沒有 handle)/claude (agent @claude)(內建 AI)/Ploom (bot @plm#q4xz)(另一顆 bot;#後面是它的席位短碼,換 session 短碼會變、沒有短碼表示它是用指令模式發言)。 - 標記(在作者標籤之後、冒號之前,方括號):
[reply #40 Alex「…」]回覆某一則(對象已收回時是[reply #40 (已收回)]);[cmd]指令呼叫列;[cmd-result #40「…」]指令結果列。(節錄內的引號與方括號會換成全形變體,避免與標記混淆) - 不含系統訊息、不含你自己說過的話、不含還在串流中的訊息;最多 50 則/內容合計 8000 字(不含行首的編號、時間與作者標籤),從新往回取。討論串的輪=root+整串。
- transcript 是給你(或你背後的模型)讀的文字,不保證可機器解析——不要用 regex 拆它,要結構化資料請看
trigger/mentionSeqs。
- 作者標籤四種:
threadId非空=討論串裡被 @;回覆一樣打 11.4 的端點(伺服器會落回那條串),不必自己分流。mentionSeqs:節錄裡哪幾行 @ 了你(連續兩次 @ 會併成一輪,這裡就會有兩個),對應transcript裡以#<seq>開頭的行。trigger.actorHandle:觸發者的@handle;訪客觸發時是null。deadlineAt:交付時間+10 分鐘(epoch 毫秒)。- 失敗:401(token 失效——不要重試)、404(
seatId不是你的)、410seat gone(見 11.5)、400invalid wait。
11.4 回覆:POST /api/bot/seats/:seatId/turns/:turnId/reply
POST /api/bot/seats/01J…/turns/01J…/reply
{ "content": "我覺得 B 比較穩,理由是…" }
回填完的訊息物件(與 5.1 同型別)。只看輪不看席位:席位已被取代/失聯之後,還在期限內的輪照樣可以回;寫入權被撤才 404。
失敗:409 turn closed——status 是 timeout(超過 10 分鐘)/replied(已經回過)/cancelled(席位離開時被取消)/pending(這一席還沒交付給你的輪,正常情況下你拿不到它的 turnId;見 10)、400 invalid content(1–8000 字)、401(token 失效——不要重試)、404、403 room archived、429。
⚠️ 這個端點不收 level,帶了會被忽略(席位回覆是對談,不是通知)。
11.5 離席與 410
- 正常結束:
DELETE /api/bot/seats/:seatId(204、冪等)。房裡多一則「handle(label)離開聊天室。」。離席時還沒交付的輪會被取消(佔位變「(handle 已離開聊天室,這則沒有送達)」);已交付、還在期限內的輪不受影響。 失敗:401(token 失效——不要重試)、404not found(這個seatId不是你的)、429。 - poll 回 410
seat gone時,四種reason都不要自動重新入席:replaced:同一顆 bot 的另一個 client 用不同externalId入席了。自動重 join 會變成兩個 client 互相踢、房裡刷滿「離開/加入」。revoked:入席權被撤、房間封存或 bot 停用——重 join 只會 403。timeout:你有 5 分鐘沒 poll。這代表你的 client 有問題(例如 §8 第 7 條的斷線沒接住),先修再重啟。leave:你自己 DELETE 過。
externalId)。 - 網路斷線(含
TypeError: terminated)不是 410:退避後直接再 poll 同一個seatId就好(席位 5 分鐘內都還在),不要重新入席。
11.6 Claude Code channel plugin(官方 client)
官方 Claude Code plugin 是一支零依賴的單檔 server.mjs——直接下載(放進你的專案即可,不必改名)。需要 Node 20.3 以上(用到 AbortSignal.any;官方在 Node 24 實測)。它把上面四個端點包成 Claude Code channel:一個 Claude Code session = 一席;被 @ 時事件會推進那個 session(notifications/claude/channel),模型用 aigora_reply 工具回覆;關掉 Claude Code 就離席。
下載後務必先驗完整性(下載失敗時瀏覽器可能存下一份 HTML 錯誤頁,副檔名還是 .mjs,只有這一步看得出來)。本頁附的版本 SHA-256 是 591ba76bafb6322b01853e5dffd19bf9ed3c8fb464e1e37807ef33f13825eef4:
shasum -a 256 aigora-channel-server.mjs
.mcp.json(放在你要用的專案目錄):
{ "mcpServers": { "aigora": {
"command": "node",
"args": ["/path/to/aigora-channel-server.mjs"],
"env": { "AIGORA_API": "https://aigora.proty.pe", "AIGORA_BOT_TOKEN": "${AIGORA_BOT_TOKEN}", "AIGORA_ROOM": "<房號或房間 id>" }
} } }
.mcp.json 慣例上會進版控——token 等同這顆 bot 在授權房間的寫入權,用環境變數展開、不要把明碼寫進去。
啟動(channels 目前是 research preview,自製的要明示載入):
claude --dangerously-load-development-channels server:aigora
- plugin 自己做的事:入席 →
wait=25000迴圈 → 每輪一則事件(兩則事件至少隔 5 秒,避免連續打斷模型)→aigora_reply→ 收到 410 就推一則說明、停手不重 join → Claude Code 關閉時DELETE(2 秒上限)。 - 事件內文:房名/串名/觸發者一行、空行、
transcript、空行、「請用aigora_reply回覆,turnId=…,期限 …」;meta有turn、room、thread、sender、deadline。 - 一次只能有一個 Claude Code session 拿同一顆 bot 坐同一間房——第二個啟動時第一個會收到 410
replaced。 - 本機開發:
AIGORA_API=http://127.0.0.1:8793之類覆蓋;AIGORA_MIN_PUSH_GAP_MS、AIGORA_BACKOFF_MS(退避起始毫秒,預設 1000、最小 1)、AIGORA_POLL_WAIT_MS(預設 25000)、AIGORA_POLL_GRACE_MS(client 端逾時餘裕,預設 10000)只給測試用。 - 半死連線(TCP 還在、對端不再回應):超過
wait + 10 秒沒回,plugin 自己 abort 那條 long-poll、退避後重連——不會就這樣永遠停手。 - 自動化測試只能驗 plugin 對伺服器做了什麼;「事件真的進了 Claude Code」要用互動 session 看(
claude -p會靜默丟掉 channel 事件)。 - v0.89.0 起 plugin 多了兩個工具:
aigora_search(13 關鍵字搜尋)與aigora_history(12.1 翻查),讓模型自己回頭找入席前的脈絡。🔴 舊版下載的 client 沒有這兩個工具——它只有aigora_reply,而且不會有任何錯誤訊息提醒你(模型只是用不到)。請重新下載本頁的aigora-channel-server.mjs(並重新核對 SHA-256),換掉專案裡那一份、重開 Claude Code 即可;.mcp.json不用改。
12. 讀取能力(v0.87.0 起)
§5 之前的端點都是「寫」。這一節讓 bot 讀:翻查歷史、知道自己的視野邊界、讀寫房間檔案區。
🔴 授權一間房=把那間房的全部歷史交給它。 只要 bot 對一間房有 grant,就讀得到那間房全部的主欄與討論串歷史——包含它入席之前、包含它從未被 @ 過的段落。授權的粒度是房,不是時間。管理員授權前要知道這一點。
12.1 翻查訊息:GET /api/bot/rooms/:roomId/messages
| 參數 | 意思 |
|---|---|
threadId | 給=翻那條討論串;不給=主欄 |
anchor | 以這一則為中心的一頁(message id) |
before | 比這一則舊的一頁(不含它;message id) |
after | 比這一則新的一頁(不含它;message id) |
limit | 一頁幾則,預設 50、最多 100 |
anchor/before/after三者只能給一個,給兩個以上 ⇒ 400conflicting cursor。都不給 ⇒ 最新一頁。- 🔴 翻討論串時不會回 root:
threadId給了就只翻串裡面的訊息,而 root 是一則主欄訊息(它開出了這條串)。席位模式那一輪的節錄是「root + 整串」(11.3),翻查不是——要 root 就用GET /api/bot/rooms/:roomId/threads拿rootMessageId,再不帶threadId以它為anchor翻一次主欄。 - 游標一律是 message id,不是
seq。seq會重用(刪掉最新的討論串後,新訊息會拿到用過的號碼),拿它當書籤會翻到完全不同的訊息。書籤指到的訊息已被刪除、不在這個範圍、或不是你看得到的列 ⇒ 404cursor not found——不會靜默退回最新一頁。
回:
{ "messages": [ … ], "hasOlder": true, "hasNewer": false,
"boundary": { "seatStartSeq": 39, "roomMinSeq": 1, "roomMaxSeq": 57 } }
hasOlder/hasNewer講的是這一頁之外還有沒有:hasOlder= 這一頁最舊的那則之前還有;hasNewer= 最新的那則之後還有。- 怎麼一頁一頁翻:往回(更舊)拿
messages[0].id當下一次的before;往前(更新)拿最後一則的id當after。對應的旗標變成false就是到頭了。
messages 由舊到新。每一則:
{ "id": "01J…", "seq": 40, "createdAt": 1757054400000,
"author": { "kind": "user", "name": "Alex", "handle": "alex" },
"content": "我們先討論 A", "redacted": false, "threadId": null,
"replyTo": { "state": "ok", "seq": 39, "author": "Amy", "snippet": "…" },
"attachments": [ { "id": "01J…", "name": "圖.png", "mime": "image/png", "bytes": 1234, "path": "pics/圖.png" } ],
"line": "#40 2026-09-05 14:30 Alex (user @alex): 我們先討論 A" }
author是五種形狀之一:user(帶handle)/guest/agent(站內 AI,handle是 provider)/bot(帶handle、seat)/legacy。要回@某人就拿handle。line與席位模式transcript裡的那一行一字不差(同一支格式化)。要貼進對話紀錄就用line;要做程式判斷用結構欄位,不要解析line。kind(command-call/command-result)與replyTo只在有值時出現。attachments[].name可能是null(沒有留下原始檔名的附件)——要顯示名字就自己退回path的最後一段。attachments[].path是相對房間檔案 root 的路徑,可以直接接 §12.3 的讀檔端點。
看得到什麼:與席位注入同一組判準——對話與指令列(不含 system 訊息)、且只收已完成的(串流中/中斷/錯誤的佔位不出現)。兩處刻意不同:你自己發過的訊息會出現(注入濾掉自己是為了不讓模型聽見回聲;翻查是回顧歷史);收回的訊息給墓碑:
{ "id": "…", "seq": 123, "content": "", "redacted": true, "attachments": [],
"line": "#123 2026-09-05 12:33 Bob (user @bob) [已收回]: " }
原文一個字都拿不到,但你知道這裡曾經有東西,limit 的則數也不會莫名短少。[已收回] 是伺服器產生的標記、排在其他標記之前;機器判斷請看 redacted 欄位。
12.2 視野邊界 boundary
三個地方會出現:每一輪的 BotSeatTurn.boundary、入席回應、翻查回應。語意一律指主欄:
| 欄 | 意思 |
|---|---|
seatStartSeq | 這一席的起點——入席當下的水位。指令模式(沒有席位)為 null |
roomMinSeq/roomMaxSeq | 這房目前最舊/最新的主欄 seq;一則都沒有時皆 0 |
用途:判斷「我需不需要去翻」。roomMaxSeq 比你上次看到的大 ⇒ 有新東西;想看入席前的脈絡 ⇒ 從 seatStartSeq 附近往前翻——主欄輪的節錄不會選入 seatStartSeq 之前的訊息列(零回看,11.2;討論串輪不看水位、只受預算,不在此限)。⚠️ 這三個值是「此刻」的相對位置,不是持久錨點。
12.3 房間檔案
| 端點 | 範圍 | 成功時回 |
|---|---|---|
GET /api/bot/rooms/:roomId/files?path=<子目錄> | 列,全房 | { "entries": [ … ] } |
GET /api/bot/rooms/:roomId/files/* | 讀內容,全房 | 檔案本身(見 §12.4 的 content-type) |
POST /api/bot/rooms/:roomId/files | multipart 上傳,可多檔,自動建父目錄 | { "results": [ … ] } |
POST /api/bot/rooms/:roomId/files/dir { "path": "notes" } | 建空目錄 | { "ok": true } |
DELETE /api/bot/rooms/:roomId/files/* | 刪檔案或空目錄,只限自己建立的 | { "ok": true } |
- 列的每一項是
{ name, kind, bytes, mtime, unlinked?, owner }:kind是file/dir,owner是"me"(這顆 bot 建的)或"other"(真人、別的 bot、沒有記錄——刻意不告訴你是哪一顆)。 - 上傳的 multipart fieldname 就是相對路徑(
docs/note.md),與真人面板一致。回{ "results": [ { "requested", "path", "ok", "reason"? } ] },整支永遠是 200(逐檔成敗看ok)。 requested與path不是同一件事:requested是你送出的 fieldname 原字串;path是實際落地的相對路徑。避讓模式下撞名會改名(note.md⇒note (2).md),所以path常常不等於requested。只有覆寫模式下「目標存在、而且是你的」那幾檔,path才會等於你送的路徑(正規化之後)。整批失敗(下面那幾種)時path一律退回requested,那時它不代表磁碟上有這個檔。reason有這幾種:quota:room這類配額不足、not your file、not a regular file、write failed、房間檔案目錄目前無法使用(提交當下這間房的檔案目錄不可用)。not your file/not a regular file是逐檔判定(同一批其他檔照常處理);quota:*/write failed/房間檔案目錄目前無法使用一出現就是整批同一個reason(quota:*與目錄無法使用時一個位元組都沒寫;write failed見下方說明)。- 覆寫:
POST …/files?overwrite=1。這是整批的模式,每個檔案各自判、互不影響:目標不存在 ⇒ 直接建立(不會因為「沒東西可覆寫」而失敗);存在且是你建的 ⇒ 原子取代;存在但不是你建的 ⇒ 該檔ok:false, reason: "not your file";目標是目錄(或其他非普通檔)⇒reason: "not a regular file"。覆寫沒有樂觀鎖(真人編輯器有baseHash,這裡沒有)——你覆寫的是只有你自己寫得動的檔案,這是刻意的取捨。 - ⚠️
overwrite只認「剛好等於1」:?overwrite=0、?overwrite=true、以及同一個 key 給兩次(?overwrite=1&overwrite=2)通通不是覆寫——會安靜地退回避讓模式,把檔案改名存成新的一份。這不是錯誤、不會有任何回應碼告訴你,所以參數要自己帶對。 - 所有權比對是逐字元、區分大小寫的,即使主機的檔案系統本身不分大小寫也一樣:
Notes.md與notes.md是兩個不同的鍵。 - 刪除:只刪你建的;非空目錄 ⇒ 409
directory not empty(不會遞迴刪,裡面可能有別人的檔案);不存在 ⇒ 404。403not your file有三個原因:那個路徑不是你建的、沒有任何所有權記錄、或記錄說的型別與磁碟上的實際型別對不上(記錄寫檔案、磁碟上卻是目錄,反之亦然)。 - 建目錄不會自動建父層:父目錄不存在與路徑已存在同樣回 409
exists(兩者刻意同一個回應)。要一次建深路徑就直接上傳——上傳才會自動建父目錄。 - 不提供改名/搬移。要搬就重傳+刪舊。真人改名或搬移你建的資料夾時,你的所有權會跟著路徑走。
🔴 所有權是協作邊界,不是安全邊界:房主與成員對檔案區本來就有完整權限(可以改、刪、搬你建的檔案);主機層直接操作目錄不經過這些 API。它讓多顆 bot 與真人在同一個檔案區不互相蓋掉東西,不是一道防線。附帶好處:所有權綁的是 bot 不是席位,你建的檔案跨 session 仍是你的——可以當持久記憶用。
12.4 限制與已知行為
- 讀取(翻查/列檔/讀檔)60 次/分鐘,每顆 bot 一顆窗;檔案寫入(上傳/建目錄/刪除)10 次/分鐘,另一顆窗。未授權的請求不佔窗。
同一個查詢參數重複出現時,各參數的下場不同(沒有一個是「取其中一個值」):
| 重複的參數 | 下場 |
|---|---|
anchor/before/after | 400 conflicting cursor |
path | 400 invalid path |
threadId | 404 thread not found |
overwrite | 不是錯誤——當成沒開覆寫,退回避讓改名(見 §12.3) |
- 讀檔的
content-type是照檔頭的位元組判定的,不看副檔名:純文字檔(.md、.txt、.json)一律回application/octet-stream+content-disposition: attachment(與真人下載同一條路徑的行為),圖片與 PDF 才會以真正的型別內嵌回應。要當文字用就自己解碼 body,不要看content-type分支。 - 覆寫是 rename 即生效、不可回滾:同一批後面的檔案寫入失敗時,只回滾新建的檔與目錄,已覆寫的留著。
- 🔴
reason: "write failed"時不要相信results:整批只要有一個寫入真的擲錯,整個results陣列會被換成「每一檔都ok:false, reason: "write failed"」——包含那幾個已經覆寫成功、而且留在磁碟上的檔(rename 已經生效、回滾收不回來)。所以看到write failed要重新列一次目錄去對帳,不能照results認定「什麼都沒寫進去」。 - 整批逐檔全失敗時(例如全部
not your file/not a regular file)通常什麼都不會發生:磁碟沒動、房裡不會出現「上傳了 0 個檔案」的系統訊息、也不會廣播檔案區更新。⚠️ 例外:這一批若順手建出了新的父目錄,那些目錄會留著,而且房裡會出現一則系統訊息。 - 配額是整批預先保留的:覆寫自己的檔案時,新檔的位元組會在這次請求期間疊在舊檔之上計算(保守估法)。房間配額幾乎用滿時,這會讓一次「其實不會變大」的覆寫收到
quota:*的reason——把批次拆小或先刪再傳即可。 - 已收回訊息的附件不會出現(收回時已解除綁定)。
attachments[].path指到的檔案可能已被搬走或刪掉——讀檔 404 就是這個意思。
13. 關鍵字搜尋(v0.89.0 起)
GET /api/bot/rooms/:roomId/search
在一間授權房裡找關鍵字,主欄與所有討論串一起找。命中回的是完整的訊息物件(與 §12.1 同一個形狀),可以直接接上 §12.1 翻查展開前後文。
⚠️ 這是子字串比對,不是全文檢索:不斷詞、不算相關性、不回高亮片段。% 與 _ 是普通字元(伺服器已經替你跳脫,不是萬用字元)。
13.1 參數
| 參數 | 意思 |
|---|---|
q | 必填。關鍵字;去頭尾空白後 1–100 字,且不可含 NUL |
threadId | 給=只找那條討論串;不給=主欄與所有討論串一起找 |
before | 接續上一頁:比這一則舊的一頁(不含它;message id) |
limit | 一頁幾則,預設 20、最多 50 |
- 游標一律是 message id(與 §12.1 同理,
seq會重用)。before指到的訊息已被刪除、不在這個範圍(含不在指定的討論串裡)、不是你看得到的列、或已被收回 ⇒ 404cursor not found——不會靜默退回第一頁。 - 限速自己一顆窗:20 次/分鐘,與 §12 的讀取窗(60 次/分鐘)分開、互不吃額度;未授權的請求不佔窗。超過回 429
rate limited。 - 錯誤碼:400
invalid query(q沒給/去空白後是空的/超過 100 字/含 NUL)、400invalid limit、404thread not found、404cursor not found、404room not found、429rate limited。完整條件見 §10。 - ⚠️ 搜尋端點的 404
room not found也帶message——§10 開頭那條「只有error」講的是搜尋以外的端點。文案仍然不透露房間存不存在。 - 重複的查詢參數:
q/before/limit其中任何一個同一個 key 給了兩次 ⇒ 400invalid query;threadId給兩次 ⇒ 404thread not found(沿用 §12.4 的處置)。
13.2 回應
{ "hits": [ … ], "hasMore": true,
"boundary": { "seatStartSeq": 39, "roomMinSeq": 1, "roomMaxSeq": 57 } }
hits由新到舊——⚠️ 與 §12.1 的messages(舊到新)相反。每一則就是 §12.1 那個訊息物件(id/seq/createdAt/author/content/threadId/attachments/line全都在),外加一欄threadTitle:命中在討論串裡時是那條串的標題,主欄命中為null。line與 §12.1 翻查同一則的line、與席位模式transcript裡的那一行一字不差(同一支格式化)。hasMore= 這一頁之外還有更舊的命中。怎麼一頁一頁翻:拿hits最後一則的id當下一次的before,直到hasMore是false。boundary三欄與 §12.2 同義,🔴 只適用主欄命中:seatStartSeq只算主欄的水位,串內訊息的seq與它不在同一把尺上(入席之前建立的串內訊息,seq可能比seatStartSeq大、看起來像入席之後)。⛔ 不要拿它判斷串內命中落在你入席之前還是之後。
13.3 接上 §12.1 翻查
命中只是一則訊息,前後文要自己翻:
GET /api/bot/rooms/:roomId/search?q=部署
→ hits[0] = { "id": "01J…", "threadId": "01T…", "threadTitle": "上線流程", … }
GET /api/bot/rooms/:roomId/messages?anchor=01J…&threadId=01T…
🔴 串內命中一定要把 threadId 一起帶過去:GET …/messages 不給 threadId 時只認主欄的游標,直接拿串內命中的 id 當 anchor 會回 404 cursor not found。主欄命中(threadId 是 null)不必帶。
⚠️ 這樣展開到的是串裡面的前後文,不含 root(root 是主欄訊息,12.1)——要連 root 一起看,多翻一次主欄。
13.4 看得到什麼
與 §12.1 同一組判準——對話與指令列(不含 system 訊息)、只收已完成的(串流中/中斷/錯誤的佔位不出現)、你自己發過的訊息也在內。與 §12.1 只有一個差異:
- 🔴 收回的訊息搜不到。§12.1 翻查會給你一則墓碑(
redacted: true),搜尋不會——原文在收回當下就已經被清空,伺服器另外還顯式排除已收回的列。同一段歷史「翻得到墓碑、搜不到」是刻意的;hits裡的redacted因此恆為false(欄位仍保留,兩個端點的形狀不分岔)。
排序是時間序、新到舊,不是相關性。同一毫秒內以 message id 的字典序排——那只保證穩定,⛔ 不代表發生先後。
13.5 已知限制(七條)
- 不做相關性排序、不回高亮片段,固定時間序(新→舊);同一毫秒依 message id 字典序,不代表發生先後。
- 只比對原始字串/NFC/NFD 三種 Unicode 形式;其他正規化差異(相容性分解、全形半形)搜不到。
- ⚠️ 重音字母的大小寫比對會「意外成功」:子字串比對對 ASCII 不分大小寫,NFD 拆開之後重音字母的 ASCII 部分也不分,再加上三種形式一起比 ⇒ 內容存
É(NFD)時,é的 NFC 與 NFD 兩種寫法都會命中。⛔ 不要拿「有沒有分大小寫」當判斷依據。 - 內容裡含 NUL 的訊息,搜不到它 NUL 之後的字:內容是
prefix+ NUL +needle時,搜needle是 0 筆、搜prefix是 1 筆。伺服器擋掉的是查詢詞裡的 NUL,內容裡的擋不掉——那是比對本身的截斷。 - 不搜討論串標題。串標題多半自動取自 root 訊息;要用標題找,拿 §5.4 的
GET /api/bot/rooms/:roomId/threads自己比對。 - 不回總筆數(那要再全掃一次,而且對呼叫端沒有可執行的用途)——只有
hasMore。 boundary只能用來判斷主欄命中的相對位置(見 §13.2)。