简介:这是一份面向C#开发者的钉钉消息推送源码示例,适合需要将钉钉通知集成到业务系统或OA场景的读者。资源以Visual Studio解决方案形式组织,包含工程文件、启动入口及依赖的SDK与序列化库,核心逻辑集中在入口源码中。使用前需在钉钉开放平台创建开发者应用,并配置AgentId、AppKey、AppSecret等凭证以获取access_token,再通过手机号调用GetUser接口取得userid,即可向指定用户发送工作通知。资源共11个文件,主要包括C#源码、编译缓存、DLL类库、XML配置、解决方案与项目文件,整体压缩包仅907KB,轻量易用。目前已有1749人学习下载,对于刚接触钉钉开放接口的开发者来说,是一份能快速跑通消息推送链路的参考实现,也可作为二次封装与异常处理的基础模板。
1. 钉钉消息推送:为什么监控系统第一件事就是对接它
做运维告警、订单通知、定时报表推送的时候,钉钉机器人几乎是团队里出现频率最高的消息出口。钉钉的机器人接口本质上就是一个 HTTP POST 通道,加上签名、安全策略和几种固定的消息体结构,并不需要引入额外的消息中间件,也不需要维护长连接。这篇文章把 C# 对接钉钉机器人从选型、签名、发送到排错的过程拆开讲,新手能照着搭出第一个可用的推送服务,熟手也能在参数和边界上少踩几个坑。适合做监控告警、业务通知、批量报表推送的 .NET 开发者直接复现。
2. 机器人类型与选型:自定义 Webhook 和内部应用差在哪
2.1 自定义机器人:Webhook、加签与关键字规则
自定义机器人是目前中小团队用得最多的方式。在群设置里添加一个自定义机器人,钉钉会生成一个形如https://oapi.dingtalk.com/robot/send?access_token=xxxx的 Webhook 地址,往这个地址 POST 一段 JSON 就能把消息推到群里。整个过程不涉及企业应用审批,也不依赖 OAuth,属于最直接的接入路径。
创建时有三类安全设置可以选:关键字、加签、IP 白名单。关键字要求你推送的消息正文里必须包含某个词,比如“告警”“监控”这类,否则请求会被拒绝;加签要求每次请求带上用密钥算出来的签名,服务端验签通过才接收;IP 白名单则限制来源 IP。日常用监控告警场景,建议关键字和加签都开,关键字兜底正文过滤,加签兜住伪造请求。只开关键字的话,一旦消息内容调整漏掉关键字,会直接翻车,而且这个问题排查起来很隐蔽。
Webhook 的 URL 里只有 access_token 是必需参数,加签模式还需要额外带 timestamp 和 sign 两个查询参数。timestamp 是当前毫秒级时间戳,sign 是用密钥对时间戳签名后的 Base64 字符串。这里最容易搞错的是签名串的格式:timestamp + "\n" + secret,换行符是明文拼进去的,不是分隔符,少一个换行签出来的值服务端就不认。
2.2 内部应用机器人:获取 access_token 再推送
自定义机器人适合群内通知,但它绑定的是某个群,跨团队、跨群复用就得每个群都加一遍机器人。内部应用机器人走的是另一条链路:在企业后台创建应用,拿到 appKey 和 appSecret,然后以应用的身份调用机器人接口发送消息。它不依赖群里的 Webhook,可以按部门、按用户维度定向推送,权限模型也更接近企业级应用。
内部应用的核心流程分两步。第一步是换取 access_token,调用https://oapi.dingtalk.com/gettoken?appkey=xxx&appsecret=xxx,接口返回 JSON 里有 access_token 字段,有效期通常是两小时。第二步是拿着 access_token 拼到https://oapi.dingtalk.com/robot/send?access_token=xxx,请求体和自定义机器人完全一致。也就是说,消息结构是同一套,只是身份认证从“群的 Webhook 密钥”换成了“应用的 access_token”。
注意一点:内部应用机器人通常需要先申请接口权限点,比如“机器人发送消息”的权限范围,否则调用时会返回权限不足。权限点申请在企业后台的应用详情里操作,审批通过后一般几分钟内生效。第一次接入的人容易忽略这个前置步骤,拿到 token 后直接发消息,结果收到一堆权限报错,属于比较典型的“差一步”场景。
2.3 怎么选:一张表看清两类机器人
两类机器人没有绝对优劣,主要看你的消息是发给“群”还是发给“应用成员”。我平时会按下面的维度快速做判断:
| 维度 | 自定义机器人 | 内部应用机器人 |
|---|---|---|
| 认证方式 | Webhook URL + 可选签名/关键字/IP | appKey + appSecret 换取 access_token |
| 配置成本 | 群内添加,分钟级可用 | 企业后台创建应用,需申请权限点 |
| 消息范围 | 只能推到指定群 | 可按应用维度调用,范围更广 |
| 安全强度 | 中,依赖加签和关键字兜底 | 高,走应用级令牌 |
| 维护成本 | 低,机器人跟群绑定 | 中,要维护 token 刷新逻辑 |
| 典型场景 | 监控告警、团队通知、日报推送 | 跨群定向通知、业务系统集成 |
提示:自定义机器人并不能真正“跨群”复用。一个 Webhook 只对应一个群,如果要把同一条消息推到多个群,得维护一个 Webhook 列表逐一发送。
选择逻辑其实很直白:临时通知、监控告警、内部工具提醒,优先自定义机器人;如果消息要按组织架构定向发,或者后续要接人、组织相关的接口能力,再上内部应用。不要一上来就做应用,监控场景里自定义机器人往往已经把 80% 的需求吃掉了。
3. C# 发送实现:签名计算、消息体构造与超时控制
3.1 签名算法:HMAC-SHA256 的 C# 实现与 URL 编码
加签模式下,签名计算是整个接入过程里最容易出问题的一环。算法本身不复杂,但实现细节决定了能否通过服务端验签。标准步骤是:取当前毫秒时间戳,拼出timestamp + "\n" + secret字符串,用 secret 作为密钥做 HMAC-SHA256 哈希,对结果做 Base64 编码,最后对编码后的字符串做 URL 编码。
下面是我在项目里一直沿用的签名实现:
using System; using System.Security.Cryptography; using System.Text; using System.Web; public static class DingTalkSigner { public static string ComputeSign(string secret, long timestamp) { // 签名串:时间戳 + 换行符 + 密钥 string stringToSign = timestamp + "\n" + secret; byte[] keyBytes = Encoding.UTF8.GetBytes(secret); byte[] dataBytes = Encoding.UTF8.GetBytes(stringToSign); using (var hmac = new HMACSHA256(keyBytes)) { byte[] hashBytes = hmac.ComputeHash(dataBytes); string base64Sign = Convert.ToBase64String(hashBytes); // 钉钉要求 URL 编码,C# 的 UrlEncode 会把空格转成 +,需要手动替换成 %20 return HttpUtility.UrlEncode(base64Sign, Encoding.UTF8) .Replace("+", "%20"); } } }这里有两个细节值得单独说明。第一,HMACSHA256 的密钥是 secret,而不是 stringToSign,别把两个参数弄反了,弄反的结果是每次签名都不对。第二,Base64 后的字符串里可能出现+,而标准 URL 编码下+会被服务端解析成空格,导致验签失败。这是很多初接者到处找原因却找不到的坑,症状就是同样的时间戳在离线工具里验算没问题,发到钉钉却一直报签名不匹配。
调用端需要同时生成时间戳:
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); string sign = DingTalkSigner.ComputeSign(secret, timestamp); var url = $"https://oapi.dingtalk.com/robot/send?access_token={token}×tamp={timestamp}&sign={sign}";参数说明:token 是自定义机器人 Webhook 里的 access_token;secret 是创建机器人时生成的那串密钥字符串。时间戳必须取服务器当前毫秒数,不能用缓存值,因为钉钉服务端会校验时间偏差,超过一分钟就拒绝。
3.2 发送封装:HttpClient、超时与错误解析
发送消息这一步可以用最普通的 HttpClient 完成,但有两个习惯必须养成:给请求设超时,以及正确读取并解析错误结果。钉钉接口的响应是 JSON,无论成功失败都包含errcode和errmsg字段,成功时errcode为 0。
using System; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading.Tasks; public class DingTalkClient { private readonly HttpClient _http; private readonly string _webhookUrl; public DingTalkClient(string webhookUrl) { _webhookUrl = webhookUrl; _http = new HttpClient(); _http.Timeout = TimeSpan.FromSeconds(5); } public async Task<(bool ok, string message)> SendTextAsync(string content) { var payload = new { msgtype = "text", text = new { content } }; string json = JsonSerializer.Serialize(payload); using var request = new HttpRequestMessage(HttpMethod.Post, _webhookUrl); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); try { HttpResponseMessage response = await _http.SendAsync(request); string result = await response.Content.ReadAsStringAsync(); using JsonDocument doc = JsonDocument.Parse(result); int errcode = doc.RootElement.GetProperty("errcode").GetInt32(); string errmsg = doc.RootElement.GetProperty("errmsg").GetString(); return (errcode == 0, errmsg); } catch (TaskCanceledException) { return (false, "request timeout"); } catch (HttpRequestException ex) { return (false, ex.Message); } } }提示:这段代码里的 errcode 和 errmsg 一定要打印到日志里。钉钉的错误信息写得还算直白,比如关键字不匹配、签名不对、频率受限都能从 errmsg 里直接看到关键词,排错时比靠猜快得多。
超时设 5 秒是基于一个经验:钉钉接口正常情况下 1 秒内就有响应,超过 5 秒基本是网络链路或者代理的问题,再等下去意义不大。另外,HttpClient 不要每次发送都 new 一个,高并发场景下 socket 会耗尽,建议注册成单例,或者直接上IHttpClientFactory。上面这段为了演示简单直接 new,生产环境换成工厂模式即可。
3.3 消息体参数:text、markdown、link 的 JSON 结构
钉钉机器人支持多种消息类型,最常用的是 text、markdown、link 三种。三种结构差异不大,但字段名有细微区别,写错字段会引起消息被拒。我把常用的结构列在这里:
// text 文本消息 { "msgtype": "text", "text": { "content": "告警:订单服务 CPU 使用率超过 90%" } } // markdown 消息 { "msgtype": "markdown", "markdown": { "title": "订单服务告警", "text": "### 故障描述\n- 服务:订单服务\n- 指标:CPU 使用率 92%\n- 时间:2025-01-01 10:00:00" }, "at": { "atMobiles": ["13800138000"], "isAtAll": false } } // link 跳转消息 { "msgtype": "link", "link": { "text": "点击查看详细监控面板", "title": "订单服务异常", "picUrl": "", "messageUrl": "https://monitor.example.com/detail/order" } }参数说明:text 类型只有 content 一个业务字段,也是最不容易出错的类型;markdown 类型的 top 级必须有msgtype=markdown,markdown 对象里必须有 title 和 text,title 是聊天列表里显示的标题,text 是内容体,支持基础 Markdown 语法;link 类型的 messageUrl 是点击后跳转的地址,picUrl 可以留空,但如果传了必须是可访问的图片地址。
@ 指定人这个动作嵌套在 at 字段里:atMobiles是手机号数组,钉钉会按手机号找人;isAtAll=true表示 @ 所有人。注意atMobiles只在群聊天场景生效,如果机器人被配置成单聊模式,这个字段会被忽略,别指望它能在单聊里拉起指定人。
4. 避坑排查:消息发不出去时的四个真实场景
4.1 关键字不匹配:返回成功但群里没消息
现象:HTTP 返回 200,响应 JSON 里errcode是 310000,errmsg里带有类似 keyword 的提示,消息没有出现在群里。
原因:创建自定义机器人时选择了关键字安全设置,而你发送的消息正文里不包含已配置的关键字。这是最容易被误判的一次失败,因为网络层完全正常,甚至能打印出返回体,但消息确实被安全策略拦下了。
解决:两种方案。一种是在机器人后台把关键字改成消息里稳定出现的词,对监控告警来说,选“告警”或“监控”这类业务强相关词;另一种是把关键字模式关掉,改用加签模式。我一般建议保留关键字,作为最后一道过滤,同时把告警文案里的固定词作为默认填充,这样两者不冲突。
4.2 签名不匹配:服务器时间差一分钟就报错
现象:签名代码实现看似完全正确,同一个签名串放到在线工具里能算出结果,但钉钉一直返回sign not match之类的错误。
原因:最常见的是服务器本地时间不准。钉钉服务端会对比请求里的 timestamp 和自身时间,偏差超过一分钟直接拒绝。另一类是前面提到的 Base64 字符串里的+没有正确转成%20,离线工具算出来的是原始 Base64,你发出去的是未编码的版本,服务端解码后对不上。
解决:先检查服务器时间同步,ntpdate或 Windows 自带的 w32tm 强制同步一次后再试;确认 URL 里的 sign 参数值里没有裸的+。如果用的是我前面那段代码,把.Replace("+", "%20")去掉后能复现失败,加上后能成功,就说明问题出在这里。
4.3 内网代理与 TLS 版本:PostAsync 超时的隐藏原因
现象:请求发出后要么长时间无响应直到超时,要么抛出AuthenticationException,日志里看不到任何来自钉钉的响应。
原因:很多人忽略了两点。第一,钉钉接口强制要求 HTTPS,服务端会校验证书,如果你的出口网络经过一台只支持 TLS 1.0 的代理,或者说客户端默认加密协议协商失败,连接根本到不了钉钉服务器;第二,内网环境要访问公网必须走代理,而代代码里没有配置代理地址,HttpClient 默认用系统的代理设置,某些环境里系统代理未生效,请求就卡住了。
解决:在 HttpClientHandler 里明确 Proxy 配置,并设置SslProtocols。生产环境我会写一个专门的 Handler:
var handler = new HttpClientHandler { Proxy = new WebProxy("http://proxy.internal:8080"), UseProxy = true, SslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 }; var client = new HttpClient(handler);注意:代理地址要按你团队实际的出口代理填写,这段代码里的地址是占位形式。另外,如果没有代理,
UseProxy保持 false,避免 HttpClient 去读系统代理导致启动变慢。
4.4 频率限制:一分钟 20 条,超了直接丢
现象:快速连续发送时,前面的消息能到群,后面的消息开始报错,错误信息里有速率或频率相关的描述;有时候不报错但消息就是没出现。
原因:钉钉对自定义机器人做了频控限制,单机器人每分钟最多 20 条消息。监控系统告警风暴的时候最容易触发:一条链路故障瞬间产生几十条告警,直接撞上限流。
解决:发送端做本地缓冲和聚合。把一分钟内的同类告警合并成一条总览消息:先等 5 秒再发,或者按固定间隔发送。也可以对不同的告警源用不同的机器人分发,从源头摊薄单点的发送频率。这块没有银弹,必须在告警源侧做降噪,不然频控永远会是一个间歇性出现的玄学问题。
5. 进阶玩法:Markdown 模板、@指定人与故障自愈
告警推送真正发生价值的地方,不是把文本发出去,而是让收到消息的人能快速判断严重性、知道该找谁。我通常会把 text 消息全部换成 Markdown 模板,在模板里固定头部、指标区、处理建议和责任人,这样每次告警的阅读成本会低很多。模板不复杂,关键是把变量位留对,比如服务名、指标值、阈值、触发时间这四类信息放在固定位置,新增监控项时只需要替换模板变量。
另一个技巧是 @ 指定人加去重缓存。同一条故障在恢复前会反复触发,每一分钟推一次,群里的效果就是刷屏加麻木。做法是在发送器外层套一层内存缓存,以服务名加故障类型拼成 key,记录最近 15 分钟内是否发送过相同内容,只有第一次触发时发送,后续恢复前每隔 5 分钟再补发一次,直到故障恢复消息出现。这里需要注意的是,去重缓存是进程内的,多实例部署时每个实例各自判断,可能还是会有重复,需要的话可以改成 Redis 计数。
至于重试,我踩过一个比较深刻的坑:不加重试的发送器在遇上暂时性网络抖动时,告警直接丢了,而监控指标图上是能看出问题的。从那以后我每次对接钉钉推送,都强制在发送器上走一遍“超时 5 秒、失败指数退避重试 3 次”的固定套路,第一次失败后等 500 毫秒,第二次等 1 秒,第三次等 2 秒。重试逻辑不复杂,但能挡住大部分偶发性网络问题。最后再说一句:接入钉钉消息推送,真正让你省心的是把关键字、签名、超时、频控这几个边界条件都当成一等公民对待,希望帮到你。
本文还有配套的精品资源,点击获取