Korat

做一個回客人訊息的聊天機器人

這一頁前半段寫給不會寫程式碼的店主也能照做,後半段才是給開發者的技術細節。想直接跳到某一節,用右邊的目錄。

機器人是什麼,能拿來做什麼

機器人是 Korat 系統裡另一種“使用者帳號”,螢幕後面沒有人在打字——是你自己的程式發請求進來,訊息就出現在機器人被邀請進去的會話裡。實際用法比如:

  • 把新訂單提醒發進會話——你自己的銷售系統來了新單 ⇒ 讓機器人立刻發進和客人的會話或團隊的房間。
  • 庫存告急提醒——庫存低於你設的線 ⇒ 機器人發進店鋪的團隊房間。
  • 伺服器/系統狀態播報——你自己的健康檢查指令碼跑完 ⇒ 讓機器人把結果報進 IT 團隊盯著的群組房間。

機器人做不到的事(這是資料庫裡的真實情況,不是臨時限制):

  • 機器人讀不到房間裡的訊息——它只出不進,除非你自己配了接收訊息的 webhook(見頁尾的技術參考)。而且就算配了,今天也不會真的有東西發出來,因為把佇列取出去投遞的定時器在生產環境上還沒配置。
  • 機器人不能自己把自己拉進房間——永遠要由已經在那個房間裡的人(或者有該房間管理權限的人)來邀請。
  • 一個機器人同時最多持有2個有效令牌,一個帳號最多建立5個機器人。

怎麼開始——四步

  1. 1建立機器人:給它起名字和使用者名稱(使用者名稱必須以 bot 結尾,比如 orderbot)——想讓它屬於店鋪(而不是隻屬於你個人),建立時選上店鋪就行。
  2. 2複製令牌:建立成功時系統只顯示一次令牌——立刻複製並妥善儲存。關掉那個介面之後,真實值再也取不回來(只能重新簽發一個)。
  3. 3把機器人請進房間:選你想讓它說話的會話、群組房間或團隊房間——你本人必須已經在那個房間裡(或者有該房間的管理權限)才能邀請。
  4. 4發出第一條訊息:拿第2步的令牌去調 Bot API——下一節有可以照抄的例子。

第1–3步在 Korat 應用裡做

菜單在設定 › 帳號 › “我的機器人”——在那裡能建立機器人、簽發/輪換/吊銷令牌,並把機器人請進會話、群組房間或團隊房間,一行程式碼都不用寫。這個菜單會隨下一版 Korat 應用一起來——今天能下載到的版本還沒有。開啟設定看不到“我的機器人”,就等應用更新;在此之前,第1–3步也可以直接調 Supabase 的 RPC 完成(見頁尾的技術參考)。

🔒 令牌就是機器人的密碼

任何拿到這個令牌的人,都能立刻以機器人的名義發訊息——不要透過聊天、截圖或未加密的郵件傳令牌,只放在你自己的伺服器或金鑰保管庫裡。Korat 的任何工作人員都不會向你索取令牌,無論是聊天、郵件還是電話——有人自稱 Korat 團隊來要令牌,請一律當作詐騙,並立刻透過聯絡頁告訴我們。令牌一旦洩露,可以馬上在應用裡吊銷(不可撤銷,只能重新簽發)。

示例——發出第一條訊息

只有一個端點:POST https://koratland.com/api/bot/send。把令牌放在請求頭裡,再說清楚往哪個房間說什麼:

curl 版(給熟悉終端的人)

curl -X POST "https://koratland.com/api/bot/send" \
  -H "X-Bot-Token: kbot_12_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "chat", "chat_id": 9931, "text": "來新訂單了" }'

kbot_12_xxxxxxxxxxxxxxxxxxxx 換成機器人的真實令牌,把 9931 換成你請機器人進去的那個房間的 id(房間 id 可以從應用裡的會話連結看到,或者在“我的機器人”介面的房間列表裡找)。

不寫程式碼版(Google Apps Script)

沒有自己的伺服器,用一個 Google 帳號就能免費用 Google Apps Script——開啟 script.google.com → 新建專案 → 貼上這段程式碼 → 點“執行”。第一次會申請訪問網際網路的權限(可以允許,這是你自己的指令碼,安全):

function sendKoratMessage() {
  var url = "https://koratland.com/api/bot/send";
  var payload = {
    kind: "chat",
    chat_id: 9931,              // 換成你的房間 id
    text: "來新訂單了"            // 換成你要發的文字
  };
  var options = {
    method: "post",
    contentType: "application/json",
    headers: { "X-Bot-Token": "kbot_12_xxxxxxxxxxxxxxxxxxxx" },  // 換成你的機器人令牌
    payload: JSON.stringify(payload)
  };
  var res = UrlFetchApp.fetch(url, options);
  Logger.log(res.getContentText());  // 在“執行日誌”(Execution log)裡看結果
}

點一次“執行”就能測試——想讓它自動發,比如記錄訂單的 Google Sheet 每加一行就發一次,用 Apps Script 的觸發器(Triggers)設成表格被修改時(onEdit)呼叫這個函式,或者每隔幾分鐘調一次。

同樣的做法也適用於任何自帶 “HTTP request”/“Webhook” 動作的自動化工具,比如 n8n、Make 或 Zapier——設成 POST 到同一個 URL,加上 X-Bot-Token 請求頭,JSON 請求體和上面的例子一樣。

常見問題

你看到的(reason它是什麼意思怎麼解決
not_invited機器人還沒被請進這個房間(或者被請過又被踢了)。在“我的機器人”介面或用 bot_invite 再邀請一次。
invite_stale當初把機器人請進來的那個人,在這個房間裡已經沒有權限了(退群/被降級/被撤銷團隊房間管理權/房間被關閉)⇒ 於是機器人自己啞了,沒人踢過它。在那個房間裡仍然真正有權限的人重新邀請一次機器人——權限永遠按最後一位邀請人來算。
conversation_not_open這個房間是店鋪的訊息收件箱,而客人還沒有先找上門——店家一側(包括店鋪的機器人)不能先開口。等客人先發訊息,再讓機器人回。
rate_limited機器人發得太頻繁,超過了設定的上限(每分鐘或每天)。retry_after_sec 給的秒數等一等再發——確實需要更高的上限,請透過聯絡頁告訴我們。
blocked那個會話裡的使用者把這個機器人拉黑了。這沒法從使用者那一側改——必要時換個房間或換個機器人。
機器人本來能說話,突然就啞了最常見的原因就是上面那個 invite_stale——不是機器人壞了。查一下當初邀請它的那個人還在不在房間裡、還有沒有權限。

完整的錯誤碼見下面技術參考裡的表格。

技術參考(給開發者)

下面這幾節寫給要自己寫程式調 Bot API 的人——每個引數的細節、完整的錯誤碼錶,以及接收訊息的 webhook。

直接用 RPC 完成第1–3步(不走應用)

你用的應用版本還沒有“我的機器人”介面,或者想自動化處理,就用已登入使用者的 Access Token(不是機器人令牌)透過 PostgREST 直接調 Supabase 的 RPC——三步:

1. 建立機器人——bot_create

curl -X POST "https://<SUPABASE_PROJECT>.supabase.co/rest/v1/rpc/bot_create" \
  -H "apikey: <SUPABASE_ANON_KEY>" \
  -H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "p_name": "通知機器人",
    "p_username": "notifybot",
    "p_business_id": null,
    "p_about": "播報訂單狀態"
  }'

p_username 只能用 a–z 0–9 _,長度 4–31,並且必須以 bot 結尾(正則 ^[a-z][a-z0-9_]{2,29}bot$)。想讓機器人屬於店鋪就傳 p_business_id——呼叫者必須是該店鋪成員並持有 bot.manage 權限。成功響應裡的 token這個令牌一生中唯一一次出現的真實值,資料庫只存 sha256 雜湊——錯過了就只能重新簽發(bot_rotate_token),沒有任何辦法把真實值讀回來。

{ "ok": true, "bot_id": 12, "user_id": 4821, "username": "notifybot", "token": "kbot_12_…" }

2. 把機器人請進會話——bot_invite

你必須確實在那個房間裡(或者房間是店鋪收件箱時,你是店鋪員工),並且能管理這個機器人(can_manage_bot)。p_target 就是 chats.id

curl -X POST "https://<SUPABASE_PROJECT>.supabase.co/rest/v1/rpc/bot_invite" \
  -H "apikey: <SUPABASE_ANON_KEY>" \
  -H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{ "p_bot": 12, "p_kind": "chat", "p_target": 9931 }'

2a. 請進群組房間/團隊房間——權限比普通會話嚴

p_kind: "community_room""business_room"p_targetcommunity_rooms.idbusiness_rooms.id,這已經可以用了——但邀請人必須過得了與那個房間含義相符的權限關,光是成員不夠

  • 群組房間——邀請人必須按房間自己的規則(read_rolepost_role真的能在那裡發言。普通聊天房間成員就能邀請;設成只有管理員能發的公告房或後臺房,只有群主/管理員才行。
  • 店鋪團隊房間——房間原有的各道關(讀權限/發帖權限/收件人規則)都要過,再加上 teamroom.manage 這項能力——把機器人帶進公司內部房間算“管理房間”,不只是“在房間裡發言”。

兩種情況都仍然要求你是這個機器人的管理者(can_manage_bot)——公共房間不是誰都能把別人的機器人拖進來說話的地方。

🔴 invite_stale——用之前最該知道的一件事

邀請人的權限不是隻在邀請那一刻檢查——每發一條訊息,bot_send 都會以那位邀請人的身份重新問一遍房間規則 ⇒ 邀請人一旦退群 / 被降級 / 被撤銷 teamroom.manage / 房間被關閉,機器人的下一條訊息立刻得到 reason: "invite_stale"——機器人自己啞了,沒人踢過它。這裡沒有後臺任務、沒有必須跑完的觸發器,純粹是權限被重新算了一遍的結果。

傳送訊息——POST /api/bot/send

令牌有兩種傳法,任選其一:

# 方式一 —— Authorization: Bearer
curl -X POST "https://koratland.com/api/bot/send" \
  -H "Authorization: Bearer kbot_12_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-9931-shipped" \
  -d '{ "kind": "chat", "chat_id": 9931, "text": "包裹已經從店裡發出" }'
# 方式二 —— X-Bot-Token
curl -X POST "https://koratland.com/api/bot/send" \
  -H "X-Bot-Token: kbot_12_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "chat", "chat_id": 9931, "text": "包裹已經從店裡發出" }'

完全不傳令牌會得到 401

{ "ok": false, "reason": "bad_token", "detail": "令牌只能放在 Authorization: Bearer … 或 X-Bot-Token 裡" }

請求體欄位

欄位型別必填含義
kindstring否(預設 "chat"決定目標的型別——真正可用的只有三個值:"chat" · "community_room" · "business_room"。其他值會直接從資料庫拿到 reason: "bad_target"(不會悄悄退回 "chat"——以前把這個值寫死的那個 bug 已經修了)。
chat_idinteger > 0是(或用 target_id機器人已被邀請進去的目標房間 id——kind: "chat" 時是 chats.id"community_room" 時是 community_rooms.id"business_room" 時是 business_rooms.id(請求體裡的欄位名永遠是 chat_idtarget_id,不會按 kind 換名字)。
textstring訊息正文,不能為空,長度不得超過 text_max_message 登記表定的上限(這個上限可調,不是寫死的數字——看 too_long 錯誤裡帶的 max)。
client_keystring防重複鍵,可以代替 Idempotency-Key 請求頭——長度 8–128,只能用 A–Z a–z 0–9 _ . : -

私聊 vs 店鋪收件箱——由哪個欄位決定

kind決定的是目標屬於哪張表(會話/群組房間/店鋪團隊房間)。而私聊和店鋪收件箱並不是靠 kind 區分的——它們都是 chats 表裡的行,區別只在 chats.business_id 有沒有值(有值 = 店鋪收件箱)。邀請權限檢查、使用者拉黑檢查,以及“店家不能先開口(conversation_not_open)”這條規則,讀的都是這一列——你傳的 chat_id 本來就必須是正確的那個房間,API 裡沒有單獨的“私聊/商用”引數可選。

Idempotency-Key 與速率上限

用標準請求頭 Idempotency-Key,或者請求體裡的 client_key——任選其一。兩個都傳時值必須一致,不一致會得到 422 idempotency_key_conflict。用同一個鍵(同房間+同鍵)重發同一個請求,會拿回原來的 200 響應並帶 "duplicate": true,不會產生重複訊息。

上限分兩層:按 IP(Worker 自身 120 請求/分鐘)和按機器人(預設每個機器人 20/分鐘、1000/天,管理員可逐個調整)。兩種都返回 429 並帶 Retry-After 請求頭,單位為秒(按 IP 恆為 60 · 按機器人每分鐘 = 60 · 按機器人每天 = 3600)。

接收訊息——webhook

當有人在機器人已被邀請進去的會話裡發訊息(且傳送者不是機器人自己的帳號——防止機器人的訊息回彈給自己),Korat 會向你設定的 URL 發一個 POST。設定要用已登入、且能管理這個機器人(can_manage_bot)的使用者帳號透過 RPC 完成,和申請令牌那一步一樣:

curl -X POST "https://<SUPABASE_PROJECT>.supabase.co/rest/v1/rpc/bot_set_webhook" \
  -H "apikey: <SUPABASE_ANON_KEY>" \
  -H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{ "p_bot": 12, "p_url": "https://your-server.example.com/korat-webhook" }'
{ "ok": true, "secret": "a1b2c3…", "note": "這個 secret 只顯示一次——自己儲存好用來校驗簽名" }

URL 必須是 https://,並且不能是內網/迴環/鏈路本地地址(localhost127.0.0.110.0.0.0/8172.16.0.0/12192.168.0.0/16169.254.0.0/16,包括各家雲的後設資料端點)——設定時就拒絕,每次投遞前還會真實解析 DNS 再查一遍,防止 DNS 事後被指到別處。secret 只在設定或輪換時顯示一次——bot_rotate_webhook_secret(p_bot) 隨時可以輪換,bot_disable_webhook(p_bot)bot_enable_webhook(p_bot) 自行關閉或開啟,bot_webhook_status(p_bot) 檢視狀態(不返回 secret)。

今天還沒有任何東西真的發出去

投遞佇列(bot_webhook_deliveries)在資料庫裡工作正常並已驗證,投遞通道的 secret(BOT_WEBHOOK_DISPATCH_SECRET)也已經由所有者設好,但真正把佇列取出去投遞的那個東西(每分鐘呼叫 Worker 的 pg_cron還沒有在生產環境上配置——webhook 可以設,訊息會排進佇列,但在 pg_cron 定時器被配好之前,你的接收端永遠收不到任何東西。

你的接收端會收到的請求長什麼樣

POST /korat-webhook HTTP/1.1
Content-Type: application/json
X-Korat-Signature: sha256=<hex>
X-Korat-Timestamp: 1750000000000

{
  "event": "message.created",
  "chat_id": 9931,
  "message_id": 88213,
  "text": "有紅色的嗎",
  "time": "14:02",
  "sender": { "id": 4821, "username": "somchai_p", "name": "Somchai P." }
}

就這些——請求體裡沒有任何人的電話、郵箱或令牌。機器人只看到房間、訊息,以及為了回覆所必需的那點傳送者身份資訊。

先驗籤,再相信這個請求

X-Korat-SignatureHMAC-SHA256(secret, "<timestamp>." + rawBody) 的十六進位制值——Node.js 示例:

const crypto = require("crypto");
function verify(rawBody, signatureHeader, timestampHeader, secret) {
  const age = Date.now() - Number(timestampHeader);
  if (!(age >= 0 && age < 5 * 60_000)) return false; // 防重放:時間戳不能超過5分鐘
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(`${timestampHeader}.${rawBody}`)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}

在8秒內返回 2xx 算投遞成功——返回別的(包括 3xx 重定向,Korat 不會跟進)或者根本不回,都算失敗並排隊重試。

接收端掛了之後的重試

失敗 ⇒ 指數退避(1、2、4、8、16、32、64、128 分鐘),每條訊息最多重試 8 次,然後放棄這一條。如果接收端連續失敗15次(跨多條訊息),webhook 會自動被關閉,並在應用裡通知機器人所有者——絕不會對一個已經死掉的接收端永遠發下去。你那邊修好之後,調 bot_enable_webhook 重新開啟。

完整錯誤碼錶

失敗時每個響應都一定有 {"ok": false, "reason": "…"}——該拿來做判斷的是 reason,而不只是 HTTP 狀態碼。

HTTPreason來自含義 / 怎麼解決
401bad_tokenWorker / DB沒傳令牌,或者令牌錯誤/已被吊銷/機器人已停用——故意全部回同一個詞,防止被人逐一試探。
403not_invitedDB機器人還沒被請進這個房間,或者已經被踢了——先調 bot_invite
403blockedDB房間裡的使用者把這個機器人拉黑了(僅限非店鋪收件箱的房間)。
403account_suspendedDB房間裡有一方的帳號被封停了——這是權限問題,不是請求寫錯了。
404no_such_chatDBchat_id 根本不存在。
404no_such_endpointWorker路徑錯了,只有 POST /api/bot/send
405method_not_allowedWorker用了 POST 以外的方法。
409conversation_not_openDB這是店鋪收件箱,客人還沒有先發過訊息——店家一側(包括機器人)不能先開口。
409chat_closedDB交友配對的會話已經關閉,不再接收訊息。
422empty_textDB去掉空白後 text 是空的。
422too_longDB訊息超過長度上限——看響應裡帶的 max
422bad_targetDBkind 不是 "chat""community_room""business_room" 之一。
404no_such_roomDBchat_id 對應的群組房間/團隊房間並不存在(房間已被刪除)。
409room_archivedDB這個房間已經歸檔關閉——人和機器人都發不了。
403invite_staleDB邀請人的權限被重新計算後不透過了——邀請人退群/被降級/被撤銷 teamroom.manage/房間被關閉。細分原因看 detailnot_a_memberroom_rolenot_teamroom_manager)——解決辦法是讓仍然有權限的人重新邀請機器人(bot_invite),乾等沒用。
422bad_jsonWorker請求體不是合法的 JSON。
422bad_chat_idWorkerchat_idtarget_id 必須是正整數。
422bad_textWorkertext 必須是字串。
422idempotency_key_conflictWorkerIdempotency-Keyclient_key 同時傳了但值不一樣——傳一個就夠。
422bad_client_keyWorker防重複鍵格式不對(8–128 個字元,A–Z a–z 0–9 _ . : -)。
429rate_limitedWorker / DB超過上限——看 Retry-After 頭和 windowretry_after_sec 欄位。
502database_errorWorker資料庫回了,但不是 2xx——看附帶的 pg_codedetail,那是 Postgres 自己的原話。
503service_key_not_configuredWorker伺服器還沒配置 service key——這是 Korat 這邊的問題,不是呼叫方的。
504database_unreachableWorker調資料庫失敗或超時——看 safe_to_retry:只有當初傳了 client_keyIdempotency-Key 時,重發才是安全的。

成功時的響應

{ "ok": true, "duplicate": false, "message_id": 88213 }

duplicate: true 表示這個請求之前已經發過了(靠 client_keyIdempotency-Key 去重)——沒有產生新訊息,但仍算成功(200),因為呼叫方想要的結果確實已經發生了。