机器人是什么,能拿来做什么
机器人是 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),因为调用方想要的结果确实已经发生了。