钉钉机器人 webhook 接入:加签在 URL query,Outgoing 在 HTTP header,别写反
钉钉群自定义机器人是告警和部署通知最省事的出口,但两个签名位置完全不同,310000 又只给一句 errmsg。下面按接入顺序记一遍,重点放在加签和限流上。
建机器人,拿 Webhook
群设置 → 智能群助手 → 自定义机器人,创建后拿到:
https://oapi.dingtalk.com/robot/send?access_token=XXXXXX
消息发送权限默认开通,不需要申请。POST 时字符集必须是 UTF-8,Content-Type 为 application/json。
频率限制:20 条/分钟,超了停 10 分钟
每个机器人每分钟最多发送 20 条消息到群里,超过会限流 10 分钟(错误码 410100)。官方对监控报警这类大量消息场景的建议是整合信息,用 markdown 消息以摘要形式发一条,而不是每个实例一条。
落到实现上就是本地先聚合:按 (告警名, 服务) 做 key,60 秒窗口内合并成一条 markdown,正文列出所有实例、首次/末次时间和累计次数,只发一次。不要在捕获到 410100 之后再重试,那只会把限流窗口往后推。
三种安全设置,至少配一种
1. 自定义关键词
最多 10 个,消息中至少包含其中 1 个关键词才发送成功。实现最简单,但关键词必须真的出现在发送内容里,改文案时容易漏。校验失败返回 310000 / keywords not in content。
2. 加签
算法三步:
- 把
timestamp + "\n" + secret作为签名字符串,用 HmacSHA256 计算签名,key 为 secret; - 对签名结果做 Base64 encode;
- 再对签名做 urlEncode(UTF-8)。
timestamp 是毫秒,与请求调用时间误差不能超过 1 小时;secret 是机器人安全设置页面加签一栏显示的 SEC 开头的字符串。
注意 secret 在算法里出现两次:一次作为 HMAC 的 key,一次拼进待签串。
然后拼到 URL 上:
https://oapi.dingtalk.com/robot/send?access_token=XXXXXX×tamp=XXX&sign=XXX
Go 实现(Go 1.17+,UnixMilli 依赖):
func sign(secret string, ts int64) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(ts, 10) + "\n" + secret))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func sendMarkdown(token, secret, title, text string) ([]byte, error) {
ts := time.Now().UnixMilli()
q := url.Values{}
q.Set("access_token", token)
q.Set("timestamp", strconv.FormatInt(ts, 10))
q.Set("sign", sign(secret, ts)) // Encode() 顺带做了 urlEncode
body, _ := json.Marshal(map[string]any{
"msgtype": "markdown",
"markdown": map[string]string{"title": title, "text": text},
"at": map[string]any{"atMobiles": []string{}, "isAtAll": false},
})
req, err := http.NewRequest(http.MethodPost,
"https://oapi.dingtalk.com/robot/send?"+q.Encode(), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body) // 记得解 errcode/errmsg,HTTP 200 不代表发送成功
}
Base64 里的 + / = 经 url.Values.Encode() 会转成 %2B %2F %3D,服务端解码正常;自行拼接 URL 时容易漏掉这一步,sign not match 多半由此而来。
不想写代码时,用一条 openssl + python3 的命令验证签名是否正确:
TOKEN='xxxx'; SEC='SECxxxx'
TS=$(python3 -c 'import time;print(int(time.time()*1000))')
SIGN=$(printf '%s\n%s' "$TS" "$SEC" | openssl dgst -sha256 -hmac "$SEC" -binary \
| base64 -w0 | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote_plus(sys.stdin.read().strip()))')
curl -s "https://oapi.dingtalk.com/robot/send?access_token=$TOKEN×tamp=$TS&sign=$SIGN" \
-H 'Content-Type: application/json' \
-d '{"msgtype":"text","text":{"content":"sign check"}}'
-binary 必须加,否则 openssl 输出的是 hex 而不是原始摘要。服务端返回 errcode:0 就说明 key 和待签串都对了。
3. IP 地址(段)
只有来自该范围的请求才处理,支持 IP 与 IP 段,暂不支持 IPv6 白名单。容器或弹性扩容之后出口 IP 会变,这一步容易失效,报错是 310000 / ip X.X.X.X not in whitelist。
错误码
| errcode | 含义 |
|---|---|
| -1 | 系统繁忙 |
| 40035 | 缺少参数 json |
| 43004 | 无效的 HTTP HEADER Content-Type |
| 400013 | 群已被解散 |
| 400101 | access_token 不存在 |
| 400102 | 机器人已停用 |
| 400105 | 不支持的消息类型 |
| 400106 | 机器人不存在 |
| 410100 | 发送速度太快而限流 |
| 430101 | 含不安全外链 |
| 430102/430103/430104 | 含不合适文本/图片/内容 |
310000 的四种 errmsg 怎么查
校验类错误统一返回 310000,只能靠 errmsg 区分:
keywords not in content:把实际发出的 JSON 打出来,确认关键词出现在text.content或markdown.text里,而不是只在title或日志里。十个关键词里命中一个即可,先核对是不是一个都没命中。invalid timestamp:多半传成了秒级时间戳,或者机器时钟漂移超过 1 小时。打印timestamp和time.Now().UnixMilli()对比,容器里检查 NTP。sign not match:逐项核对——待签串是否为timestamp + "\n" + secret(不是+号拼接)、HMAC key 是否为 secret(不是 access_token)、Base64 之后有没有 urlEncode、有没有对已编码的 sign 再算一次签名。ip X.X.X.X not in whitelist:从实际出网的机器上查真实出口 IP(NAT、CI runner、K8s 出网节点常与预期不符),并确认没有走 IPv6。
Outgoing:签名在 HTTP header,和上面完全不是一回事
主动发送是「自己算 sign 塞进 URL query」;Outgoing 是接收消息,方向相反:用户 @机器人 时钉钉回调开发者的 HTTPS 服务地址,HTTP header 里带 timestamp 与 sign,body 是 JSON(conversationId、atUsers、msgId、senderNick、sessionWebhook、text.content、msgtype 等)。
验证逻辑:header 里的 timestamp 与当前时间相差 1 小时以上判非法;sign 用 header 里的 timestamp + "\n" + 机器人 appSecret 做 HmacSHA256 再 Base64,与 header 中的 sign 比对。
两个 timestamp 都来自钉钉,但一个在 query、一个在 header;主动发送时 timestamp 由你生成,Outgoing 时由钉钉下发。把两套逻辑写进同一个 sign() 函数却用错位置,就是最常见的混点。
另外 sessionWebhook 有有效期,见 sessionWebhookExpiredTime(毫秒),回复消息要在过期前用它。
选哪种安全设置
- 只在内部群发通知、关键词能固定:关键词最省事。
- 出口 IP 不固定(云函数、容器、办公网):选加签,secret 不出现在请求里。
- 出口 IP 固定、且能维护白名单:IP 白名单配置成本最低,但弹性扩容和 IPv6 会踩坑。
- 公网可访问的 webhook 建议加签 + IP 白名单一起上;关键词的安全强度最弱,它只约束内容,不约束调用方。
P0 告警不要只依赖机器人
钉钉机器人是单群单 webhook 的通知通道:超过 20 条/分钟就限流 10 分钟,群解散、机器人停用、token 轮换、430101 外链拦截都会让消息直接发不出去,而且 HTTP 返回 errcode:0 也只代表钉钉收下了,不代表有人看到。P0 级别应该走电话/短信或专门的告警平台做升级链路,钉钉群作为同步和上下文补充;机器人侧只用 markdown 摘要 + atMobiles 提醒值班人,把需要人响应的部分交给有确认机制的通道。