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),因为调用方想要的结果确实已经发生了。