讨论机器人接入说明
面向站外开发者。读完本文即可实现一个讨论机器人,无需阅读本站源码。
本文是接口契约的唯一对外口径。字段、限频数值、错误码均照实抄录, 若与源码不符以源码为准(但也请提 issue —— 那就是本文过期了)。
0. 一句话说清
没有专用的「机器人接口」。 机器人就是一个普通的 core+ 用户账号, 走和对人完全一样的 HTTP 接口。你需要的只有三件事:
- 一个 core+ 账号(§2)
- 一条 SSE 长连接收消息(§6)
- 一个 POST 发消息(§7)
这三条就能跑起来。 此外还有三个「让它像个人而不是一台复读机」的可选信号 —— 发表情(§7.4)、报正在输入(§7.5)、报已读(§7.6)—— 不实现不影响收发, 但对面会感觉在跟一堵墙说话。
无需申请 token、无需白名单、无需站长开任何开关。
1. 能力边界(先读这段)
机器人账号能看到什么,完全由「它是不是这个会话的成员」决定:
| 范围 | 能读 | 能发 | 说明 |
|---|---|---|---|
大区(channel_id = lobby) |
✅ | ✅ | 任何 core+ 都可读写,不需要加入成员表 |
| 私聊:别人主动发给机器人 | ✅ | ✅ | 机器人在成员表里 |
| 私聊:机器人主动发起 | ✅ | ✅ | 见 §9.1 用户搜索 |
| 私聊:别人与别人之间 | ❌ | ❌ | 非成员,读返回 403 / 404 |
大区是全局唯一的,其频道 id 字面量就是字符串 lobby,不随部署变化。
⚠️ 机器人享受不到任何豁免:它同样受限频约束(§10),也同样会被禁言 (被禁言期间所有讨论接口返回
403 你已被禁言,暂时无法讨论)。 这是刻意设计——出问题时站长能像处理普通用户一样处理它。
2. 准备账号
2.1 注册
POST /api/auth/register
Content-Type: application/json
{
"username": "mybot",
"email": "bot@example.com",
"password": "********",
"invite_code": "可选,填有效邀请码则直接升为 core",
"turnstileToken": "可选,站点启用 Turnstile 时必填"
}
校验规则:
| 字段 | 规则 |
|---|---|
username |
3–20 字符;仅允许字母 / 数字 / _ / -;不能以 - 或 _ 开头或结尾 |
email |
常规邮箱格式,≤100 字符 |
password |
≤100 字符 |
成功即自动登录(响应会 Set-Cookie,见 §3):
{
"code": 200,
"message": "注册成功",
"user": { "id": "u_xxx", "username": "mybot", "role": "user" }
}
📌 注册需由人工完成一次,这是预期流程。 机器人账号不开放脚本自助注册 (数量有限)。此外,站点若启用了人机验证(Turnstile),脚本同样无法自行注册。
做法:由人工在浏览器里建号一次,之后机器人用账号密码登录即可 —— 后续所有交互都不再涉及人机验证。
2.2 提权到 core+
注册出来是 user,讨论接口要求 core+,否则一律 403 需要核心用户权限。
两条路:
- 注册时带有效邀请码 → 直接是 core(推荐,全自动);
- 让站长在服务器上执行:
npm run cli -- promote-core mybot
3. 鉴权
本站的会话不是 Bearer token —— 它是一个 HttpOnly Cookie。(站内另有 Bearer 凭据,
但那是鱼干市场的只读凭据,与会话是两套东西,见 docs/bot/fish-bot.md §2.1.1。)流程:
POST /api/auth/login
Content-Type: application/json
{ "username": "mybot", "password": "********" }
响应头:
Set-Cookie: raricy_session=<JWT>; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000
| 属性 | 值 |
|---|---|
| Cookie 名 | raricy_session |
| 有效期 | 30 天 |
HttpOnly |
是 |
SameSite |
Lax |
Path |
/ |
Secure |
站点走 HTTPS 时为是 |
之后每个请求带上 Cookie: raricy_session=<JWT> 即可。
响应体:
{ "code": 200, "message": "登录成功",
"user": { "id": "u_xxx", "username": "mybot", "role": "core" } }
3.1 三个必须处理的点
- Cookie 会过期(30 天)。收到
401就重新登录一次,然后重连 SSE。 - 会话会被主动作废:改密码、被强制下线、被禁言 / 重置密码都会让旧 Cookie
立刻失效(服务端有
session_version快照机制)。表现同样是401。 ⚠️ 被降权(core → user)不属于这一类 —— 会话不会失效:连接会被踢 (见 §6.4),但重连后拿到的是403 需要核心用户权限,不是401。 别把它当会话失效去重登,重登也还是 403。 - CSRF 校验对你无影响。 本站对写请求校验
Origin/Referer同源, 但两者都缺失时放行。非浏览器客户端本来就不发这两个头 → 天然通过。 ⚠️ 所以不要自作主张伪造一个Origin,写错反而会被判定为跨源而 403。
3.2 会话是否仍有效
GET /api/auth/me
⚠️ 这个接口永远返回 200 —— 会话缺失 / 失效 / 被作废时返回的是
{ "code": 200, "message": "ok", "user": null },不会回 401。
所以判据是 user 是不是 null,不是状态码:
const me = await (await fetch(`${BASE}/api/auth/me`, { headers: { cookie } })).json();
if (me.user === null) { /* 该重新登录了 */ }
⚠️ 按「等 401」写探活是个静默错误:机器人会一直拿不到用户,却永远不重登, 然后在下一个业务请求上一起撞 401。适合在重连前探活,但请按
user判。
4. 响应信封与错误码
所有 JSON 接口共用同一信封(SSE 除外,见 §6):
{ "code": 200, "message": "ok", ...其他数据字段 }
出错时 code 与 HTTP 状态码一致,且只有 code 和 message:
{ "code": 403, "message": "需要核心用户权限" }
成功时 message 通常是 "ok" 或一句中文提示(如 "登录成功")。
⚠️ 有一个例外:发送消息接口(§7)成功时
message是一个对象而非字符串。 已在 §7.2 单独说明 —— 无论哪个接口,判断成功请一律只看code。
| 状态码 | 含义 | 常见 message |
|---|---|---|
400 |
参数错误 | 请求体格式错误 / 用户名和密码不能为空 |
401 |
未登录 / 会话失效 | 请先登录 / 用户名或密码错误 |
403 |
权限不足 / 被禁言 / 跨源 | 需要核心用户权限 / 你已被禁言,暂时无法讨论 / 跨源请求被拒绝 (CSRF) |
404 |
频道不存在或不可见 | 频道不存在 |
429 |
触发限频 | 发消息 发言过于频繁,请稍后再试 / 今日发言已达上限,请明日再试;对账 请求过于频繁,请稍后再试;登录 尝试过于频繁,请 15 分钟后再试 |
5xx |
服务器内部错误 | ⚠️ 不保证是本文的 JSON 形状 —— 讨论域自己从不产出 5xx,它只出现在未捕获异常时,由框架返回默认响应体。先判 HTTP 状态与能否解析 JSON,再取 code |
(原表这里写的是 503 服务暂时不可用 —— 讨论域没有任何一条路径会返回 503,那是个编出来的码,照它写分支等于写一段永不执行的代码。2026-09 核对全部 src/app/api/chat/** 的 apiErr 调用后删掉。)
⚠️ 讨论域的接口带
Cache-Control: no-store(apiOk/apiErr统一加), 别在客户端缓存这些响应。⚠️ 但别把它当全站保证:本文档顺带提到的/api/auth/*与/api/notifications/count走的是裸Response.json, 没有显式设这个头 —— 想缓存什么之前先自己看一眼响应头。
5. 时间戳格式(⚠️ 与全站同一个坑)
消息里的 created_at 是 ISO 8601 字符串,带 T 和 Z:
"2026-09-11T20:31:05.000Z"
但那个 Z 是假的。 本站全库时间戳的语义是「UTC+8 墙上时间,贴 Z 标签」——
上面这个例子表示的是北京时间 20:31:05,而不是 UTC 20:31:05。
// ✅ 想原样展示给用户 —— 截断即得北京时间墙上时间
const wall = created_at.slice(0, 19).replace('T', ' '); // "2026-09-11 20:31:05"
// ✅ 想得到真实瞬间(做时间差、跨时区换算)
const realMs = new Date(created_at).getTime() - 8 * 3600 * 1000;
// ❌ 错误 —— 会整整差 8 小时
new Date(created_at).toLocaleString('zh-CN')
⚠️ 同一个坑的完整写法见
docs/bot/comment-bot.md§5 —— 全站一个口径, 讨论区这条早些年是空格分隔的"YYYY-MM-DD HH:MM:SS",现在服务端统一走toISOString(),两种形状别混。📌 同一形状还出现在:
message.blog.updated_at(引用文章的更新时间)、channel.last_message.created_at。都是同一个假Z。
6. 收消息:SSE 长连接
GET /api/chat/stream
Cookie: raricy_session=<JWT>
Accept: text/event-stream
一条连接覆盖机器人可见的全部频道——大区广播 + 它的所有私聊。 不用轮询、不用分频道建连。
6.1 帧格式
标准 SSE 帧,事件类型放在 JSON 体内(而不是 SSE 的 event: 字段),
所以只需监听默认消息事件:
id: 12345
data: {"type":"message","channel_id":"lobby","message":{...}}
id: 用于断线补齐(§6.3)。它出现在 message 事件上(值 = 消息 id),
私聊的 read 事件上也有(值 = 对方读到的那条消息 id);typing 与 resync
不带 id。
⚠️ 所以「记住最后一个
id:」时别假定它一定是消息 —— 保守写法是只在message事件上更新游标。用read上那个值当游标也不会漏消息(它同样是消息 id), 但没必要。
连接建立时服务端会先发一帧:
retry: 3000
: connected
即「断线后 3 秒重连」+ 一个注释帧(用于立刻产生字节,避免被中间层当成空响应)。
📌 之后服务端每 25 秒发一个注释帧
: ping保活。标准 SSE 客户端会忽略注释帧 —— 你不用处理,但别把它当成数据帧,也别拿它去更新你的「最后一条消息」游标。
6.2 四种事件
type |
载荷字段 | 含义 |
|---|---|---|
message |
channel_id, message |
一条新消息(含你自己发的,用于多端同步) |
resync |
— | 积压太多补不齐,请重新拉取(§6.3) |
typing |
channel_id, user_id, username |
有人正在输入(不落库,3 秒后自行忽略) |
read |
channel_id, user_id, message_id |
私聊已读回执 |
message 事件的 message 字段就是 §11.1 的 ChatMessageDTO。
这四个是收方向:别人做的动作推给你。发方向的三个信号 —— 让别人看到「mybot 正在输入…」(§7.5)、把会话标记为已读并推回执(§7.6)—— 是你主动 POST 出来的。
6.3 断线补齐(重要)
SSE 原生支持断线续传,请务必实现,否则断线期间的消息会永久丢失:
- 客户端记住收到的最后一个
id:值; - 重连时把它放进请求头
Last-Event-ID: <该值>; - 服务端把
id大于它的消息按序补发; - 若积压超过 100 条补不齐,服务端改发一帧
{"type":"resync"}; - 收到
resync→ 自己重新GET /api/chat/channels/lobby/messages(§8) 拉一次当前首页,把状态对齐。
6.4 会被服务端主动断开的情形
- 背压:未消费帧积压超过 512 帧 → 视为客户端卡死,断开(重连+补齐即可)。
- 权限变更:被禁言 / 降权 / 开启专注模式 → 旧连接被踢,重连时重新鉴权。
- 网络:按
retry: 3000的节奏自动重连。
断开是常态,不是错误。一个健壮的机器人应当把「断开 → 重连 → 补发」 做成默认路径,而不是异常分支。
6.5 服务端响应头(无需处理,但别自作聪明)
服务端会带 Cache-Control: no-cache, no-transform 与 X-Accel-Buffering: no。
这不是给你的,是为了绕过 Next 的压缩中间件和 nginx 缓冲,否则事件会被攒着
一次性发出、实时性归零。你用普通 SSE 客户端读即可,不要尝试自己加
Accept-Encoding 或改动这些头。
7. 发消息
大区的 channel_id 固定为 lobby:
POST /api/chat/channels/lobby/messages
Cookie: raricy_session=<JWT>
Content-Type: application/json
{ "content": "你好,我是机器人" }
7.1 请求字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
content |
string | 否* | 正文,按 Markdown 渲染(见 §7.3);写 [@合集/表情] 即发表情(见 §7.4) |
image_id |
string | 否 | 引用图床图片,需先上传(§9.2) |
blog_id |
string | 否 | 引用一篇博客(UUID) |
reply_to |
number | 否 | 引用回复的消息 id,须同频道且未删除 |
pat_target_id |
string | 否 | 「拍一拍」目标用户 id。非空即拍一拍消息,此时正文/图片一律被忽略 |
* 至少要有一个有效载荷,否则会报参数错误。
长度上限(按 trim() 之后计算,恰好到顶放行):
| 场景 | 值 | 超限时的错误码 |
|---|---|---|
| 纯文本消息 | 5000 字 | tooLong |
带附件(image_id / blog_id)时的 content |
5000 字 | captionTooLong |
两档同值(2026-09 起)—— 此前是 1000 / 500。服务端仍分两条错误码,客户端按 文案处理即可:
消息不能超过5000字/图片或引用消息不能超过5000字。
7.2 成功响应(⚠️ 信封例外)
这个接口的 message 字段是对象,不是字符串 —— 见下方警告:
{ "code": 200, "message": { /* ← 这里直接是 ChatMessageDTO,见 §11.1 */ } }
⚠️ 不要按 §4 的常规信封解析本接口。 本站其他接口的
message都是 人类可读的提示字符串,唯独发送消息这个接口,message字段被消息对象 覆盖了(服务端实现里data的展开顺序导致了覆盖,原本的"发送成功"提示被丢弃)。稳妥的写法:成功只看
code === 200,然后再去取消息对象,不要依赖message是字符串。如果你的客户端此前按字符串处理,这里会拿到一个对象。
7.3 正文是 Markdown,但有白名单
正文经 marked → DOMPurify 白名单净化后渲染。允许的标签:
p br hr strong b em i u s del code pre blockquote ul ol li a
h1 h2 h3 h4 h5 h6 table thead tbody tr th td input
要点:
- ✅ 支持 GFM 任务列表(
- [x] 完成)、表格、代码块、引用、链接 - ❌ 不支持手写
<img>—— 白名单里没有,写了会以转义文本显示出来 - ✅ 但可以用内容引用语法贴图与内联剪贴板(见下方「内容引用」)
- ❌ 不支持
<iframe>/<svg>/<style>/<form> - 链接会自动加
rel/target加固,类名为chat-msg__link
内容引用 [@<内容ID>]
正文里可以写 [@<内容ID>],服务端原样入库,由渲染端按 ID 长度展开:
| 写法 | 长度 | 效果 |
|---|---|---|
[@a1b2c3d4] |
8 位 | 内联云剪贴板的正文(该剪贴板须公开,或你是作者) |
[@AbCdEf1234] |
10 位 | 内联图床图片 —— 渲染成 /api/images/<ID>/raw 的图,可点开放大 |
[@音频/AbCdEf1234] |
—— | 内联音频 —— 渲染成 /api/audio/<ID>/raw 的播放器(带引号里的那一段是具名命名空间,不按长度分流)。一条正文最多展开 3 个,见 docs/bot/audio-bot.md §6.3 |
[@vOtE12345] |
9 位 | 投票 —— 讨论正文里不展开,原样显示 |
[@123456] |
6 位数字 | 收藏夹 —— 讨论正文里不展开,原样显示(只有博客正文会渲染成卡片) |
要点:
- 一条消息最多展开 1 条云剪贴板,多写的那些原样留在正文里
- 图床图片最多 50 张,超出部分原样保留
- 剪贴板正文超过 2000 字会截断,并附一句「已截断」+ 回原剪贴板的链接
- 写在代码块 / 行内代码里的
[@id]不展开(要展示语法本身时就这么写) - 剪贴板正文是另一名用户写的,它随后与你的正文走同一条净化管线
- 图片必须是公开的,否则别人看到的是裂图(私有图只有作者能取)
⚠️ 表情不在上表里 —— 它用的是另一套语法(
[@合集/表情],两段是名字而不是 ID),见 §7.4。📌 用户名片同样不在上表里:
[@用户/<用户名>]渲染成一枚行内名片(头像 + 用户名, 点进主页)。它不算 @ 提及(对方不会收到通知),一条消息最多 5 张。机器人发它的 唯一用途是「把人带进对话」—— 取数与权限见docs/bot/account-bot.md§6。
发出去的原文原样入库,净化只发生在渲染时。所以你可以放心发 Markdown 源文。
7.4 表情:[@合集/表情]
正文里写 [@合集名/表情名],渲染出来就是一张表情图(可以夹在句子中间,
不会把整行断开):
{ "content": "今天天气真好 [@猫猫/开心] 出门走走" }
两段都是名字而不是 ID:合集名 是素材目录名,表情名 是文件名去掉扩展名。
先拉清单,别猜名字
GET /api/stickers
Cookie: raricy_session=<JWT>
需要 core+ 登录(你本来就登录着,见 §2.2)。非 core 账号返回 403 需要核心用户权限。
响应:
{
"code": 200,
"message": "ok",
"collections": [
{
"key": "猫猫", // ← token 的第一段用**这个**
"title": "猫猫合集", // ← 面板上的显示名,**不要**写进 token
"priority": 10, // 排序权重(降序);对机器人没用,忽略即可
"stickers": [
{ "name": "开心", "url": "/api/stickers/%E7%8C%AB%E7%8C%AB/%E5%BC%80%E5%BF%83" }
]
}
],
"empty": false // true = 站长一张素材都没放
}
拼 token 就是 [@ + key + / + name + ],原样抄。url 是图片直链
(相对路径,域名要自己拼),发消息时用不上 —— 正文里贴图走 image_id(§9.2)
或 [@<10位图片ID>](§7.3)。
几条会咬人的规则
| 规则 | 说明 |
|---|---|
| 一条消息最多 30 个 | 超出部分原样显示为文本 |
| 名字写错 = 显示原文 | 不是破图、也不是空白,读者会看见你写的 [@猫猫/并不存在] |
| 不产生通知,也不算 @ 提及 | 表情是内容,不是叫人 |
| 限频按一条消息算 | 与文字消息同吃 chatMinute / chatDaily(§10) |
| 名字里不能有空白 | 两段各 ≤32 字符;字符合集是 Unicode 字母 / 数字 / + · - |
| 只在讨论与评论里生效 | 博客正文与剪贴板正文里 [@猫猫/开心] 是字面量 |
⚠️ 上表最后一条藏着个坑:清单里若出现含
_之类集合外字符的表情名 (站长把文件命名成开心_2.gif就会这样),它列得出来、但 token 匹配不上 —— 发出去会显示成原文,且没有任何报错。拿到清单后按上面的字符合集过滤一遍, 遇到不合规的名字就跳过。
图片本身不需要登录
GET /api/stickers/<合集>/<表情> 直接返回图片字节,不校验登录:表情素材是站点
素材、不属于任何账号,这条路径上没有「档位」这个概念。带 1 天浏览器缓存。所以 url
可以贴给你自己的管理界面 —— 但别贴进消息正文。
站长随时可能删掉素材、或把整个合集在
info.json里标成ignore(效果是 从清单里消失,且手打 token 也取不到图)。所以别把表情清单当永久配置缓存, 隔一段时间重拉一次;token 失效时你的消息不会报错,只会显示成原文。
7.5 正在输入(typing)
一条 POST,不落库、不进消息流,只是让同频道的人短暂看到「mybot 正在输入…」:
POST /api/chat/channels/lobby/typing
Cookie: raricy_session=<JWT>
没有请求体。成功 {"code":200,"message":"ok"}。
| 行为 | 说明 |
|---|---|
| 谁收得到 | 大区 → 所有在线连接(含你自己 —— 要按 user_id 过滤掉自己的,否则你会看到自己在打字);私聊 → 只有对方 |
| 显示多久 | 对方收到后约 3 秒淡出,服务端不会替你重发 |
| 服务端节流 | 同一用户 + 同一频道 3 秒内只广播一次 |
| 被节流时 | 仍是 200,但响应里多一个 {"throttled":true} —— 不是错误 |
| 配额 | 不消耗 chatMinute / chatDaily(它不是消息) |
推荐用法:开始生成回复时发一次;生成超过 3 秒就每隔 ~3 秒补发一次, 直到把回复发出去为止。发完不用管,提示会自己淡出。
⚠️ 它是尽力而为的提示:被节流时也是
200,所以你无法确知对方到底看没看到。 不要为它设计重试或状态机 —— 掉了就掉了,下一条消息才是真的。
7.6 已读(read)
POST /api/chat/channels/d_xxx/read
Content-Type: application/json
{ "message_id": 12345 }
message_id 可省略(省略 = 读到该频道当前最新的一条)。作用是推进你在该频道的
读游标 —— 它决定了三件事:§9.3 里的 unread_count、顶栏「讨论」小红点熄不熄、
以及该会话里 @ 你的通知清不清(第 4 条)。响应:
{ "code": 200, "message": "已读", "message_id": 12345 }
⚠️ message 与 message_id 是两个不同的东西 —— 这里的 message 是字符串
"已读"(符合 §4 的常规信封),message_id(带下划线)才是数字。
回执只在私聊里推给对方(大区人多,不推):对方会实时收到
{"type":"read","channel_id":"d_xxx","user_id":"<你>","message_id":12345}(§6.2)。
所以「让人看到机器人已读」这件事只有私聊做得到。
四个静默行为,实现时按此预期:
- 传了别的频道的
message_id→ 不报错,服务端校验它属不属于本频道,不属于 就忽略该参数、回落成「这个频道的最新一条」。(不这么做的话,一个更大的 id 会把 游标推高、静默吞掉未读。) - 没有权限 / 频道不存在 → 也是
200,但message_id会是0。 所以「到底推进成功没有」要看message_id是不是 0,只看code会误判。 - 大区也会推进游标(顶栏「讨论」小红点随之熄灭),只是不推回执。
- 读到这个会话 = 该会话里 @ 你的通知一并标成已读(别人 @ 你产生的那套条目,
见
/api/notifications/*与 §9.7)—— 人已经把消息看了,铃铛就不该继续吊着。 只清这一个会话的「讨论提及」:别的会话、以及别种通知都不受影响。 (网页端进频道 / 在频道里看新消息时会自己调本接口,所以它也是这个语义。)
为什么值得实现:不调它,§9.3 里的
unread_count永远不会下降(看过的消息也 一直算未读),你的机器人会以为总有新消息;而且人对机器人说话时,永远等不到那个 「已读」。
7.7 报到「我正在看这个会话」(viewing)
POST /api/chat/channels/lobby/viewing
Cookie: raricy_session=<JWT>
没有请求体,成功 {"code":200,"message":"ok"}。
作用是让服务端知道你人正在这个会话里看着:别人 @ 你时,若服务端判定你正在看这个 频道,那条 @ 通知根本不产生(网页端开着讨论页且标签页可见时,客户端每 60 秒报一次; 标签页一关 / 一切后台就自动停报,服务端随即不再把你当成「在看」)。
| 行为 | 说明 |
|---|---|
| 谁需要它 | 只有网页端需要。 机器人不报也完全不缺什么 —— 不报就等于「每条 @ 都发通知」 |
| 频道不存在 / 无权限 | 也是 200(静默忽略):这不是用户可感知的动作,报不上只是让通知照常发 |
| 效力多久 | 报到后约 150 秒内有效,过期即失效(所以要周期续报,网页端是 60 秒一次) |
| 怎么算离开 | 不用报「我走了」:服务端以「你的 SSE 讨论流(§6)还连着吗」为准,连接一断立刻不算在看 |
| 配额 | 不消耗任何配额(同 §7.5 正在输入、§7.6 已读) |
⚠️ 报到不等于已读,两者互不代替:报到只决定「要不要发通知」,未读数与顶栏红点仍由 §7.6 的读游标决定。网页端是两个请求(
/viewing与/read),别指望一个顶两个。⚠️ 被抑制的那条 @ 不会在通知列表里留下任何条目(
/api/notifications数不出来), 但那条消息仍然是未读 —— 未读数与顶栏红点照常亮。「正在看就不打扰」不等于「当它没发生」。
8. 读历史消息
GET /api/chat/channels/lobby/messages?limit=50
GET /api/chat/channels/lobby/messages?before=1000&limit=50
GET /api/chat/channels/lobby/messages?after=1000&limit=50
| 查询参数 | 说明 |
|---|---|
limit |
1–100,默认 50(超出会被夹到区间内) |
before |
取 id 小于它的消息(向历史回溯) |
after |
取 id 大于它的消息(追赶新消息) |
三者关系:after 优先于 before;都不给则取最新的一页。
返回的 messages 一律按 id 升序(从旧到新),无论你用的是哪种模式。
响应:
{ "code": 200, "message": "ok", "messages": [ /* ChatMessageDTO[] */ ] }
💡 消息 id 是全局自增的(大区和所有私聊共用一个序列)。所以 id 可以直接 当作全局游标使用,不需要按频道分别维护。
9. 其他可能用到的接口
9.1 搜索用户(用于主动私聊)
GET /api/chat/users?q=<关键词>&limit=30&offset=0
返回 core+ 用户(排除自己)。拿到 user_id 后发起私聊:
POST /api/chat/channels
{ "user_id": "u_xxx" }
响应里的 channel.id 就是私聊的 channel_id,之后与使用 lobby 完全一致。
已存在的会话会被复用,不会重复创建。
此接口限频
20 次/分钟(防脚本批量建空会话骚扰他人侧栏)。
9.2 上传图片(正文不能贴图)
POST /api/images
Content-Type: multipart/form-data
<二进制文件>
返回的图片 id 即发消息时的 image_id。限频 200 次/小时。
(一个请求可以带多个同名 file 字段,此时响应里没有单个 id,成功的那几张在
items[].id、失败的带原因在 failed[]。限频按张数计。一次传一张最省事。)
9.3 拉会话列表与未读数
「有没有人跟我说话」的唯一来源:
GET /api/chat/poll?channel=lobby&after=1000
| 参数 | 说明 |
|---|---|
channel |
想顺带拉新消息的频道 id(省略 = 不拉消息) |
after |
拉 id 大于它的消息;要与 channel 一起给 |
响应里的 channels 是你可见的全部频道(大区 + 你的所有私聊):
{ "code": 200, "message": "ok",
"channels": [ /* ChatChannelDTO[],见 §11.2 */ ],
"channel_id": "lobby", // 回显你传的 channel(没传就是空串)
"messages": [ /* 只有给了 channel + after 才有;ChatMessageDTO[] */ ] }
对机器人最有用的几个字段是 id、kind(lobby / direct)、peer(私聊对方的
id 与用户名)、unread_count。
⚠️ 未读数不在 §8 那个接口里。
GET .../messages只回消息、不带未读, 全站也没有第二个地方给你未读条数。想知道「有没有人跟我说话」,要么读这里的unread_count,要么实时听 SSE(§6)自己数。两个都用是最稳的:SSE 存在「连着但收不到」的半死状态(反代掐连接、NAT 超时), 那种时候只有轮询能纠正数字。本站网页端也是这么做的。
⚠️ 这是全站最重的接口(一次列表 = 若干次数据库查询),限频 120 次/分钟(§10)。 正常用法是几十秒一次,不是每秒一次。
9.4 在会话内搜索历史消息
GET /api/chat/channels/lobby/search?q=关键词&page=1
返回 { messages, total, page, per_page },per_page 固定 20,结果按 id
倒序(新的在前)。q 为空 → 返回空数组(200),不是报错。搜的是正文原文
(含 Markdown 与 [@…] 记号);图片消息、拍一拍、已删除的消息搜不到。
适合「有人 @ 我提了个老问题,去翻上下文」这类场景。别拿它当消息同步用 —— 同步走 §8 或 SSE。
9.5 撤回(软删)自己发的消息
DELETE /api/chat/messages/12345
只能删自己发的。成功 {"code":200,"message":"已删除"}。删除是软删:不物理
抹除,别人的客户端会把正文换成 [该消息已删除]。
| 状态码 | 情形 |
|---|---|
404 |
消息不存在或已删除(删过第二次也是 404) |
403 |
不是你发的(管理员删别人是另一套:必须带 {"reason":"..."},机器人用不到) |
400 |
无效的消息 id |
💡 机器人的经典用法是「先发一条占位,算完再改成结果」—— 但本接口只能删、 不能改(没有编辑接口)。所以要么等算完再发,要么发一条新的、把占位那条删掉。
9.6 会话静音 / 从侧栏移除
POST /api/chat/channels/d_xxx/mute { "muted": true }
POST /api/chat/channels/d_xxx/hide
- 静音:该会话里 @ 你的站内通知不再产生(正文消息照收,SSE 照推)。
只影响通知,未读徽标照常。
{"muted": false}取消。 - hide:把会话从你的侧栏移除(「删除会话」)。不是退出、不是拉黑、 也不影响对方 —— 对方之后再发消息,会话会重新出现。同样是软删除。
静音对任意可见频道有效;hide 只对私聊有效 —— 对大区会返回
403 该会话不能删除(大区是全员频道,隐藏它没有意义,也会破坏置顶行)。
无权操作该会话仍是 403,频道不存在仍是 404。
9.7 有没有未读(最轻的一次探测)
GET /api/notifications/count
不需要登录(未登录返回全零)。响应:
{ "code": 200, "count": 3, "chatUnread": true }
⚠️ 两点与别处不同:响应体里没有 message 字段(所以别照 §4 那个信封去解构它),
而且 chatUnread 是布尔 —— 它只回答「有没有未读」,不回答「几条」。
count= 站内通知未读数(顶栏铃铛)。讨论消息不进这个数,别拿它当讨论未读。 它只数别人 @ 你产生的那些条目,而读掉某个会话会让这些条目归零(§7.6)—— 所以 这个数字会自己下降,不是只增不减。chatUnread= 讨论侧「私聊有未读 / 大区有人 @ 我」。
适合当第一道筛子(一次请求、几乎不耗资源):chatUnread 为 false 就跳过
§9.3 那次重查询。要具体数字还是得看 §9.3。
10. 限频
这是最容易踩的坑,请按此设计机器人行为。
| 规则 | 额度 | 适用 |
|---|---|---|
chatMinute |
120 次 / 分钟 | 发消息(拍一拍 / 表情 / 带图消息各算一条) |
chatDaily |
8000 次 / 24 小时 | 发消息 |
chatPoll |
120 次 / 分钟 | 会话列表与未读对账(§9.3) |
chatNewChannel |
20 次 / 分钟 | 发起私聊(§9.1) |
imageUploadHourly |
200 张 / 小时 | 上传图片(§9.2,按张数计) |
loginPerIp |
300 次 / 15 分钟 | 登录,仅统计失败 |
loginPerUser |
100 次 / 15 分钟 | 登录,仅统计失败 |
不在表里的接口(不消耗任何配额,但也别拿它们当心跳刷):
| 接口 | 为什么没有配额 |
|---|---|
| 正在输入(§7.5) | 只有一条「同频道 3 秒一次」的服务端节流 |
| 已读(§7.6) | 只是推进游标 —— 但留意它顺带决定顶栏「讨论」红点熄不熄,以及清掉该会话的 @ 通知 |
| 报到(§7.7) | 只写服务端进程内的一个条目,没有库操作;网页端本来就在 60 秒一次地报 |
| 表情清单(§7.4) | 素材变动很慢,一天拉几次足够 |
| 用户搜索(§9.1)、会话内搜索(§9.4)、撤回(§9.5) | 都是读或轻写,没有独立配额 |
GET .../messages(§8)同样没有独立配额(chatPoll 是给 §9.3 的对账接口的),
但这不代表可以拿它当轮询用 —— 它和 §9.3 一样重。
超限一律 429,message 按接口不同:发消息是 发言过于频繁,请稍后再试(分钟额度)
或 今日发言已达上限,请明日再试(日额度),对账是 请求过于频繁,请稍后再试。
判断一律只看 code === 429。
两点提醒:
- 登录成功不消耗配额(只统计失败),所以定时重登不会把自己挡在门外。
chatDaily每天 8000 条是硬上限。 一个「看到消息就回」的机器人很容易烧光, 然后当天彻底哑火。建议只回应 @ 到自己的消息(content里出现@mybot且按用户名精确匹配),而不是回应每一条大区消息。
限频计数存在服务端(进程内计数桶,定期落盘快照),重启不会清零 —— 别指望靠重启洗掉自己的用量。但它也不是持久化账本,不要拿它做用量统计。
11. 数据结构
11.1 ChatMessageDTO
{
"id": 12345, // 全局自增,可作游标
"channel_id": "lobby",
"author": {
"id": "u_xxx",
"username": "alice",
"avatar_url": "https://...", // 可能为空字符串
"is_admin": false
},
"content": "你好", // Markdown 原文
"image": { // 无图为 null
"id": "img_xxx",
"url": "https://...",
"mime_type": "image/png"
},
"image_missing": false, // 引用了图但图已不存在
"blog": { // 无引用为 null
"id": "uuid",
"title": "标题",
"description": "...",
"author": "alice", // 作者 username,可能为 null
"updated_at": "2026-09-11T20:31:05.000Z"
},
"blog_missing": false, // 引用了博客但已被删除
"pat": { // 拍一拍消息才有,否则 null
"target_id": "u_yyy",
"target_name": "bob" // 目标已不存在时为 "某位用户"
},
"reply": { // 引用回复才有,否则 null
"id": 12300,
"content": "被引用的正文",
"author_name": "carol", // 可能为 null
"is_deleted": false,
"image_url": null // 被引用的是图片消息时的缩略图
},
"is_deleted": false, // 软删除;正文会被替换为 "[该消息已删除]"
"created_at": "2026-09-11T20:31:05.000Z"
}
11.2 ChatChannelDTO(§9.3 的 channels[])
{
"id": "lobby", // 或 "d_xxx"
"kind": "lobby", // "lobby" | "direct"
"title": "讨论大区",
"peer": null, // 私聊才有:{ "id": "u_yyy", "username": "alice" }
"unread_count": 3, // 未读条数
"mention_count": 1, // 大区专属:未读里 @ 到我的条数(私聊无此字段)
"peer_last_read_message_id": null, // 私聊专属:对方读到的最新消息 id
"muted": false, // 已静音(只影响通知,见 §9.6)
"last_message": { // 无消息时为 null
"id": 12345,
"content": "…", // 预览:正文折叠成一行后截断到 60 字
"author_name": "alice",
"created_at": "2026-09-11T20:31:05.000Z"
},
"disabled": false // 专注模式下该频道不可进入(当前只有大区会 true)
}
11.3 ChatStreamEvent(SSE 事件)
见 §6.2。完整定义:
{ "type": "message", "channel_id": "lobby", "message": { /* ChatMessageDTO */ } }
{ "type": "resync" }
{ "type": "typing", "channel_id": "lobby", "user_id": "u_xxx", "username": "alice" }
{ "type": "read", "channel_id": "d_xxx", "user_id": "u_xxx", "message_id": 12345 }
12. 礼仪与约定
技术之外,两点请配合:
- 让机器人的身份可辨认。 建议用户名带
bot后缀(如mybot), 或在其个人简介里写明「这是机器人」。否则大区里的用户会以为在跟人对话。 - 别把讨论内容外送而不告知。 如果你打算把大区消息发给第三方服务 (AI 模型、日志平台等),请先和站长沟通,并在机器人简介里说明用途 —— 大区里的其他用户并没有同意自己的发言被送往站外。
13. 排错速查
| 现象 | 原因 |
|---|---|
登录成功但讨论接口 403 需要核心用户权限 |
账号是 user 不是 core+,见 §2.2 |
所有写请求 403 跨源请求被拒绝 (CSRF) |
你手动设置了错误的 Origin。非浏览器客户端直接不带该头即可(§3.1) |
讨论接口全部 403 你已被禁言… |
账号被禁言,等解禁或联系站长 |
429 |
触发限频,见 §10;发消息注意 chatDaily 8000 上限 |
| 突然收不到消息 | 连接被踢或背压断开(§6.4),实现重连 + Last-Event-ID 补齐 |
收到 {"type":"resync"} |
断线太久积压超 100 条,按 §8 重新拉一页对齐 |
| 图片发不出去 | 正文不支持手写 <img>,要先用 image_id(§7.3 / §9.2);也可以直接用 [@<10位图片ID>] 内联(§7.3) |
400 消息不能超过5000字 / 图片或引用消息不能超过5000字 |
正文超长(§7.1) |
| 消息时间差 8 小时 | created_at 带的是假 Z —— 语义是 UTC+8 墙上时间(§5) |
| 解析时间戳崩了 / 取不到值 | 服务端发的是 "…T…Z" 的 ISO 串,不是空格分隔的 "YYYY-MM-DD HH:MM:SS"(§5) |
| 探活一直「有效」,业务请求却 401 | /api/auth/me 永远回 200,要按 user === null 判(§3.2) |
| 对方一直看不到「正在输入」 | 三查:频道对不对(§7.5)、是不是被 3 秒节流了(被节流也是 200)、对方是不是开了专注模式(他收不到大区广播) |
拉到的 unread_count 永远不降 |
收消息不会自动标记已读,要自己调 §7.6 |
§7.6 返回 200 但 message_id 是 0 |
频道不存在 / 不是你的会话 / 该频道还没有消息 —— 看 message_id,别看 code |
表情发出去是原文 [@猫猫/开心] |
名字写错、合集被 ignore、或名字含 _ 等集合外字符(§7.4)。先拉 §7.4 的清单核对 |
| 拿不到未读条数 | GET .../messages(§8)不带未读,未读在 §9.3 |
| 正在某个会话里被 @ 却收不到通知 | 刻意的:服务端判定你正在看这个会话(§7.7),只是不打扰 —— 未读数与顶栏红点照常。调过 §7.7 又不想被抑制,就别再报到了(约 150 秒后自动失效) |
| 铃铛里的 @ 通知自己变成已读了 | 读到那个会话了(§7.6 第 4 条):进频道 / 看新消息都会把该会话的 @ 通知一并标已读 |
14. 最小可用流程
1. POST /api/auth/login → 存下 raricy_session
2. GET /api/chat/stream → 建 SSE 长连接
3. 收到 type=message 且 channel_id=lobby
→ 若 content 里 @ 到自己,则:
4. POST /api/chat/channels/lobby/typing ← 可选:先让对面看到「正在输入」
5. POST /api/chat/channels/lobby/messages
{ "content": "..." }
6. POST /api/chat/channels/d_xxx/read ← 可选:把读过的会话标记已读
(私聊会推回执,对面能看到「已读」)
断线时:Last-Event-ID 重连补齐;收到 resync 就重拉一页。
收到 401 就重新登录再重连。收到 429 就退避。
想让回复带表情:启动时 GET /api/stickers(§7.4)拿一份「合集名 / 表情名」,
之后正文里写 [@猫猫/开心] 就是一张图。