批量发送人与机器人会话中机器人消息
第三方应用通过本接口,以指定内部应用机器人的身份,向一个或多个目标成员批量发送单聊消息(文本消息或结构化卡片消息)。
服务端会先为每个目标成员批量创建或复用机器人单聊会话,再并发执行消息投递,并按目标用户返回逐项结果。
接口信息
| 项目 | 说明 |
|---|---|
| 接口名称 | 批量发送人与机器人会话中机器人消息 |
| 权限标识 | robot.push(机器人消息推送) |
| 请求方式 | POST |
| 请求地址 | https://{gateway_host}/open/v1/robot/messages |
| 数据格式 | application/json |
| 鉴权方式 | 应用级 access_token(需绑定 robot.push 权限) |
| 幂等控制 | 请求头 Idempotency-Key(必填) |
注意:本接口是第三方应用机器人通道,使用应用级
access_token鉴权。Webhook 自定义机器人是另一条独立通道(使用加签方案),两者的鉴权凭证不能混用。
前置条件
- 应用状态:应用在管理后台「内部应用」中已发布版本且处于启用状态。
- 机器人启用:应用详情「机器人」面板中已开启机器人,且获取并记录了
robotCode。 - 权限与凭证:已获取有效的应用级凭据
access_token,且该令牌具备robot.push权限。 - 用户可见范围:目标接收人(
userids)必须处于该应用的可见范围内,超出可见范围的用户会被逐项拦截。
应用机器人接入步骤
应用机器人以应用身份向成员发送单聊消息,使用应用 access_token 和 robotCode。适合告警、待办和业务通知。
接入步骤
- 在应用配置中开启机器人能力并发布应用。
- 获取服务端使用的
robotCode,不要将它下发到客户端。 - 获取应用
access_token。 - 按本接口发送消息;传入多个目标用户即为批量发送。
应用机器人支持 text 和 card 消息;消息内容和卡片字段见消息类型与卡片。
四语言最小示例
Shell
curl -sS -X POST "https://{gateway_host}/open/v1/robot/messages" \
-H "Authorization: Bearer access-token-xxx" -H "Content-Type: application/json" \
-H "Idempotency-Key: robot-demo-001" \
-d '{"robotCode":"robot_xxx","userids":["user-001"],"msgType":"text","content":{"text":"服务通知"}}'
PHP
<?php
// 中文注释:机器人令牌和幂等键由服务端生成并保存。
$ch = curl_init('https://{gateway_host}/open/v1/robot/messages');
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer access-token-xxx', 'Content-Type: application/json', 'Idempotency-Key: robot-demo-001'], CURLOPT_POSTFIELDS => json_encode(['robotCode' => 'robot_xxx', 'userids' => ['user-001'], 'msgType' => 'text', 'content' => ['text' => '服务通知']], JSON_THROW_ON_ERROR), CURLOPT_RETURNTRANSFER => true]);
echo curl_exec($ch); curl_close($ch);
Golang
package main
import ("bytes"; "net/http")
func main() {
// 中文注释:重试时保持 Idempotency-Key 和请求体不变。
body := []byte(`{"robotCode":"robot_xxx","userids":["user-001"],"msgType":"text","content":{"text":"服务通知"}}`)
request, _ := http.NewRequest(http.MethodPost, "https://{gateway_host}/open/v1/robot/messages", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer access-token-xxx"); request.Header.Set("Content-Type", "application/json"); request.Header.Set("Idempotency-Key", "robot-demo-001")
response, _ := http.DefaultClient.Do(request); defer response.Body.Close()
}
C++
#include <curl/curl.h>
int main() {
// 中文注释:生产代码应读取逐项投递结果并记录 request id。
CURL* handle = curl_easy_init();
const char* body = R"({"robotCode":"robot_xxx","userids":["user-001"],"msgType":"text","content":{"text":"服务通知"}})";
struct curl_slist* headers = nullptr; headers = curl_slist_append(headers, "Authorization: Bearer access-token-xxx"); headers = curl_slist_append(headers, "Content-Type: application/json"); headers = curl_slist_append(headers, "Idempotency-Key: robot-demo-001");
curl_easy_setopt(handle, CURLOPT_URL, "https://{gateway_host}/open/v1/robot/messages"); curl_easy_setopt(handle, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(handle, CURLOPT_POSTFIELDS, body);
const CURLcode result = curl_easy_perform(handle); curl_slist_free_all(headers); curl_easy_cleanup(handle); return result == CURLE_OK ? 0 : 1;
}
请求参数
请求头
| 请求头 | 必填 | 说明 |
|---|---|---|
Content-Type | 是 | 固定为 application/json |
Authorization | 二选一 | Bearer <access_token>,推荐的令牌携带方式 |
access_token(Query 参数) | 二选一 | 备选方式:在 URL Query 中携带,如 ?access_token=xxx |
X-Request-Id | 是 | 请求追踪关联 ID,必须全局唯一且非空白,响应体 requestId 中将原样回显 |
Idempotency-Key | 是 | 客户端生成的幂等键,保证重试不产生重复消息 |
约束规则:
- 令牌只能通过
Authorization请求头或 Query 参数其一携带;若同时传递且值不一致将拒绝请求。 X-Secret内部共享密钥由受信网关自动注入,调用方严禁自行伪造。
请求体
{
"robotCode": "robot_7f3c1a2b-0d4e-4f5a-9b6c-8d7e6f5a4b3c",
"userids": ["10001", "10002"],
"msg": {
"mtype": "text",
"content": "您的验证码是 482913,5 分钟内有效。"
}
}
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
robotCode | string | 是 | 应用机器人的唯一调用标识 |
userids | string[] | 是 | 接收消息的用户 ID 列表(正整数字符串,不可重复,非空) |
msg | object | 是 | 消息主体对象 |
msg.mtype | string | 是 | 消息类型:text(文本)或 card(结构化卡片) |
msg.content | string | 条件必填 | text 类型时必填;card 类型时可选,卡片渲染与离线通知文案都取自 msg.ext |
msg.ext | object | 条件必填 | card 类型时必填,必须为 JSON 对象(传 JSON 字符串会被拒绝) |
字段约束:
userids必须使用字符串数组传输,避免整数精度丢失。单次请求中不能出现重复 ID,且必须是正整数。
消息类型与卡片结构
文本消息(text)
mtype 为 text,正文通过 content 传入:
{
"robotCode": "robot_xxx",
"userids": ["10001"],
"msg": {
"mtype": "text",
"content": "系统将在今晚 22:00 进行升级维护。"
}
}
结构化卡片消息(card)
mtype 为 card,卡片结构通过 msg.ext 传入,必须是 JSON 对象:
{
"robotCode": "robot_xxx",
"userids": ["10001", "10002"],
"msg": {
"mtype": "card",
"content": "",
"ext": {
"title": "版本发布",
"content": "版本发布窗口临近,请完成最后一轮检查并同步相关同学。",
"img": "https://xxx.png",
"button": [
{
"type": "normal",
"text": "稍后处理",
"actionType": "url",
"action": "https://example.com/release-notes"
}
]
}
}
}
卡片字段说明(msg.ext)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
title | string | 否 | 卡片大标题,最长 64 个字符 |
content | string | 否 | 卡片正文介绍,最长 2000 个字符 |
img | string | 否 | 卡片封面配图 URL,非空时必须为合法的绝对 HTTP/HTTPS 地址 |
button | object[] | 否 | 操作按钮数组(最多支持 3 个按钮) |
button[].type | string | 是 | 按钮样式,normal(普通按钮)或 primary(主按钮) |
button[].text | string | 是 | 按钮文本 |
button[].actionType | string | 是 | 按钮动作类型:url(跳转链接)或 none(无操作/纯展示) |
button[].action | string | 条件必填 | 按钮跳转 URL,actionType="url" 时必填 |
字段约束:
msg.ext.title与msg.ext.content是卡片的文字描述,按 Unicode 字符计数,缺失或显式为null视为未配置。两个字段展示位不同,各自使用独立上限:title最长 64 个字符,content最长 2000 个字符。- 单个字段超过各自上限时整批请求被拒绝,返回
card title is too long或card content is too long。 - 文字字段传入数字、对象、数组等非字符串取值时同样整批拒绝,返回
invalid card title或invalid card content,避免绕过长度限制。
离线推送文案:
- 接收方离线时通知栏文案优先取
msg.ext.title,标题为空取msg.ext.content, 两者都为空时使用兜底文案你有一条新消息(按 80 个字符截断展示)。
消息类型与卡片字段详解
说明应用机器人推送消息的 mtype、content 与 ext 结构,以及客户端展示规则。
本文只覆盖开放接口(POST /open/v1/robot/messages)支持的消息类型,
即 text 与 card 两种。接口调用方式、鉴权与错误码见《机器人文档》。
消息结构
开放接口中,消息通过 msg 对象传递:
{
"mtype": "text",
"content": "消息正文",
"ext": {}
}
| 字段 | 类型 | 说明 |
|---|---|---|
mtype | string | 消息类型,text 或 card |
content | string | 文本内容,mtype=text 时必填;mtype=card 时不使用 |
ext | object | 结构化扩展,mtype=card 时必填;mtype=text 时不使用 |
文本消息
最简形式,适合验证码、状态提醒等纯文本通知。
{
"mtype": "text",
"content": "您的验证码是 482913,5 分钟内有效。"
}
| 约束 | 说明 |
|---|---|
content 必填 | 为空时整批请求返回 content is required |
| 长度 | 无强制上限,建议控制在客户端可完整展示的范围内 |
卡片消息
卡片用于承载带标题、正文、配图和跳转按钮的结构化通知。
外层结构
mtype=card 时,卡片内容全部放在 ext 中,ext 必须是 JSON 对象:
{
"mtype": "card",
"content": "",
"ext": {
"title": "版本发布",
"content": "版本发布窗口临近,请完成最后一轮检查并同步相关同学。",
"img": "https://xxx.png",
"button": [
{ "type": "normal", "text": "稍后处理", "actionType": "url", "action": "https://example.com/release-notes" }
]
}
}
ext 字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
title | string | 否 | 卡片标题,为空则不显示 |
content | string | 否 | 卡片正文,为空则不显示 |
img | string | 否 | 卡片配图地址,为空则不显示 |
button | object[] | 否 | 卡片按钮数组,为空则不显示 |
ext 中没有任何必填业务字段:只带 title 的卡片、只带按钮的卡片都是合法的。
但 ext 本身必须存在,且必须是 JSON 对象——传数组或字符串会返回 card ext is invalid。
文字长度限制
title 与 content 是卡片的文字描述,按 Unicode 字符计数,缺失或显式为 null 视为未配置,
不参与校验。两个字段在卡片上的展示位不同,各自使用独立上限:
| 字段 | 上限 | 超限时返回 |
|---|---|---|
title | 64 个字符 | card title is too long |
content | 2000 个字符 | card content is too long |
| 情况 | 结果 |
|---|---|
| 单个字段不超过各自上限 | 通过 |
| 单个字段超过各自上限 | 拒绝,返回 card title is too long 或 card content is too long |
| 字段取值为数字、对象、数组等非字符串 | 拒绝,返回 invalid card title 或 invalid card content |
上限按单个字段计算,title 与 content 不会合并计数。校验失败会导致整批请求被拒绝,
同一批次的所有用户都不会收到消息。
离线推送文案
接收方 App 离线或处于后台时,卡片消息通过系统通知栏下发,展示文案按以下顺序取值:
| 顺序 | 取值 | 说明 |
|---|---|---|
| 1 | ext.title | 标题非空(去空白后)时优先作为通知文案 |
| 2 | ext.content | 标题为空时使用卡片正文 |
| 3 | 你有一条新消息 | 标题与正文都为空时使用兜底文案 |
通知文案按 80 个字符截断,ext.img、ext.button 等结构化字段不会进入通知栏。
同一会话在推送聚合窗口内累计多条消息时,通知栏改展示「你有 N 条新消息」,不再展示单条文字。
以上通知文案——包括兜底文案、聚合条数模板,以及图片、语音、视频、文件、位置、通话记录等各消息类型的
占位文案——统一在 common/constant/message.go 的「离线推送通知栏文案」常量表中定义。
文案语言当前固定为简体中文,多语言切换能力留待后续版本迭代。
按钮字段
| 字段 | 类型 | 说明 |
|---|---|---|
type | string | 按钮样式,normal(普通按钮)或 primary(主按钮) |
text | string | 按钮文案 |
actionType | string | 按钮事件类型,当前仅支持 url |
action | string | 按钮执行事件;actionType=url 时为打开的网页地址 |
按钮的展示与跳转规则:
-
卡片按钮的跳转地址统一由客户端内置浏览器打开。
-
第一行最多展示两个按钮,其余按钮每个独占一行并撑满宽度:
按钮数 布局 1 个 该按钮撑满一整行 2 个 首行并排显示两个 3 个及以上 首行两个,其余每个独占一行
地址校验规则
卡片中所有跳转地址必须携带 http:// 或 https:// 协议头。
以下字段会被校验,值必须是绝对 HTTP/HTTPS URL:
| 字段 | 校验条件 |
|---|---|
ext.img | 只要非空就校验 |
ext.button[].action | 仅当该按钮的 actionType 为 url 时校验 |
| 取值 | 结果 |
|---|---|
绝对 HTTP/HTTPS URL(如 https://example.com/a) | 通过 |
省略协议的地址(如 www.example.com) | 拒绝 |
javascript:、file:、data: 等自定义协议 | 拒绝 |
| 空字符串 | 通过,该字段视为未配置 |
校验失败会导致整批请求被拒绝,同一批次的所有用户都不会收到消息。 接口只返回统一的系统错误文案,不返回具体是哪个字段、哪个值不合法, 排障时需自行检查卡片内容。
按钮的 action 仅在该按钮的 actionType 为 url 时才会被校验;actionType 缺失或取值不为
url 时,该地址不会被校验,客户端也不会按网页跳转处理。因此链接类按钮请始终下发
actionType: "url",并携带完整协议头。
展示规则
以下规则影响消息的最终呈现效果,发送前建议确认卡片内容:
title、content、img、button任一为空时,对应区域不展示。- 卡片内容不会自动补全,也不会有兜底文案。
- 卡片解析异常时,可展示字段为空。
会话列表摘要
机器人会话列表中的摘要按以下优先级取值:
ext.title 非空 → 使用 title
ext.title 为空 → 使用 ext.content
两者都为空 → 使用统一卡片占位文案
搜索
用户搜索时,卡片的可搜索文本仅包含 title 与 content,不包含图片、按钮文案与 URL。
通道差异
card 类型仅在应用机器人通道(POST /open/v1/robot/messages)可用。
群机器人 Webhook 通道(POST /webhook/robot/send)支持的类型为
text、image、voice、video、file、location、custom,不包含 card。
幂等与重试机制
| 项目 | 说明 |
|---|---|
| 幂等标识 | 请求头 Idempotency-Key,调用方必填 |
| 作用范围 | 应用维度唯一,有效期为 24 小时 |
| 语义校验 | 服务端记录请求体摘要指纹 |
| 相同报文重复提交 | 判定为网络超时或重复重试,直接返回上次处理结果,不产生重复消息 |
| 不同报文复用同一 Key | 服务端拒绝请求,返回错误 IDEMPOTENCY_KEY_REUSED |
重试建议:在网络超时或未获得明确响应时,必须保持完全相同的 Idempotency-Key 与请求体进行重试。
响应参数
成功响应(HTTP 200,平台统一响应外壳):
{
"code": 200,
"msg": "",
"data": {
"requestId": "req-1710000000000000000",
"results": [
{
"index": 0,
"userId": "10001",
"status": "queued",
"sessionId": "30001",
"messageId": "1234567890123456789",
"seq": "102"
},
{
"index": 1,
"userId": "10002",
"status": "rejected",
"errorCode": "USER_NOT_ALLOWED",
"errorMessage": "user is not allowed"
}
],
"summary": {
"total": 2,
"success": 1,
"failed": 1
}
}
}
响应字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
code | integer | 业务状态码,200 表示请求已成功接收处理 |
msg | string | 错误信息,成功时为空 |
data.requestId | string | 对应请求头 X-Request-Id |
data.results | object[] | 目标用户逐条投递结果,顺序与请求 userids 一致 |
data.results[].index | integer | 对应请求中 userids 的下标(从 0 开始) |
data.results[].userId | string | 目标用户 ID |
data.results[].status | string | 投递状态:queued(已排队入库)、duplicate(幂等去重命中)、rejected(权限拦截)、failed(失败) |
data.results[].sessionId | string | 该用户与机器人的单聊会话 ID(成功时返回) |
data.results[].messageId | string | 消息唯一 ID(MID,成功时返回) |
data.results[].seq | string | 该会话内严格单调递增的序列号(成功时返回) |
data.results[].errorCode | string | 失败或拦截时的错误码(仅失败时返回) |
data.results[].errorMessage | string | 错误详情描述(仅失败时返回) |
data.summary | object | 汇总统计 |
data.summary.total | integer | 目标用户总数 |
data.summary.success | integer | 成功进入下发队列数(queued + duplicate 计入成功) |
data.summary.failed | integer | 失败/拦截总数(rejected + failed 计入失败) |
投递状态(status)取值
queued:消息已被服务端接受并成功写入消息队列,即将下发给客户端。duplicate:命中幂等缓存,此前已成功投递。rejected:目标用户未通过权限校验(如超出可见范围、用户不存在或被禁用)。failed:服务端会话创建或消息投递执行失败。
错误码说明
鉴权与传输错误(HTTP 状态码)
| HTTP 状态码 | msg 错误码 | 说明与处理建议 |
|---|---|---|
| 400 | INVALID_REQUEST | Content-Type 不为 application/json,或缺少 X-Request-Id |
| 401 | INVALID_TOKEN | 令牌缺失、已过期、失效,或应用已被停用/删除 |
| 403 | SCOPE_DENIED | 令牌缺少 robot.push 权限 |
| 403 | IP_DENIED | 请求来源 IP 未命中应用安全设置的 IP 白名单 |
| 503 | AUTH_UNAVAILABLE | 鉴权依赖暂时不可用 |
请求级业务错误(HTTP 200,code != 200)
msg 错误码 | 说明 |
|---|---|
missing idempotency key | 请求头缺少 Idempotency-Key |
invalid request | 请求参数缺失,robotCode、userids 或 msg.mtype 为空 |
content is required | 文本消息类型的 content 为空 |
card ext is invalid | 卡片消息的 ext 缺失、不是合法 JSON 对象,或传入了字符串 |
card title is too long | 卡片 ext.title 超过 64 个字符 |
card content is too long | 卡片 ext.content 超过 2000 个字符 |
invalid card title | 卡片 ext.title 不是字符串(如传入了数字、对象或数组) |
invalid card content | 卡片 ext.content 不是字符串(如传入了数字、对象或数组) |
unsupported message type | mtype 不是 text 或 card |
robot unavailable | 机器人或应用未启用、未发布,或机器人账号异常 |
invalid user id at index N | 第 N 个用户 ID 格式不是合法的正整数字符串 |
duplicate user id | userids 列表中包含重复的用户 ID |
IDEMPOTENCY_KEY_REUSED | 同一个 Idempotency-Key 绑定了不同的请求体 |
IDEMPOTENCY_UNAVAILABLE | 幂等存储不可用,请稍后重试 |
单项用户错误码(results[].errorCode)
errorCode | 对应 status | 说明 |
|---|---|---|
USER_NOT_ALLOWED | rejected | 目标用户不在当前应用的可见范围内,或用户不存在 |
USER_UNAVAILABLE | rejected / failed | 用户状态不可用(已被禁用或删除) |
SESSION_FAILED | failed | 创建或查询机器人单聊会话失败 |
SESSION_TYPE_INVALID | failed | 会话服务返回的会话类型不是机器人会话 |
MESSAGE_FAILED | failed | 消息投递 MQ 失败 |
调用示例
Shell (cURL) 完整示例
#!/usr/bin/env bash
set -euo pipefail
# 基础配置
GATEWAY_HOST="https://im-gateway.example.com"
APP_ID="app_10001"
APP_SECRET="link_sec_xxxxxxxxxxxxxxxxxxxx"
ROBOT_CODE="robot_7f3c1a2b-0d4e-4f5a-9b6c-8d7e6f5a4b3c"
# 获取应用级 access_token
echo "==> 正在获取 access_token..."
TOKEN_RESP=$(curl -s -X POST "${GATEWAY_HOST}/auth/v1/oauth/token" \
-H "Content-Type: application/json" \
-d "{
\"client_id\": \"${APP_ID}\",
\"client_secret\": \"${APP_SECRET}\",
\"grant_type\": \"client_credentials\"
}")
ACCESS_TOKEN=$(echo "${TOKEN_RESP}" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
if [ -z "${ACCESS_TOKEN}" ]; then
echo "获取 access_token 失败: ${TOKEN_RESP}"
exit 1
fi
echo "==> 获取成功: ${ACCESS_TOKEN:0:10}..."
# 构造请求头与消息体
REQUEST_ID="req-$(date +%s%N)"
IDEMPOTENCY_KEY="idem-$(date +%s)-${RANDOM}"
# 示例:发送结构化卡片消息
REQ_BODY=$(cat <<EOF
{
"robotCode": "${ROBOT_CODE}",
"userids": ["10001", "10002"],
"msg": {
"mtype": "card",
"content": "",
"ext": {
"title": "版本发布",
"content": "版本发布窗口临近,请完成最后一轮检查并同步相关同学。",
"img": "https://xxx.png",
"button": [
{
"type": "normal",
"text": "稍后处理",
"actionType": "url",
"action": "https://example.com/release-notes"
}
]
}
}
}
EOF
)
# 发送机器人消息
echo "==> 正在发送机器人消息..."
RESP=$(curl -s -X POST "${GATEWAY_HOST}/open/v1/robot/messages" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "X-Request-Id: ${REQUEST_ID}" \
-H "Idempotency-Key: ${IDEMPOTENCY_KEY}" \
-d "${REQ_BODY}")
echo "==> 接口返回结果:"
echo "${RESP}"
Golang 完整示例
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// 配置参数
const (
GatewayHost = "https://im-gateway.example.com"
AppID = "app_10001"
AppSecret = "link_sec_xxxxxxxxxxxxxxxxxxxx"
RobotCode = "robot_7f3c1a2b-0d4e-4f5a-9b6c-8d7e6f5a4b3c"
)
// OAuthTokenResponse 获取 Token 的响应结构
type OAuthTokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error,omitempty"`
}
// CardButton 卡片按钮
type CardButton struct {
Type string `json:"type"` // normal 或 primary
Text string `json:"text"`
ActionType string `json:"actionType"` // url 或 none
Action string `json:"action,omitempty"`
}
// CardExt 卡片内容结构
type CardExt struct {
Title string `json:"title,omitempty"`
Content string `json:"content,omitempty"`
Img string `json:"img,omitempty"`
Button []CardButton `json:"button,omitempty"`
}
// RobotMessage 消息结构
type RobotMessage struct {
MType string `json:"mtype"` // text 或 card
Content string `json:"content,omitempty"` // 文本正文或卡片摘要
Ext *CardExt `json:"ext,omitempty"` // 卡片扩展对象
}
// SendMessageRequest 发送消息请求体
type SendMessageRequest struct {
RobotCode string `json:"robotCode"`
UserIDs []string `json:"userids"`
Msg RobotMessage `json:"msg"`
}
// UserResult 单个用户的投递结果
type UserResult struct {
Index int `json:"index"`
UserID string `json:"userId"`
Status string `json:"status"` // queued, duplicate, rejected, failed
SessionID string `json:"sessionId,omitempty"`
MessageID string `json:"messageId,omitempty"`
Seq string `json:"seq,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
// Summary 批量汇总
type Summary struct {
Total int `json:"total"`
Success int `json:"success"`
Failed int `json:"failed"`
}
// SendMessageResponse 发送消息响应
type SendMessageResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
RequestID string `json:"requestId"`
Results []UserResult `json:"results"`
Summary Summary `json:"summary"`
} `json:"data"`
}
var httpClient = &http.Client{Timeout: 10 * time.Second}
// FetchAccessToken 获取应用级 access_token
func FetchAccessToken(ctx context.Context, host, appID, appSecret string) (string, error) {
reqBody, _ := json.Marshal(map[string]string{
"client_id": appID,
"client_secret": appSecret,
"grant_type": "client_credentials",
})
url := fmt.Sprintf("%s/auth/v1/oauth/token", host)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("token 请求失败: %w", err)
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("获取 token 错误 (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
}
var tokenResp OAuthTokenResponse
if err := json.Unmarshal(bodyBytes, &tokenResp); err != nil {
return "", fmt.Errorf("解析 token 响应失败: %w", err)
}
return tokenResp.AccessToken, nil
}
// SendRobotMessage 调用发送机器人消息接口
func SendRobotMessage(ctx context.Context, host, token, reqID, idemKey string, payload SendMessageRequest) (*SendMessageResponse, error) {
bodyData, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("序列化请求体失败: %w", err)
}
url := fmt.Sprintf("%s/open/v1/robot/messages", host)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Request-Id", reqID)
req.Header.Set("Idempotency-Key", idemKey)
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("发送消息 HTTP 请求失败: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("服务端返回异常 (HTTP %d): %s", resp.StatusCode, string(respBytes))
}
var result SendMessageResponse
if err := json.Unmarshal(respBytes, &result); err != nil {
return nil, fmt.Errorf("解析业务响应失败: %w", err)
}
return &result, nil
}
func main() {
ctx := context.Background()
// 1. 获取 access_token
token, err := FetchAccessToken(ctx, GatewayHost, AppID, AppSecret)
if err != nil {
fmt.Printf("获取 Token 失败: %v\n", err)
return
}
fmt.Println("成功获取 access_token")
// 2. 构造消息体(以结构化卡片为例)
reqPayload := SendMessageRequest{
RobotCode: RobotCode,
UserIDs: []string{"10001", "10002"},
Msg: RobotMessage{
MType: "card",
Content: "",
Ext: &CardExt{
Title: "版本发布",
Content: "版本发布窗口临近,请完成最后一轮检查并同步相关同学。",
Img: "https://xxx.png",
Button: []CardButton{
{
Type: "normal",
Text: "稍后处理",
ActionType: "url",
Action: "https://example.com/release-notes",
},
},
},
},
}
requestID := fmt.Sprintf("req-%d", time.Now().UnixNano())
idempotencyKey := fmt.Sprintf("idem-%d", time.Now().UnixNano())
// 3. 执行单聊机器人消息推送
resp, err := SendRobotMessage(ctx, GatewayHost, token, requestID, idempotencyKey, reqPayload)
if err != nil {
fmt.Printf("推送消息失败: %v\n", err)
return
}
// 4. 打印投递结果
fmt.Printf("消息发送完成! 状态码: %d, 汇总: 总数 %d, 成功 %d, 失败 %d\n",
resp.Code, resp.Data.Summary.Total, resp.Data.Summary.Success, resp.Data.Summary.Failed)
for _, item := range resp.Data.Results {
fmt.Printf(" - 用户 %s: 状态=%s, 会话ID=%s, 消息ID=%s, 错误码=%s\n",
item.UserID, item.Status, item.SessionID, item.MessageID, item.ErrorCode)
}
}
PHP
<?php
// 中文注释:卡片字段保持为嵌套对象,按钮动作使用纯 HTTP(S) URL。
$payload = [
'robotCode' => 'robot_xxx',
'userids' => ['10001'],
'msg' => [
'mtype' => 'card',
'content' => '',
'ext' => [
'title' => '版本发布',
'content' => '版本发布窗口临近,请完成最后一轮检查并同步相关同学。',
'img' => 'https://xxx.png',
'button' => [[
'type' => 'normal',
'text' => '稍后处理',
'actionType' => 'url',
'action' => 'https://example.com/release-notes',
]],
],
],
];
$handle = curl_init('https://{gateway_host}/open/v1/robot/messages');
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer access-token-xxx',
'Content-Type: application/json',
'X-Request-Id: req-20260920-0002',
'Idempotency-Key: idem-20260920-0002',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($handle);
curl_close($handle);
C++
#include <curl/curl.h>
int main() {
// 中文注释:action 字段必须是纯 URL,不能把 Markdown 链接语法作为请求值。
const char* payload = R"({
"robotCode": "robot_xxx",
"userids": ["10001"],
"msg": {
"mtype": "card",
"content": "",
"ext": {
"title": "版本发布",
"content": "版本发布窗口临近,请完成最后一轮检查并同步相关同学。",
"img": "https://xxx.png",
"button": [{
"type": "normal",
"text": "稍后处理",
"actionType": "url",
"action": "https://example.com/release-notes"
}]
}
}
})";
CURL* handle = curl_easy_init();
struct curl_slist* headers = nullptr;
headers = curl_slist_append(headers, "Authorization: Bearer access-token-xxx");
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = curl_slist_append(headers, "X-Request-Id: req-20260920-0002");
headers = curl_slist_append(headers, "Idempotency-Key: idem-20260920-0002");
curl_easy_setopt(handle, CURLOPT_URL, "https://{gateway_host}/open/v1/robot/messages");
curl_easy_setopt(handle, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(handle, CURLOPT_POSTFIELDS, payload);
const CURLcode result = curl_easy_perform(handle);
curl_slist_free_all(headers);
curl_easy_cleanup(handle);
return result == CURLE_OK ? 0 : 1;
}
会话与展示规则
机器人消息进入客户端后以机器人会话展示。应用后端只负责发送消息,不应假设客户端会话已存在或会立即打开。
展示要点
- 应用机器人以应用身份展示,群聊机器人以群内机器人身份展示。
- 会话列表摘要由客户端根据消息类型生成,撤回、卡片和附件可能使用不同的摘要文案。
- 机器人消息仍受目标用户、应用状态和可见范围约束。
- 应用后端应使用服务端返回的消息标识和逐项投递状态做审计,不要以客户端展示作为成功依据。
相关接口
批量发送(多个目标用户)
本接口同时承担单聊与批量发送:userids 传入多个目标用户时,服务端按用户逐项投递并返回逐项结果。除 userids 扩展为目标用户列表外,请求头、幂等键和其他字段与单聊发送完全一致。
{ "robotCode": "robot_xxx", "userids": ["user-001", "user-002"], "msg": { "mtype": "text", "content": "批量通知" } }
code != 200表示整批请求失败,不能按部分成功处理。results[]是逐项结果,必须按用户逐个处理:rejected通常是权限或可见范围问题,failed才按错误码决定是否重试。- 批量请求应按业务规模分批,避免瞬时触达过多成员。
- 网络超时使用同一
Idempotency-Key与请求体重试,避免重复发送。