文档索引

讨论机器人接入说明

面向站外开发者。读完本文即可实现一个讨论机器人,无需阅读本站源码。

本文是接口契约的唯一对外口径。字段、限频数值、错误码均照实抄录, 若与源码不符以源码为准(但也请提 issue —— 那就是本文过期了)。

0. 一句话说清

没有专用的「机器人接口」。 机器人就是一个普通的 core+ 用户账号, 走和对人完全一样的 HTTP 接口。你需要的只有三件事:

  1. 一个 core+ 账号(§2)
  2. 一条 SSE 长连接收消息(§6)
  3. 一个 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 三个必须处理的点

  1. Cookie 会过期(30 天)。收到 401 就重新登录一次,然后重连 SSE。
  2. 会话会被主动作废:改密码、被强制下线、被禁言 / 重置密码都会让旧 Cookie 立刻失效(服务端有 session_version 快照机制)。表现同样是 401。 ⚠️ 被降权(core → user)不属于这一类 —— 会话不会失效:连接会被踢 (见 §6.4),但重连后拿到的是 403 需要核心用户权限,不是 401。 别把它当会话失效去重登,重登也还是 403。
  3. 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 原生支持断线续传,请务必实现,否则断线期间的消息会永久丢失:

  1. 客户端记住收到的最后一个 id: 值;
  2. 重连时把它放进请求头 Last-Event-ID: <该值>;
  3. 服务端把 id 大于它的消息按序补发;
  4. 若积压超过 100 条补不齐,服务端改发一帧 {"type":"resync"};
  5. 收到 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)。 所以「让人看到机器人已读」这件事只有私聊做得到。

四个静默行为,实现时按此预期:

  1. 传了别的频道的 message_id → 不报错,服务端校验它属不属于本频道,不属于 就忽略该参数、回落成「这个频道的最新一条」。(不这么做的话,一个更大的 id 会把 游标推高、静默吞掉未读。)
  2. 没有权限 / 频道不存在 → 也是 200,但 message_id 会是 0。 所以「到底推进成功没有」要看 message_id 是不是 0,只看 code 会误判。
  3. 大区也会推进游标(顶栏「讨论」小红点随之熄灭),只是不推回执。
  4. 读到这个会话 = 该会话里 @ 你的通知一并标成已读(别人 @ 你产生的那套条目, 见 /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. 礼仪与约定

技术之外,两点请配合:

  1. 让机器人的身份可辨认。 建议用户名带 bot 后缀(如 mybot), 或在其个人简介里写明「这是机器人」。否则大区里的用户会以为在跟人对话。
  2. 别把讨论内容外送而不告知。 如果你打算把大区消息发给第三方服务 (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)拿一份「合集名 / 表情名」, 之后正文里写 [@猫猫/开心] 就是一张图。

本页内容来自仓库 docs/bot/chat-bot.md·在 GitHub 上查看