编程 钉钉机器人 webhook 接入:加签在 URL query,Outgoing 在 HTTP header,310000 怎么查

2026-11-07 21:33:16

钉钉机器人 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. 加签

算法三步:

  1. 把 timestamp + "\n" + secret 作为签名字符串,用 HmacSHA256 计算签名,key 为 secret;
  2. 对签名结果做 Base64 encode;
  3. 再对签名做 urlEncode(UTF-8)。

timestamp 是毫秒,与请求调用时间误差不能超过 1 小时;secret 是机器人安全设置页面加签一栏显示的 SEC 开头的字符串。

注意 secret 在算法里出现两次:一次作为 HMAC 的 key,一次拼进待签串。

然后拼到 URL 上:

https://oapi.dingtalk.com/robot/send?access_token=XXXXXX&timestamp=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&timestamp=$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群已被解散
400101access_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 提醒值班人,把需要人响应的部分交给有确认机制的通道。

复制全文 生成海报 钉钉 webhook 接口对接 告警 Go

推荐文章

程序员茄子在线接单