機器人是什麼,能拿來做什麼
機器人是 Korat 系統裡另一種“使用者帳號”,螢幕後面沒有人在打字——是你自己的程式發請求進來,訊息就出現在機器人被邀請進去的會話裡。實際用法比如:
- 把新訂單提醒發進會話——你自己的銷售系統來了新單 ⇒ 讓機器人立刻發進和客人的會話或團隊的房間。
- 庫存告急提醒——庫存低於你設的線 ⇒ 機器人發進店鋪的團隊房間。
- 伺服器/系統狀態播報——你自己的健康檢查指令碼跑完 ⇒ 讓機器人把結果報進 IT 團隊盯著的群組房間。
機器人做不到的事(這是資料庫裡的真實情況,不是臨時限制):
- 機器人讀不到房間裡的訊息——它只出不進,除非你自己配了接收訊息的 webhook(見頁尾的技術參考)。而且就算配了,今天也不會真的有東西發出來,因為把佇列取出去投遞的定時器在生產環境上還沒配置。
- 機器人不能自己把自己拉進房間——永遠要由已經在那個房間裡的人(或者有該房間管理權限的人)來邀請。
- 一個機器人同時最多持有2個有效令牌,一個帳號最多建立5個機器人。
怎麼開始——四步
- 1建立機器人:給它起名字和使用者名稱(使用者名稱必須以
bot結尾,比如orderbot)——想讓它屬於店鋪(而不是隻屬於你個人),建立時選上店鋪就行。 - 2複製令牌:建立成功時系統只顯示一次令牌——立刻複製並妥善儲存。關掉那個介面之後,真實值再也取不回來(只能重新簽發一個)。
- 3把機器人請進房間:選你想讓它說話的會話、群組房間或團隊房間——你本人必須已經在那個房間裡(或者有該房間的管理權限)才能邀請。
- 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_target 傳 community_rooms.id/business_rooms.id,這已經可以用了——但邀請人必須過得了與那個房間含義相符的權限關,光是成員不夠:
- 群組房間——邀請人必須按房間自己的規則(
read_role/post_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 裡" }
請求體欄位
| 欄位 | 型別 | 必填 | 含義 |
|---|---|---|---|
kind | string | 否(預設 "chat") | 決定目標的型別——真正可用的只有三個值:"chat" · "community_room" · "business_room"。其他值會直接從資料庫拿到 reason: "bad_target"(不會悄悄退回 "chat"——以前把這個值寫死的那個 bug 已經修了)。 |
chat_id | integer > 0 | 是(或用 target_id) | 機器人已被邀請進去的目標房間 id——kind: "chat" 時是 chats.id,"community_room" 時是 community_rooms.id,"business_room" 時是 business_rooms.id(請求體裡的欄位名永遠是 chat_id/target_id,不會按 kind 換名字)。 |
text | string | 是 | 訊息正文,不能為空,長度不得超過 text_max_message 登記表定的上限(這個上限可調,不是寫死的數字——看 too_long 錯誤裡帶的 max)。 |
client_key | string | 否 | 防重複鍵,可以代替 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://,並且不能是內網/迴環/鏈路本地地址(localhost、127.0.0.1、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、169.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-Signature 是 HMAC-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 狀態碼。
| HTTP | reason | 來自 | 含義 / 怎麼解決 |
|---|---|---|---|
| 401 | bad_token | Worker / DB | 沒傳令牌,或者令牌錯誤/已被吊銷/機器人已停用——故意全部回同一個詞,防止被人逐一試探。 |
| 403 | not_invited | DB | 機器人還沒被請進這個房間,或者已經被踢了——先調 bot_invite。 |
| 403 | blocked | DB | 房間裡的使用者把這個機器人拉黑了(僅限非店鋪收件箱的房間)。 |
| 403 | account_suspended | DB | 房間裡有一方的帳號被封停了——這是權限問題,不是請求寫錯了。 |
| 404 | no_such_chat | DB | chat_id 根本不存在。 |
| 404 | no_such_endpoint | Worker | 路徑錯了,只有 POST /api/bot/send。 |
| 405 | method_not_allowed | Worker | 用了 POST 以外的方法。 |
| 409 | conversation_not_open | DB | 這是店鋪收件箱,客人還沒有先發過訊息——店家一側(包括機器人)不能先開口。 |
| 409 | chat_closed | DB | 交友配對的會話已經關閉,不再接收訊息。 |
| 422 | empty_text | DB | 去掉空白後 text 是空的。 |
| 422 | too_long | DB | 訊息超過長度上限——看響應裡帶的 max。 |
| 422 | bad_target | DB | kind 不是 "chat"/"community_room"/"business_room" 之一。 |
| 404 | no_such_room | DB | chat_id 對應的群組房間/團隊房間並不存在(房間已被刪除)。 |
| 409 | room_archived | DB | 這個房間已經歸檔關閉——人和機器人都發不了。 |
| 403 | invite_stale | DB | 邀請人的權限被重新計算後不透過了——邀請人退群/被降級/被撤銷 teamroom.manage/房間被關閉。細分原因看 detail(not_a_member/room_role/not_teamroom_manager)——解決辦法是讓仍然有權限的人重新邀請機器人(bot_invite),乾等沒用。 |
| 422 | bad_json | Worker | 請求體不是合法的 JSON。 |
| 422 | bad_chat_id | Worker | chat_id/target_id 必須是正整數。 |
| 422 | bad_text | Worker | text 必須是字串。 |
| 422 | idempotency_key_conflict | Worker | Idempotency-Key 和 client_key 同時傳了但值不一樣——傳一個就夠。 |
| 422 | bad_client_key | Worker | 防重複鍵格式不對(8–128 個字元,A–Z a–z 0–9 _ . : -)。 |
| 429 | rate_limited | Worker / DB | 超過上限——看 Retry-After 頭和 window/retry_after_sec 欄位。 |
| 502 | database_error | Worker | 資料庫回了,但不是 2xx——看附帶的 pg_code/detail,那是 Postgres 自己的原話。 |
| 503 | service_key_not_configured | Worker | 伺服器還沒配置 service key——這是 Korat 這邊的問題,不是呼叫方的。 |
| 504 | database_unreachable | Worker | 調資料庫失敗或超時——看 safe_to_retry:只有當初傳了 client_key/Idempotency-Key 時,重發才是安全的。 |
成功時的響應
{ "ok": true, "duplicate": false, "message_id": 88213 }
duplicate: true 表示這個請求之前已經發過了(靠 client_key/Idempotency-Key 去重)——沒有產生新訊息,但仍算成功(200),因為呼叫方想要的結果確實已經發生了。