Skip to content

机器人(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 ​

请求参数

参数类型必填说明
botKeystring✅机器人管理页获取
botSecretstring✅机器人管理页获取,仅服务端持有
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。正确做法:

  1. 启动时取一次,缓存在内存里
  2. 按响应里的 expires_in 计算过期时间,不要写死 7200 之类的常量
  3. 收到 401 时重新取一次并重试该请求

重置 botSecret 后,必须用新的 secret 重新换取 token。


四、获取可发送的用户列表 ​

发消息要指定 openId。openId 从这个接口拿。

GET /api/mini/bot/userList ​

返回本 H5APP 下的用户(在你的 H5APP 里初始化过 JSSDK,或私聊过你的机器人),按建立关联的时间倒序分页。

请求参数(Query)

参数类型必填说明
pageint页码,从 1 开始,默认 1
pageSizeint每页条数,默认 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
  }
}
字段类型说明
openIdstring用户在本应用内的标识,发消息、认人都用这个
nicknamestring昵称
avatarstring头像 URL
faceUrlstring聊天头像 URL,为空时用 avatar
onlineint1 在线,0 离线

只有 openId,没有平台用户 ID

接口不会返回用户在看购平台内的数字 ID 或全局 guid。

openId 是同一个人在不同 H5APP 下取值不同的匿名标识 —— 你拿到的 openId 只在你自己的应用里有意义, 换一个应用去查是查不到同一个人的。把平台内部 ID 发给你就等于让这层隔离失效,所以不发。

Webhook 回调里给的也是同一个 openId,两边对得上,不需要自己维护任何映射表。


五、发送消息 ​

POST /api/mini/bot/send ​

给本应用的一个关注用户私聊发一条消息。

请求参数

参数类型必填说明
openIdstring✅目标用户,来自 /bot/userList 或你自己的映射表
msgTypeint消息类型,默认 0(文本),取值 0 ~ 11
msgstring✅消息内容,含义随 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✅
11MarkdownMarkdown 文本,客户端渲染成富文本✅ 推荐用于结构化通知

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
}
字段类型说明
botIdint64收到消息的机器人 ID。一台服务器接多个 Bot 时用它区分
openIdstring发消息的用户,与 /bot/userList 里的 openId 是同一个值
msgIdint64消息 ID,平台内唯一,用它做幂等
msgTypeint消息类型,取值同发送接口
msgstring消息内容
sendTimeint64发送时间戳(Unix 秒)

回调里的 openId 可以直接拿去回复

openId 原样填进 POST /api/mini/bot/send 就能回消息,中间不需要任何转换或映射表。

回调里不含用户在平台内的数字 ID —— 那是跨应用的内部标识,不对开发者开放(见第四节)。

6.3 你的 Webhook 服务需要做什么 ​

  1. 接收 POST,解析 JSON body
  2. 在 5 秒内返回 HTTP 200
  3. 把耗时逻辑放到异步——调用大模型、查数据库、请求第三方,都不要在返回 200 之前做
  4. 按 msgId 去重——同一条消息在极端情况下可能重复到达,重复处理会给用户发两遍回复
  5. 需要回复时,调用 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)。

HTTPcodemessage排查方向
200200ok成功
200400botKey 和 botSecret 不能为空请求体字段缺失或拼写错误
200400botKey 不存在或已禁用botKey 填错,或机器人 status ≠ 1
200400botSecret 错误botSecret 与 botKey 不匹配,或重置后未同步配置
200400msg 不能为空msg 为空字符串
200400openId 不能为空openId 缺失
200400msgType 无效msgType 大于 11
200400目标用户不存在或未关注openId 不属于本应用,或该用户授权已被停用
401401缺少 Authorization Bearer token请求头没带,或格式不是 Bearer <token>
401401token 无效或已过期重新调 /api/mini/bot/token 取一次
500500token 生成失败平台侧异常,稍后重试
500500消息发送失败平台侧异常,稍后重试

九、常见问题 ​

Q:Bot 能发到群里吗?能群发吗?

不能。当前只支持一对一私聊,且一次一个用户。要通知多个人请自己遍历 openId 列表逐个调用,并注意控制频率。

Q:Bot 能主动给没关注过的用户发消息吗?

不能。你只能发给已经出现在 /bot/userList 里的 openId。用户进入这份名单有两条路:在你的 H5APP 里完成一次 JSSDK 初始化,或者主动私聊你的机器人(收到第一条消息时平台自动建立关联,回调里就带着可用的 openId)。

两条路都要用户先动手,不存在凭空拿到某个人 openId 的办法。

Q:Webhook 配好了,用户发消息却收不到回调。

按顺序排查:

  1. 机器人 status 是不是 1
  2. 回调地址是不是 http / https 开头
  3. 地址解析出来的是不是公网 IP——localhost、内网段、CGNAT 一律不投
  4. 有没有 301/302 跳转(比如 http 自动跳 https),跳转会被判失败,请直接填最终地址
  5. 你的服务是不是超过 5 秒才响应
  6. 把 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}。返回成功说明消息已进入平台投递队列;用户端没有显示,通常是该用户把机器人会话删除或屏蔽了,也可能消息命中了内容安全审核。