Aigora Bot 協定

v0.93.0 給要寫 bot client 的人

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 連線互不相干,多間房各一條

指令模式怎麼開始:

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

席位模式怎麼開始:

  1. 同上,但管理員還要另外給這間房入席權(有「可寫」不等於有「可入席」)。
  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 你會從管理員拿到什麼

項目說明
handlebot 在房裡的指令前綴,例如 plm ⇒ 使用者打 /plm show 123
token32 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
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 只有一種: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(結果列,會綁回那則呼叫)
authorNamebot 的顯示名(管理員設的,不是 handle)
content落庫後的內容(前後空白已去除)
createdAtepoch 毫秒

物件上還有一批前端渲染用的欄位(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(帶了回 409 root-not-accepted)——root 一律由伺服器自己插。
  • ⚠️ 也不接受 invocationId(帶了回 400 invocation-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 從哪來——三個來源,都不需要寫死在設定檔裡:

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

⚠️ 讀訊息與房間檔案走 §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.mjsGET /api/bot/updates 迴圈(2.2)游標 offset =「我要的下一則」;每次請求就是 ack
bot-sse.mjsGET /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. 容易寫錯的八件事

  1. 游標差一:long-poll 的 offset 是「下一則」,SSE 的 Last-Event-ID 是「最後一則」。兩者混用會漏掉或重複收一則。
  2. 滿批不代表結束:updates.length === 100 時要立刻再拉,不要進入下一輪等待——否則 backlog 大時每 25 秒才前進 100 則。
  3. 只在真的處理完才推進 offset:推進即刪除,中途當掉就拿不回來了。
  4. 收到 auth-invalid 不要自動重連:那不是網路問題,是鑰匙失效了。
  5. 回錯 scope:threadId 不是 null 時要回討論串端點;打到房間端點會被 409 拒絕(不會靜默寫到主欄)。
  6. 用 invocationId 做冪等:重連、重送、以及「處理到一半當掉」都可能讓你看到同一則事件兩次。重送同一個 invocationId 回話,會得到 409 invocation-already-answered——這視為成功,不是錯誤,不必重試、不必當機(見 5.1)。
  7. 🔴 連上之後的斷線是從「讀串流」那裡拋的,不是從 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 秒重試在「一連上就被踢掉」時會變成打伺服器的熱迴圈。
  8. 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哪裡什麼時候
400invalid content三條寫入端點content 不是字串/去空白後為空/超過 8000 字
400invalid-level三條寫入端點level 不是 normal/warn/urgent
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串內發言、翻查/搜尋(?threadId=)串不存在/串在未授權的房——刻意同一個回應(翻查與搜尋時同一個 key 給了兩個 threadId 也是這個碼)
409replacedlong-poll你自己開了第二條 outbound 連線(一顆 bot 只能有一條)
409main-scope-only主欄發言body 帶了 threadId(討論串請用 §5.2 的端點)
409root-not-accepted開一條新討論串body 帶了 rootMessageId(root 一律由伺服器自建)
409thread-deleted串內發言那條串正在刪除收尾——重試沒有意義,不要重送
400invalid-invocation-id主欄/串內發言帶了 invocationId 但不是 64 字以內的字串(見 5.1)
400invocation-not-accepted開一條新討論串body 帶了 invocationId(這個端點是開新串,不是回一次呼叫)
404invocation-not-found主欄/串內發言這個 invocationId 對不到一列還在的呼叫
403invocation-not-yours主欄/串內發言這次呼叫是發給別顆 bot 的
409invocation-scope-mismatch主欄/串內發言呼叫列的房間/討論串與這次回話的端點不一致
409invocation-already-answered主欄/串內發言這個呼叫已經有結果了——視為成功(8-6),不要當錯誤處理
429rate limited三條寫入端點、讀取端點(60 次/分鐘)、搜尋端點(20 次/分鐘)、檔案寫入端點超過該顆窗的上限(見 §6;各窗互不吃額度)
400invalid roomId入席body 的 roomId 不是字串
400invalid externalId入席externalId 不符 ^[A-Za-z0-9_-]{4,64}$
400invalid wait席位 long-pollwait 不是 0 以上的整數
403seat not granted入席這顆 bot 在這間房有寫入權但沒有入席權——請管理員在房間設定勾「可入席」(與 404 刻意分開:404 是「你不在這房」,403 是「你在這房但不能入席」)
410seat gone席位 long-poll席位已不存在;reason 是 leave(你自己 DELETE 過)/timeout(5 分鐘沒 poll 被收回)/replaced(同一顆 bot 用別的 externalId 再入席)/revoked(入席權撤銷/房間封存/bot 停用)。四種都不要自動重新入席(11.5)
409turn closed席位回覆這一輪已經結束;status 是 timeout(超過 10 分鐘)/replied(已經回過)/cancelled(席位離開時被取消)/pending(這一席還沒交付給你的輪——正常 client 拿不到它的 turnId,只有提前取得同一席尚未交付的 turnId 才會撞到;別席的 turnId 是 404)
404not found席位 long-poll/回覆/離席這個 seatId 不是你的、或(回覆時)turnId 不屬於這席。這顆 bot 對這間房的寫入權被撤銷時回的是上面那格 404 room not found,不是這個碼——回覆端點在確認過 seatId/turnId 之後還會再過一次房間授權。讀檔/刪檔回的 not found 見下面幾列
400conflicting cursor翻查anchor/before/after 給了不只一個——同一個 key 給兩次也算(?anchor=a&anchor=b)
400invalid query搜尋q 沒給/去掉頭尾空白後是空的/超過 100 字/含 NUL(?q=%00),或 q/before/limit 其中一個同一個 key 給了兩次
400invalid limit翻查、搜尋limit 不是整數,或超出該端點的範圍(翻查 1–100、搜尋 1–50)。⚠️ 小數與帶尾巴的字串(1.5/20junk)不會被截成整數,一律是這個碼
404cursor not found翻查、搜尋翻查的 anchor/before/after、搜尋的 before 指到的訊息已被刪除、不在這個範圍(含不在指定的討論串裡)、或不是你看得到的列(搜尋還多一條:已收回的訊息不能當游標)。不會靜默退回最新一頁
400invalid path列檔/讀檔/建目錄/刪除/上傳路徑不合法(絕對路徑、..、空片段、控制字元…);同一個 key 給兩次(?path=a&path=b)也是這個碼
400invalid files dir列檔/讀檔/建目錄/刪除/上傳這間房的檔案目錄目前無法使用(例如外接卷宗沒掛上)——不是你送錯
400invalid staging dir上傳伺服器暫存目錄無法使用——不是你送錯,退避後重試
404not found讀檔/刪除那個路徑不存在(讀檔時「存在但讀不出型別」也是這個碼,不透露差別)
403not your file刪除那個檔案或目錄不是這顆 bot 建的(含沒有記錄的)。上傳覆寫模式下同名的字串是逐檔的 results[].reason,不是這個頂層碼
409directory not empty刪除目錄裡還有東西——絕不遞迴刪(裡面可能有別人的檔案),自己先清空
409exists建目錄那個路徑已存在,或父目錄不存在(建目錄不會自動建父層;上傳會)
413upload limit exceeded上傳超過單次上傳的檔案數或單檔容量上限
507storage unavailable上傳儲存空間目前不可用(配額系統暫時無法判斷)——退避後重試
500upload rollback incomplete上傳上傳失敗、且清理沒清乾淨。請聯絡管理員檢查房間檔案,不要無限重試
500file read failed讀檔檔案打得開但讀不出來(主機層 I/O 錯誤)
500delete 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 失效——不要重試)、403 seat not granted/room archived、404 room 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 不是你的)、410 seat gone(見 11.5)、400 invalid 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 失效——不要重試)、404 not 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 過。
    正確做法:把原因告訴人、停止 poll、等人重啟你的 client(新的行程=新的 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 三者只能給一個,給兩個以上 ⇒ 400 conflicting cursor。都不給 ⇒ 最新一頁。
  • 🔴 翻討論串時不會回 root:threadId 給了就只翻串裡面的訊息,而 root 是一則主欄訊息(它開出了這條串)。席位模式那一輪的節錄是「root + 整串」(11.3),翻查不是——要 root 就用 GET /api/bot/rooms/:roomId/threads 拿 rootMessageId,再不帶 threadId 以它為 anchor 翻一次主欄。
  • 游標一律是 message id,不是 seq。seq 會重用(刪掉最新的討論串後,新訊息會拿到用過的號碼),拿它當書籤會翻到完全不同的訊息。書籤指到的訊息已被刪除、不在這個範圍、或不是你看得到的列 ⇒ 404 cursor 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/filesmultipart 上傳,可多檔,自動建父目錄{ "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。403 not your file 有三個原因:那個路徑不是你建的、沒有任何所有權記錄、或記錄說的型別與磁碟上的實際型別對不上(記錄寫檔案、磁碟上卻是目錄,反之亦然)。
  • 建目錄不會自動建父層:父目錄不存在與路徑已存在同樣回 409 exists(兩者刻意同一個回應)。要一次建深路徑就直接上傳——上傳才會自動建父目錄。
  • 不提供改名/搬移。要搬就重傳+刪舊。真人改名或搬移你建的資料夾時,你的所有權會跟著路徑走。

🔴 所有權是協作邊界,不是安全邊界:房主與成員對檔案區本來就有完整權限(可以改、刪、搬你建的檔案);主機層直接操作目錄不經過這些 API。它讓多顆 bot 與真人在同一個檔案區不互相蓋掉東西,不是一道防線。附帶好處:所有權綁的是 bot 不是席位,你建的檔案跨 session 仍是你的——可以當持久記憶用。

12.4 限制與已知行為

  • 讀取(翻查/列檔/讀檔)60 次/分鐘,每顆 bot 一顆窗;檔案寫入(上傳/建目錄/刪除)10 次/分鐘,另一顆窗。未授權的請求不佔窗。

同一個查詢參數重複出現時,各參數的下場不同(沒有一個是「取其中一個值」):

重複的參數下場
anchor/before/after400 conflicting cursor
path400 invalid path
threadId404 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 就是這個意思。