# 机器人接入文档

把部署通知、业务提醒和自动化任务送进 Surprising 聊天。你的服务通过 HTTP 发送文字、Markdown 或交互卡片；用户回复和按钮操作通过 WebSocket 或 Webhook 回到你的服务。Web、iOS、Android 和桌面端使用同一个机器人会话。

## 快速开始

1. [打开应用中心的机器人](https://web.surprising.top/app/bots)，登录后点击「创建机器人」。
2. 填写名称和简介，保留默认的 WebSocket 模式。只发通知时，无需建立事件连接。
3. 保存创建后显示的 Token，然后在自己的服务端运行下面的命令。

> 完整 Token 只在创建或重置时显示。不要把 Token 放进网页源码、移动端代码、URL 或公开仓库。

```sh
export BOT_BASE_URL='https://api.surprising.top:8443'
export BOT_TOKEN='替换为创建时显示的 Token'
curl "$BOT_BASE_URL/api/openapi/v1/bot/messages" \
  -H "Authorization: Bearer $BOT_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"requestId":"hello-001","type":"text","content":"你好，第一条机器人消息已送达。"}'
```

打开机器人详情的「打开会话」即可查看消息。你也可以先在「接入与测试」中使用「发送到我的会话」，确认消息样式和接收效果。

官方服务的根地址是 `https://api.surprising.top:8443`，接口前缀是 `/api/openapi/v1/bot`。自行部署时将 `BOT_BASE_URL` 换成自己的后端 HTTPS 根地址，不含 `/api`。机器人 Token 使用 `Authorization: Bearer …`，不能替代用户登录凭证调用管理接口。

## 接收对象与权限

每个账户最多保留 3 个机器人，停用仍占名额，删除后释放。创建者默认订阅自己的机器人；发送时不传 `chatId`，就发送给创建者。

其他用户在应用中心通过机器人 ID 打开详情，点击「开始使用」后，你的服务才能向其会话发消息。使用该用户回复事件中的 `chatId`；不要自行拼接会话 ID。用户可以随时「停止接收」。

机器人消息在独立会话中展示，并有机器人标识。当前支持机器人与已订阅用户的一对一会话，不支持向好友私聊、群聊直接发送，也不提供定时发送接口。用户回复支持文字和引用文字；机器人会话不支持附件发送、音视频通话和阅后即焚。

## 发送与查询

| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/api/openapi/v1/bot/me` | 查看当前机器人的基本信息 |
| POST | `/api/openapi/v1/bot/messages` | 发送消息 |
| GET | `/api/openapi/v1/bot/messages/{requestId}` | 按发送请求 ID 查询投递状态 |

以上接口都需要 Bearer Token。发送和状态查询合计每个机器人每秒最多 10 次。

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `requestId` | 是 | 1–128 字符，同一机器人内唯一；重试时保持原值 |
| `chatId` | 否 | 已授权会话，不传则发送给创建者 |
| `type` | 是 | `text`、`markdown`、`card`，不区分大小写 |
| `content` | 是 | 非空正文，最多 16000 字符 |
| `title` | 否 | 卡片标题，最多 200 字符 |
| `image` | 否 | 卡片图片的 HTTPS 地址，最多 2048 字符 |
| `fields` | 否 | 卡片信息字段，最多 20 个 |
| `buttons` | 否 | 卡片按钮，最多 5 个 |

同一个 `requestId` 和相同消息内容重复请求，会返回同一条投递记录，不会重复发送。将同一个 ID 用于不同内容或不同会话会返回 409。网络超时后重试应保留原 ID；新消息使用新 ID。

所有接口响应使用 `{ "code": 200, "resp": ... }`。发送接口返回 HTTP 200 表示已写入聊天服务，202 表示已受理等待投递。以下是已发送时的响应示例，ID 和时间仅用于说明：

```json
{
  "code": 200,
  "resp": {
    "id": "投递记录 UUID",
    "requestId": "hello-001",
    "chatId": "bot_机器人UUID:用户ID",
    "status": "SENT",
    "messageId": "消息ID",
    "seq": 12,
    "error": null,
    "createdAt": "2026-10-28T00:00:00Z"
  }
}
```

`resp.status` 为 `PENDING`、`SENDING`、`SENT` 或 `FAILED`。只有 `SENT` 后才有 `messageId` 和 `seq`；它表示消息已保存，不代表用户已读。HTTP 202 时响应体的 `code` 仍为 200，应检查 `resp.status`。

```sh
curl "$BOT_BASE_URL/api/openapi/v1/bot/messages/hello-001" \
  -H "Authorization: Bearer $BOT_TOKEN"
```

## 消息格式与卡片

`text` 展示纯文字。`markdown` 支持标题、列表、粗体、代码和 HTTPS 链接；原始 HTML 只展示文字。图片、字段和按钮仅适用于 `card` 消息。

```json
{
  "requestId": "release-42",
  "type": "card",
  "title": "发布完成",
  "content": "**v1.2.0** 已发布。\n查看结果后确认。",
  "image": "https://your-host/release.png",
  "fields": [{ "label": "环境", "value": "生产" }],
  "buttons": [
    { "text": "查看详情", "url": "https://your-host/releases/42" },
    { "text": "确认", "action": "release.confirm" }
  ]
}
```

字段标题 `label` 最多 40 字符，`value` 最多 500 字符。按钮 `text` 最多 40 字符，每个按钮必须只包含 `url` 或 `action` 中的一个。链接仅支持 HTTPS，最多 2048 字符。`action` 使用 1–64 位字母、数字、下划线、点、冒号或连字符，同一张卡片内不能重复。

链接按钮打开外部地址；交互按钮向你的服务发送 `card.action` 事件。卡片按统一组件渲染，不允许任意 HTML 或脚本。转发机器人消息时会转成普通文字摘要，不会复制原卡片的操作权限。

## WebSocket 接收事件

适合不想部署公网回调地址的服务。创建机器人时保留默认 WebSocket 模式，你的后端连接下面的地址，在握手请求头中携带同一个 Bearer Token：

```text
wss://api.surprising.top:8443/api/openapi/v1/bot/events/ws
Authorization: Bearer YOUR_BOT_TOKEN
```

连接工具必须支持自定义请求头。浏览器原生 WebSocket 不适合保管机器人 Token。连接成功后收到：

```json
{"op":"ready","botId":"机器人 UUID","ackTimeoutSeconds":30}
```

事件帧示例：

```json
{
  "op": "event",
  "event": {
    "eventId": "事件 UUID",
    "type": "message.received",
    "chatId": "bot_机器人UUID:用户ID",
    "userId": "123",
    "data": {
      "messageId": "消息ID",
      "seq": 12,
      "message": { "type": "text", "content": { "text": "你好" } }
    },
    "createdAt": "2026-10-28T00:00:00Z"
  }
}
```

按钮事件的 `type` 为 `card.action`，`data` 为 `{ "messageId": "…", "action": "release.confirm" }`。所有用户 ID 按字符串处理。引用回复的 `message.type` 为 `quote_text`，`message.content` 为聊天协议定义的引用对象。

完成业务处理或可靠入队后发送确认；未确认的事件会再次投递，使用 `eventId` 做持久化去重。可每 25 秒发送心跳，服务返回 `{"op":"pong"}`。

```json
{"op":"ack","eventId":"收到的事件 ID"}
{"op":"ping"}
```

30 秒未确认或连接中断会重新投递。每个机器人只保留一个开发者连接，后建立的连接接管；重连时重新携带 Token，无需自行维护游标。[下载 Node.js 示例](bot-example.mjs)可用于连接、重连、心跳和自动回复。

## Webhook 接收事件

适合已有公网 HTTPS 服务的开发者。在机器人编辑页面选择 Webhook，填写公网 HTTPS 回调地址。一个机器人同时选择一种事件接收方式；只发送通知时不需要配置回调。

平台 POST 上面的 `event` 对象，不包含 WebSocket 的 `op/event` 外层。请求头包含：

```text
X-Surprising-Timestamp: Unix 秒
X-Surprising-Signature: sha256=十六进制 HMAC
Content-Type: application/json
```

在机器人详情中查看「回调签名密钥」，它与 API Token 不同。签名计算为 `HMAC-SHA256(webhookSecret, timestamp + "." + 原始请求正文)`。先检查时间差不超过 5 分钟，再对原始字节验签，使用恒定时间比较；通过后才解析 JSON。不要把解析后重新序列化的 JSON 用于验签。

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody 必须是原始请求正文 Buffer；secret 仅保存在服务端。
function verifyWebhook(rawBody, timestamp, signature, secret) {
  if (typeof timestamp !== 'string' || !/^\d+$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  if (typeof signature !== 'string' || !/^sha256=[0-9a-f]{64}$/.test(signature)) return false;
  const expected = createHmac('sha256', secret)
    .update(timestamp + '.').update(rawBody).digest();
  return timingSafeEqual(expected, Buffer.from(signature.slice(7), 'hex'));
}
```

验签不能代替去重，业务仍需使用 `eventId` 的持久化唯一约束。完成处理或可靠入队后返回 2xx；失败或超时会重试。同一事件保持相同 `eventId` 和正文，每次重新签名。自动重试最多 12 次，失败后可在「事件记录」手动重试。

回调地址必须可通过公网访问并具有有效 TLS 证书。平台拒绝内网、回环及特殊用途地址，不跟随重定向。不要填写 localhost、自签名地址或需要跳转的 URL。

## 可运行示例

[下载 bot-example.mjs](bot-example.mjs)，保存到本地目录。需要 Node.js 22 或以上。WebSocket 模式额外安装 `ws`；脚本会用事件 ID 构造回复消息的幂等键。

```sh
npm install ws
export BOT_BASE_URL='https://api.surprising.top:8443'
export BOT_TOKEN='替换为创建时显示的 Token'
node bot-example.mjs
```

Webhook 模式在 `127.0.0.1:8088/events` 监听，应由你自己的公网 HTTPS 反向代理转发到这个端口。将代理公网 URL 填入机器人编辑页：

```sh
export BOT_MODE='webhook'
export BOT_WEBHOOK_SECRET='替换为回调签名密钥'
node bot-example.mjs
```

脚本演示验签、确认、重连和幂等回复。生产业务需持久化事件去重记录，并针对外部业务的副作用设计幂等处理。不要在收到事件但尚未保存或处理时提前确认成功。

## 错误与投递重试

| HTTP 状态 | 常见原因 | 处理方式 |
| --- | --- | --- |
| 400 | 参数或卡片格式错误 | 检查必填字段、长度和按钮配置 |
| 401 | Token 无效或已重置，机器人停用或删除 | 检查 Token 与机器人状态，不要无限重试 |
| 403 | 会话尚未授权或已停止订阅 | 请用户「开始使用」，使用真实事件中的 chatId |
| 404 | 发送记录不存在 | 检查 requestId 是否属于当前机器人 |
| 409 | requestId 对应的内容或会话发生变化 | 原消息重试保持内容一致，新消息使用新 ID |
| 429 | 超出每秒 10 次的发送或查询限额 | 延迟后重试，避免密集轮询 |
| 5xx | 服务暂时不可用 | 退避重试，保留原 requestId |

发送到聊天服务的消息自动尝试最多 8 次，失败后可在「消息记录」手动重试。Webhook 事件自动尝试最多 12 次，失败后可在「事件记录」手动重试。停用机器人会暂停未完成投递，重新启用后继续。

## 管理与常见问题

### Token 关闭后找不到了？

完整 Token 无法再次查看。在机器人详情重置 Token 后更新你的服务配置，再重新连接。重置会同时更换 Webhook 签名密钥，并关闭旧开发者连接。

### 只想发通知，也要开 WebSocket 吗？

不用。只需 Token 和 HTTP 发送接口。需要处理用户回复或卡片交互时，再连接 WebSocket 或配置 Webhook。

### 为什么返回 202，却暂时没有消息？

202 表示已受理。使用相同 requestId 查询投递状态，或打开机器人详情的「消息记录」查看结果；不要换一个 ID 再发送同一条消息。

### 如何让其他人使用我的机器人？

把机器人 ID 给对方。对方在应用中心查找并点击「开始使用」后即可接收；让对方发一条文字，你的服务就能从事件中取得其 chatId。

### 能否同时使用 WebSocket 和 Webhook？

一个机器人选择一种事件接收方式。可以在编辑页面切换；WebSocket 模式每个机器人只有一个开发者连接，多端用户聊天不受此限制。

### 停用和删除有什么区别？

停用可恢复，仍占创建名额，并暂停未完成投递。删除后释放名额，原 Token 失效，需要重新创建机器人。
