外观
机器人(Bot)开发文档
机器人(Bot)是 H5APP 内置的自动化消息账号。它有自己的头像和昵称,在用户的聊天列表里就是一个普通联系人,但背后由你的服务器驱动:可以主动给用户推送消息,也可以在用户发来消息时通过 Webhook 通知你的服务器,由你决定怎么回复。
典型用途:客服自动回复、订单/物流通知、告警推送、把外部系统的消息转发给用户。
能力边界
Bot 只能一对一私聊,且只能发给自己所属 H5APP 的关注用户。
不支持群聊、不支持群发广播、不支持给其他应用的用户发消息、不支持主动加好友。
一、开始之前
| 你需要 | 说明 |
|---|---|
| 一个已创建的 H5APP | 还没有请先看起步指南 |
| 一台能跑服务的服务器 | Bot 的逻辑运行在你自己的服务器上 |
| 一个公网可访问的回调地址 | 只有需要接收用户消息时才必需,见第六节 |
关注关系
用户在你的 H5APP 里通过 JSSDK 完成一次初始化(kg.config)后,就成为该应用的关注用户,此时 Bot 才能给他发消息。
没有关注过的用户,调用发送接口会返回「目标用户不存在或未关注」。
消息流向
text
┌─────────────────────────┐
用户 ──发消息──▶│ 看购平台(IM 服务) │
└───────────┬─────────────┘
│ Webhook(HTTP POST)
▼
你的 Bot 服务器
│ POST /api/mini/bot/send
▼
┌─────────────────────────┐
│ 看购平台(IM 服务) │──推送──▶ 用户
└─────────────────────────┘二、创建机器人
登录开放平台,进入「应用管理 → 机器人管理」,点击「新建机器人」。

| 字段 | 必填 | 说明 |
|---|---|---|
| 机器人名称 | ✅ | 展示给用户的昵称 |
| 用户名 / 密码 | ✅ | Bot 也是一个账号,这对凭据可以用来直接登录看购客户端查看它的会话,便于排查问题 |
| 描述 | 内部备注 | |
| Webhook | 接收用户消息的回调地址,留空则不接收 |
创建成功后,列表里会显示这个机器人的 botKey 和 botSecret(默认打码,点眼睛图标显示)。

凭证说明
| 字段 | 说明 |
|---|---|
botKey | 机器人公开标识,32 位十六进制字符串,作用等同 AppKey |
botSecret | 机器人密钥,只能保存在你的服务器上,作用等同 AppSecret |
botSecret 不要放进客户端
botSecret 一旦泄露,任何人都能以你的机器人身份给你的用户发消息。请把它写进服务器的环境变量或配置中心,不要提交进代码库、不要出现在 H5 页面或 App 里。
怀疑泄露时,在机器人列表点「重置 Secret」立即换一把新的,然后同步更新你服务器上的配置。
机器人状态
| status | 含义 |
|---|---|
| 0 | 待审核 |
| 1 | 正常(可用) |
| 2 | 停用 |
| 3 | 已删除 |
只有 status = 1 的机器人能换取 access_token。
三、获取 access_token
所有 Bot 接口都要在请求头带 Authorization: Bearer <access_token>。token 用 botKey + botSecret 换取。
POST /api/mini/bot/token
请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
botKey | string | ✅ | 机器人管理页获取 |
botSecret | string | ✅ | 机器人管理页获取,仅服务端持有 |
http
POST /api/mini/bot/token
Content-Type: application/json
{
"botKey": "bf37f92b4da604753f1ae787f7f36465",
"botSecret": "5c91c4f14dec40f5434bd39324a77f4f"
}响应
json
{
"code": 200,
"data": {
"access_token": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"expires_in": 31536000
}
}expires_in 是剩余有效秒数,当前为一年。
正确的 token 使用方式
不要每次发消息都调一次 /token,每调一次都会新签发一个 token。正确做法:
- 启动时取一次,缓存在内存里
- 按响应里的
expires_in计算过期时间,不要写死 7200 之类的常量 - 收到
401时重新取一次并重试该请求
重置 botSecret 后,必须用新的 secret 重新换取 token。
四、获取可发送的用户列表
发消息要指定 openId。openId 从这个接口拿。
GET /api/mini/bot/userList
返回本 H5APP 下的用户(在你的 H5APP 里初始化过 JSSDK,或私聊过你的机器人),按建立关联的时间倒序分页。
请求参数(Query)
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
page | int | 页码,从 1 开始,默认 1 | |
pageSize | int | 每页条数,默认 20,最大 200(超出按 20 处理) |
http
GET /api/mini/bot/userList?page=1&pageSize=20
Authorization: Bearer <access_token>响应
json
{
"code": 200,
"data": {
"list": [
{
"openId": "3f2a9c8b7e6d5a4b3c2d1e0f9a8b7c6d",
"nickname": "张三",
"avatar": "https://cdn.example.com/avatar.jpg",
"faceUrl": "",
"online": 1
}
],
"total": 1
}
}| 字段 | 类型 | 说明 |
|---|---|---|
openId | string | 用户在本应用内的标识,发消息、认人都用这个 |
nickname | string | 昵称 |
avatar | string | 头像 URL |
faceUrl | string | 聊天头像 URL,为空时用 avatar |
online | int | 1 在线,0 离线 |
只有 openId,没有平台用户 ID
接口不会返回用户在看购平台内的数字 ID 或全局 guid。
openId 是同一个人在不同 H5APP 下取值不同的匿名标识 —— 你拿到的 openId 只在你自己的应用里有意义, 换一个应用去查是查不到同一个人的。把平台内部 ID 发给你就等于让这层隔离失效,所以不发。
Webhook 回调里给的也是同一个 openId,两边对得上,不需要自己维护任何映射表。
五、发送消息
POST /api/mini/bot/send
给本应用的一个关注用户私聊发一条消息。
请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
openId | string | ✅ | 目标用户,来自 /bot/userList 或你自己的映射表 |
msgType | int | 消息类型,默认 0(文本),取值 0 ~ 11 | |
msg | string | ✅ | 消息内容,含义随 msgType 变化 |
http
POST /api/mini/bot/send
Authorization: Bearer <access_token>
Content-Type: application/json
{
"openId": "3f2a9c8b7e6d5a4b3c2d1e0f9a8b7c6d",
"msgType": 0,
"msg": "你好,这是一条来自机器人的消息"
}响应
json
{
"code": 200,
"data": { "ok": true }
}消息类型(msgType)
| 值 | 类型 | msg 的内容 | 建议 Bot 使用 |
|---|---|---|---|
| 0 | 文本 | 纯文本字符串 | ✅ 推荐 |
| 1 | 图片 | 可公网访问的图片 URL | ✅ |
| 2 | 视频 | 可公网访问的视频 URL | ✅ |
| 3 | 音频 | 可公网访问的音频 URL | ✅ |
| 4 | 商品卡片 | 商品信息 JSON | 需按客户端约定构造 |
| 5 | 系统消息 | — | ❌ 平台内部使用 |
| 6 | 撤回 | — | ❌ 平台内部使用 |
| 7 | 引用 | — | ❌ 平台内部使用 |
| 8 | 卡片 | 卡片 JSON | 需按客户端约定构造 |
| 9 | 位置 | 位置 JSON | 需按客户端约定构造 |
| 10 | 文件 | 可公网访问的文件 URL | ✅ |
| 11 | Markdown | Markdown 文本,客户端渲染成富文本 | ✅ 推荐用于结构化通知 |
TIP
msgType 传大于 11 的值会被拒绝并返回「msgType 无效」。
日常只用 0(文本)和 11(Markdown)就够了。通知类消息推荐 Markdown,排版比纯文本清楚很多。
六、接收用户消息(Webhook)
在机器人的 Webhook 字段填上你的回调地址后,用户私聊发给这个 Bot 的每条消息,平台都会以 HTTP POST 推送到该地址。
6.1 回调地址的硬性要求
平台对回调地址有严格校验,不满足会直接放弃投递(不会给你任何错误提示,只在平台侧留日志):
| 要求 | 说明 |
|---|---|
协议只能是 http / https | 其他协议一律拒绝 |
| 域名必须解析到公网 IP | 环回(127.0.0.1、localhost)、内网(10.x、172.16~31.x、192.168.x)、链路本地(169.254.x)、组播、CGNAT(100.64~127.x)全部拒绝 |
| 不跟随重定向 | 返回 3xx 视为失败,请直接给出最终地址 |
| 5 秒内建连并响应 | 超时视为失败 |
本机地址收不到回调
这是接入时最常见的问题。http://localhost:8080/webhook、http://192.168.1.5/webhook、内网穿透前的地址,平台一律不会连——校验发生在真正建立连接时解析出的 IP 上,所以把域名解析到内网 IP 也没用。
本地开发请用 ngrok、frp 这类工具映射出一个公网地址,或者先用第 6.4 节的演示接口确认平台侧确实发了。
6.2 回调请求格式
http
POST https://your-server.com/bot/webhook
Content-Type: application/json
{
"botId": 10,
"openId": "3f2a9c8b7e6d5a4b3c2d1e0f9a8b7c6d",
"msgId": 987654321,
"msgType": 0,
"msg": "你好,机器人",
"sendTime": 1717000000
}| 字段 | 类型 | 说明 |
|---|---|---|
botId | int64 | 收到消息的机器人 ID。一台服务器接多个 Bot 时用它区分 |
openId | string | 发消息的用户,与 /bot/userList 里的 openId 是同一个值 |
msgId | int64 | 消息 ID,平台内唯一,用它做幂等 |
msgType | int | 消息类型,取值同发送接口 |
msg | string | 消息内容 |
sendTime | int64 | 发送时间戳(Unix 秒) |
回调里的 openId 可以直接拿去回复
openId 原样填进 POST /api/mini/bot/send 就能回消息,中间不需要任何转换或映射表。
回调里不含用户在平台内的数字 ID —— 那是跨应用的内部标识,不对开发者开放(见第四节)。
6.3 你的 Webhook 服务需要做什么
- 接收 POST,解析 JSON body
- 在 5 秒内返回 HTTP 200
- 把耗时逻辑放到异步——调用大模型、查数据库、请求第三方,都不要在返回 200 之前做
- 按
msgId去重——同一条消息在极端情况下可能重复到达,重复处理会给用户发两遍回复 - 需要回复时,调用
POST /api/mini/bot/send
投递失败不会重试
平台对 Webhook 采用「尽力而为」投递:发一次,失败(超时、连不上、地址不合法、返回非 2xx)只记录日志,不重试、不补发。
你的服务重启、发版期间收到的消息会永久丢失。对可靠性有要求的场景,请让用户的关键操作走你自己的接口,不要只依赖 Webhook。
6.4 用演示接口调试
平台内置了一个演示用的接收端,可以先把 Bot 的 Webhook 指向它,确认平台侧确实发出了回调、以及回调的真实内容长什么样。
text
POST /api/mini/webhook 接收并暂存(保留最近 100 条)
GET /api/mini/webhook 查看已收到的记录,最新在前http
GET https://kg.citv.cc/api/mini/webhook
{
"code": 200,
"data": {
"list": [
{
"receivedAt": "2026-09-09T11:20:31+08:00",
"remoteAddr": "10.0.0.12:41022",
"body": { "botId": 10, "openId": "3f2a9c8b...", "msg": "你好", "...": "..." }
}
],
"total": 1
}
}演示接口不要用于生产
它无鉴权、任何人都能读写,数据只存在内存里、重启即清空、且全平台共用同一份缓冲区。仅用于调试。
七、完整示例
包含 token 缓存、用户映射表、Webhook 自动回复。
go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
const (
baseURL = "https://kg.citv.cc"
botKey = "bf37f92b4da604753f1ae787f7f36465"
botSecret = "5c91c4f14dec40f5434bd39324a77f4f"
)
var (
mu sync.RWMutex
token string
tokenExp time.Time
seenMsg = map[int64]bool{} // msgId 去重
)
// getToken 取或刷新 access_token(按 expires_in 计算过期,提前 1 小时刷新)
func getToken() (string, error) {
mu.RLock()
if token != "" && time.Now().Before(tokenExp) {
defer mu.RUnlock()
return token, nil
}
mu.RUnlock()
body, _ := json.Marshal(map[string]string{"botKey": botKey, "botSecret": botSecret})
resp, err := http.Post(baseURL+"/api/mini/bot/token", "application/json", bytes.NewReader(body))
if err != nil {
return "", err
}
defer resp.Body.Close()
var r struct {
Code int `json:"code"`
Data struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return "", err
}
if r.Code != 200 || r.Data.AccessToken == "" {
return "", fmt.Errorf("取 token 失败: code=%d", r.Code)
}
mu.Lock()
token = r.Data.AccessToken
tokenExp = time.Now().Add(time.Duration(r.Data.ExpiresIn-3600) * time.Second)
mu.Unlock()
return token, nil
}
// sendMsg 私聊发消息
func sendMsg(openId string, msgType int, msg string) error {
tk, err := getToken()
if err != nil {
return err
}
body, _ := json.Marshal(map[string]interface{}{
"openId": openId, "msgType": msgType, "msg": msg,
})
req, _ := http.NewRequest("POST", baseURL+"/api/mini/bot/send", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+tk)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
// token 失效:清掉缓存后重试一次
mu.Lock()
token = ""
mu.Unlock()
return sendMsg(openId, msgType, msg)
}
return nil
}
type webhookPayload struct {
BotId int64 `json:"botId"`
OpenId string `json:"openId"` // 发消息的用户,可直接回传给 /bot/send
MsgId int64 `json:"msgId"`
MsgType int `json:"msgType"`
Msg string `json:"msg"`
SendTime int64 `json:"sendTime"`
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
var p webhookPayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
w.WriteHeader(http.StatusOK) // 解析失败也返回 200,平台不会重投
return
}
// 幂等:同一条 msgId 只处理一次
mu.Lock()
dup := seenMsg[p.MsgId]
seenMsg[p.MsgId] = true
mu.Unlock()
// 先回 200,再异步处理 —— 平台只等 5 秒
w.WriteHeader(http.StatusOK)
if dup || p.MsgType != 0 {
return
}
go func() {
if err := sendMsg(p.OpenId, 0, "收到你的消息:"+p.Msg); err != nil {
fmt.Println("回复失败:", err)
}
}()
}
func main() {
http.HandleFunc("/bot/webhook", webhookHandler)
fmt.Println("Bot 服务已启动 :8080")
http.ListenAndServe(":8080", nil) //nolint:errcheck
}javascript
const express = require("express");
const BASE_URL = "https://kg.citv.cc";
const BOT_KEY = "bf37f92b4da604753f1ae787f7f36465";
const BOT_SECRET = "5c91c4f14dec40f5434bd39324a77f4f";
let token = "";
let tokenExp = 0;
const seenMsg = new Set(); // msgId 去重
async function getToken() {
if (token && Date.now() < tokenExp) return token;
const res = await fetch(`${BASE_URL}/api/mini/bot/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ botKey: BOT_KEY, botSecret: BOT_SECRET }),
});
const json = await res.json();
if (json.code !== 200) throw new Error("取 token 失败");
token = json.data.access_token;
// 按 expires_in 计算,提前 1 小时刷新
tokenExp = Date.now() + (json.data.expires_in - 3600) * 1000;
return token;
}
async function sendMsg(openId, msgType, msg) {
const tk = await getToken();
const res = await fetch(`${BASE_URL}/api/mini/bot/send`, {
method: "POST",
headers: {
Authorization: `Bearer ${tk}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ openId, msgType, msg }),
});
if (res.status === 401) {
token = ""; // token 失效,重取后重试一次
return sendMsg(openId, msgType, msg);
}
}
const app = express();
app.use(express.json());
app.post("/bot/webhook", (req, res) => {
const { openId, msgId, msgType, msg } = req.body || {};
// 先回 200,再异步处理 —— 平台只等 5 秒
res.sendStatus(200);
if (seenMsg.has(msgId)) return; // 幂等
seenMsg.add(msgId);
if (msgType !== 0) return;
(async () => {
await sendMsg(openId, 0, `收到你的消息:${msg}`);
})().catch((err) => console.error("回复失败:", err));
});
app.listen(8080, () => console.log("Bot 服务已启动 :8080"));八、错误码
接口统一返回 HTTP 200 + 业务 code,鉴权失败除外(HTTP 401)。
| HTTP | code | message | 排查方向 |
|---|---|---|---|
| 200 | 200 | ok | 成功 |
| 200 | 400 | botKey 和 botSecret 不能为空 | 请求体字段缺失或拼写错误 |
| 200 | 400 | botKey 不存在或已禁用 | botKey 填错,或机器人 status ≠ 1 |
| 200 | 400 | botSecret 错误 | botSecret 与 botKey 不匹配,或重置后未同步配置 |
| 200 | 400 | msg 不能为空 | msg 为空字符串 |
| 200 | 400 | openId 不能为空 | openId 缺失 |
| 200 | 400 | msgType 无效 | msgType 大于 11 |
| 200 | 400 | 目标用户不存在或未关注 | openId 不属于本应用,或该用户授权已被停用 |
| 401 | 401 | 缺少 Authorization Bearer token | 请求头没带,或格式不是 Bearer <token> |
| 401 | 401 | token 无效或已过期 | 重新调 /api/mini/bot/token 取一次 |
| 500 | 500 | token 生成失败 | 平台侧异常,稍后重试 |
| 500 | 500 | 消息发送失败 | 平台侧异常,稍后重试 |
九、常见问题
Q:Bot 能发到群里吗?能群发吗?
不能。当前只支持一对一私聊,且一次一个用户。要通知多个人请自己遍历 openId 列表逐个调用,并注意控制频率。
Q:Bot 能主动给没关注过的用户发消息吗?
不能。你只能发给已经出现在 /bot/userList 里的 openId。用户进入这份名单有两条路:在你的 H5APP 里完成一次 JSSDK 初始化,或者主动私聊你的机器人(收到第一条消息时平台自动建立关联,回调里就带着可用的 openId)。
两条路都要用户先动手,不存在凭空拿到某个人 openId 的办法。
Q:Webhook 配好了,用户发消息却收不到回调。
按顺序排查:
- 机器人
status是不是1 - 回调地址是不是
http/https开头 - 地址解析出来的是不是公网 IP——
localhost、内网段、CGNAT 一律不投 - 有没有 301/302 跳转(比如 http 自动跳 https),跳转会被判失败,请直接填最终地址
- 你的服务是不是超过 5 秒才响应
- 把 Webhook 临时指向
https://kg.citv.cc/api/mini/webhook,再GET同一地址看平台有没有发出来
Q:为什么接口里看不到用户的平台 ID?
openId 之外的用户标识(平台数字 ID、全局 guid)一律不对开发者开放。openId 按「用户 × 应用」生成,同一个人在不同 H5APP 下取值不同;把平台内部 ID 发出去,两个开发者一对表就能把同一个自然人串起来。
你需要的一切都能用 openId 完成:回调里给的是它,发消息要的也是它。
Q:/bot/send 报「目标用户不存在或未关注」。
openId 不属于你这个应用(比如从别处抄来的、或者手写错了),或者该用户的授权已在开发者中心被停用。授权被停用的用户,他发来的消息也不会再触发 Webhook。
Q:access_token 到底多久过期?要不要定时刷新?
以响应里的 expires_in 为准(当前为一年)。不要写死常量。缓存起来,收到 401 再重取即可。
Q:重置 botSecret 之后要做什么?
用新的 secret 重新调 /api/mini/bot/token 换一个 token,并同步更新服务器上的配置。
Q:同一个用户在我的两个 H5APP 里,Bot 看到的 openId 一样吗?
不一样。openId 按「用户 × 应用」生成。每个 Bot 只能看到自己所属应用下的 openId。
Q:消息发出去了但用户没收到。
先确认接口返回 {"ok": true}。返回成功说明消息已进入平台投递队列;用户端没有显示,通常是该用户把机器人会话删除或屏蔽了,也可能消息命中了内容安全审核。