群聊机器人
群聊机器人绑定到指定群聊,外部系统通过 Webhook 即可向该群发送机器人消息,适合监控告警、CI/CD 通知和业务系统提醒。 群聊机器人与应用机器人是两条独立通道,凭证、鉴权方式和消息类型不能混用。
与应用机器人对比
| 对比项 | 群聊机器人 | 应用机器人 |
|---|---|---|
| 消息落点 | 指定群聊 | 与成员的机器人单聊 |
| 鉴权方式 | Webhook access_token + HMAC-SHA256 签名 | 应用级 access_token + 权限标识 |
| 凭证来源 | 在群聊中添加机器人后生成 | 应用配置中的机器人设置 |
| 支持的消息类型 | text、image、voice、video、file、location、custom | text、card |
| 目标控制 | 由机器人绑定的群聊决定 | 由目标成员列表和应用可见范围决定 |
| 是否需要发布应用 | 不需要 | 需要应用已发布并启用 |
不要把群聊机器人的 access_token 用于应用级 API,也不要把应用机器人的 robotCode 用于群聊 Webhook。
接口信息
| 项目 | 说明 |
|---|---|
| 接口名称 | 群聊机器人发送消息 |
| 请求方式 | POST |
| 请求地址 | https://{gateway_host}/webhook/robot/send |
| 请求格式 | application/json |
| 鉴权方式 | Query 参数 access_token、timestamp、sign |
| 成功响应 | 返回机器人消息 msgId |
接口不使用应用级 access_token,也不要求应用发布。请求通常返回 HTTP 200,调用方必须继续根据响应体中的 code 判断业务是否成功。
接入步骤
1. 创建群聊机器人
在目标群聊中添加机器人。创建成功后保存以下凭证:
| 凭证 | 用途 | 保管要求 |
|---|---|---|
access_token | 放在 Webhook URL 的 Query 中,标识机器人和目标群聊 | 按密钥管理,不能公开或写入前端 |
secret_key | 参与 HMAC-SHA256 签名校验 | 只保存在调用方服务端,不能提交到公开仓库 |
Webhook URL 的基础格式如下:
https://{gateway_host}/webhook/robot/send?access_token={access_token}
2. 构造请求参数
Query 参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
access_token | string | 是 | 群聊机器人的 Webhook 令牌 |
timestamp | int64 | 是 | 当前 Unix 时间戳,单位为毫秒 |
sign | string | 是 | 对签名原文执行 HMAC-SHA256 后进行 Base64 编码的结果 |
JSON 请求体
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
mtype | string | 是 | 消息类型,见消息类型 |
content | string | 是 | 消息内容,最多 65536 个字符 |
HTTP 原始请求体最大为 1 MiB,Content-Length 和 chunked 传输都受此限制。content 是 JSON 字符串字段;不要把应用机器人的 card 对象直接作为群聊机器人请求体发送。
文本消息示例:
{
"mtype": "text",
"content": "构建 #1024 已完成"
}
3. 计算签名
签名原文和计算方式固定如下:
stringToSign = timestamp + "\n" + secret_key
sign = Base64(HmacSHA256(stringToSign, secret_key))
计算时必须遵守以下规则:
timestamp使用毫秒级 Unix 时间戳,并以十进制字符串参与计算。- 时间戳和
secret_key之间只能有一个换行符\n。 - 使用
secret_key作为 HMAC-SHA256 的密钥。 - 对 HMAC 二进制结果做标准 Base64 编码,再对
sign做 URL 编码后放入 Query。 - 服务端只接受当前时间前后 5 分钟内的签名。
- 同一个签名在有效期内只能使用一次;重试必须重新生成
timestamp和sign。
4. 发送请求
完整请求格式如下:
POST https://{gateway_host}/webhook/robot/send?access_token={access_token}×tamp={timestamp}&sign={url_encoded_sign}
Content-Type: application/json
{"mtype":"text","content":"构建 #1024 已完成"}
5. 处理响应
成功响应:
{
"code": 200,
"data": {
"msgId": "message-id-xxx"
},
"msg": ""
}
失败响应:
{
"code": 500,
"data": null,
"msg": "invalid signature"
}
请求方应同时检查网络错误、HTTP 状态和响应体 code。对于 request too frequent, use a new timestamp and retry,不要复用原来的签名,应使用新的毫秒时间戳重新计算。
消息类型
群聊机器人支持以下消息类型:
mtype | 说明 |
|---|---|
text | 纯文本消息 |
image | 图片消息 |
voice | 语音消息 |
video | 视频消息 |
file | 文件消息 |
location | 位置消息 |
custom | 自定义消息 |
card 结构化卡片只适用于应用机器人,不适用于群聊机器人 Webhook。不同消息类型的 content 由消息服务按对应类型解析,发送前应确保内容格式与 mtype 匹配。
四语言调用示例
以下示例都使用相同的 Query 鉴权、毫秒时间戳和文本请求体。示例中的令牌、密钥和网关地址均为占位值,生产环境请从服务端密钥配置中读取。
Shell
#!/usr/bin/env bash
set -euo pipefail
GATEWAY_HOST="https://{gateway_host}"
WEBHOOK_TOKEN="webhook-token-xxx"
WEBHOOK_SECRET="webhook-secret-xxx"
TIMESTAMP=$(( $(date +%s) * 1000 ))
# 中文注释:签名只在服务端生成,不能把 secret_key 下发到浏览器或移动端。
STRING_TO_SIGN="$TIMESTAMP\n$WEBHOOK_SECRET"
SIGNATURE=$(printf '%b' "$STRING_TO_SIGN" \
| openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -binary \
| openssl base64 -A)
# 中文注释:Query 中的 Base64 签名必须 URL 编码,避免 +、/ 和 = 被错误解析。
TOKEN_URLENCODED=$(printf '%s' "$WEBHOOK_TOKEN" | sed 's/%/%25/g; s/+/%2B/g; s|/|%2F|g; s/=/%3D/g')
SIGNATURE_URLENCODED=$(printf '%s' "$SIGNATURE" | sed 's/%/%25/g; s/+/%2B/g; s|/|%2F|g; s/=/%3D/g')
curl -sS -X POST "$GATEWAY_HOST/webhook/robot/send?access_token=$TOKEN_URLENCODED×tamp=$TIMESTAMP&sign=$SIGNATURE_URLENCODED" \
-H 'Content-Type: application/json' \
-d '{"mtype":"text","content":"构建 #1024 已完成"}'
PHP
<?php
$gatewayHost = 'https://{gateway_host}';
$accessToken = 'webhook-token-xxx';
$secretKey = 'webhook-secret-xxx';
$timestamp = (int) floor(microtime(true) * 1000);
// 中文注释:时间戳与密钥之间必须只有一个换行符,且签名密钥只在服务端使用。
$stringToSign = $timestamp . "\n" . $secretKey;
$sign = base64_encode(hash_hmac('sha256', $stringToSign, $secretKey, true));
$query = http_build_query([
'access_token' => $accessToken,
'timestamp' => $timestamp,
'sign' => $sign,
]);
$body = json_encode([
'mtype' => 'text',
'content' => '构建 #1024 已完成',
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$handle = curl_init($gatewayHost . '/webhook/robot/send?' . $query);
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($handle);
if ($response === false) {
throw new RuntimeException(curl_error($handle));
}
curl_close($handle);
// 中文注释:生产环境应解析 code,并记录 msgId 或错误信息。
echo $response;
Golang
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
func main() {
gatewayHost := "https://{gateway_host}"
accessToken := "webhook-token-xxx"
secretKey := "webhook-secret-xxx"
timestamp := time.Now().UnixMilli()
// 中文注释:签名原文必须使用毫秒时间戳、一个换行符和 secret_key。
stringToSign := strconv.FormatInt(timestamp, 10) + "\n" + secretKey
mac := hmac.New(sha256.New, []byte(secretKey))
_, _ = mac.Write([]byte(stringToSign))
sign := base64.StdEncoding.EncodeToString(mac.Sum(nil))
query := url.Values{}
query.Set("access_token", accessToken)
query.Set("timestamp", strconv.FormatInt(timestamp, 10))
query.Set("sign", sign)
body := bytes.NewBufferString(`{"mtype":"text","content":"构建 #1024 已完成"}`)
request, err := http.NewRequest(http.MethodPost, gatewayHost+"/webhook/robot/send?"+query.Encode(), body)
if err != nil {
panic(err)
}
request.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request)
if err != nil {
panic(err)
}
defer response.Body.Close()
data, _ := io.ReadAll(response.Body)
fmt.Println(string(data))
}
C++
#include <curl/curl.h>
#include <openssl/evp.h>
#include <openssl/hmac.h>
#include <chrono>
#include <sstream>
#include <string>
std::string base64Encode(const unsigned char* data, unsigned int length) {
std::string output(4 * ((length + 2) / 3), '\\0');
const int encodedLength = EVP_EncodeBlock(
reinterpret_cast<unsigned char*>(output.data()), data, length);
output.resize(encodedLength);
return output;
}
std::string signRequest(long long timestamp, const std::string& secretKey) {
// 中文注释:HMAC 输入必须是 timestamp + 换行符 + secret_key。
const std::string stringToSign = std::to_string(timestamp) + "\n" + secretKey;
unsigned char digest[EVP_MAX_MD_SIZE];
unsigned int digestLength = 0;
HMAC(EVP_sha256(), secretKey.data(), static_cast<int>(secretKey.size()),
reinterpret_cast<const unsigned char*>(stringToSign.data()),
stringToSign.size(), digest, &digestLength);
return base64Encode(digest, digestLength);
}
int main() {
const std::string gatewayHost = "https://{gateway_host}";
const std::string accessToken = "webhook-token-xxx";
const std::string secretKey = "webhook-secret-xxx";
const auto timestamp = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
const std::string sign = signRequest(timestamp, secretKey);
CURL* handle = curl_easy_init();
if (handle == nullptr) {
return 1;
}
// 中文注释:对 Query 中的令牌和签名做 URL 编码,避免特殊字符破坏请求。
char* escapedToken = curl_easy_escape(handle, accessToken.c_str(), 0);
char* escapedSign = curl_easy_escape(handle, sign.c_str(), 0);
std::ostringstream url;
url << gatewayHost << "/webhook/robot/send?access_token=" << escapedToken
<< "×tamp=" << timestamp << "&sign=" << escapedSign;
curl_free(escapedToken);
curl_free(escapedSign);
const std::string body = R"({"mtype":"text","content":"构建 #1024 已完成"})";
struct curl_slist* headers = nullptr;
headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(handle, CURLOPT_URL, url.str().c_str());
curl_easy_setopt(handle, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(handle, CURLOPT_POST, 1L);
curl_easy_setopt(handle, CURLOPT_POSTFIELDS, body.c_str());
const CURLcode result = curl_easy_perform(handle);
curl_slist_free_all(headers);
curl_easy_cleanup(handle);
return result == CURLE_OK ? 0 : 1;
}
错误码说明
接口失败时通常返回 HTTP 200 和 code=500 的统一响应体。以下 msg 文案用于定位问题:
msg | 原因 | 处理建议 |
|---|---|---|
access_token is required | 缺少 access_token | 在 Query 中添加机器人令牌 |
timestamp is required | 缺少 timestamp | 添加毫秒级时间戳 |
invalid timestamp | 时间戳不是合法整数 | 使用十进制 int64 毫秒时间戳 |
sign is required | 缺少 sign | 计算签名后放入 Query |
robot not found | 令牌对应的机器人不存在 | 检查令牌,确认机器人仍在群内 |
robot secret key not configured | 机器人没有配置签名密钥 | 在机器人设置中配置或重新生成密钥 |
signature expired | 时间戳与服务端相差超过 5 分钟 | 使用当前时间重新计算签名 |
invalid signature | 签名原文、密钥或编码不正确 | 检查换行符、HMAC 密钥和 Base64 编码 |
request too frequent, use a new timestamp and retry | 同一签名已被消费 | 使用新的毫秒时间戳重新计算并重试 |
robot push disabled | 机器人消息推送已关闭 | 在机器人设置中开启消息推送 |
session not found | 机器人关联的群聊不存在 | 检查机器人绑定的群聊 |
unsupported message type | mtype 不在允许列表 | 使用支持的消息类型 |
invalid request body | JSON 格式或必填字段错误 | 检查 mtype、content 和 JSON 格式 |
request body too large | 原始请求体超过 1 MiB | 缩小请求体后重试 |
安全与重试建议
access_token和secret_key只保存在调用方服务端,不要放在前端、移动端、公开仓库或日志中。- 生产环境必须使用 HTTPS,避免令牌和消息内容在网络中被窃听或篡改。
- 不要把完整 Webhook URL 放入截图、工单或监控日志;如已泄露,应立即移除机器人或轮换凭证。
- 重试前重新获取当前毫秒时间戳并重新计算
sign,不要复用已经发送过的签名。 - 消息发送成功后保存响应中的
data.msgId,便于后续排查投递问题。