☰
C#钉钉机器人消息推送实战:签名、消息构造与避坑指南
2026/10/20 1:24:01 网站建设 项目流程

简介:这是一份面向C#开发者的钉钉消息推送功能源码示例,专门解决企业应用中接入钉钉机器人或应用消息通知的凭证获取与用户身份匹配问题。资源基于钉钉开放平台开发者应用,演示如何通过AgentId、AppKey、AppSecret获取access_token,并调用GetUser接口根据手机号获取userid,适用于需要在内部系统、OA或管理后台中集成钉钉消息通知的团队。资源包共11个文件,核心为3个C#源文件,包含主要推送逻辑;另有编译所需的两个DLL(TopSdk.dll与Newtonsoft.Json.dll)及配置说明XML,同时附带Visual Studio解决方案文件、项目文件与缓存文件,下载后可直接打开工程进行调试,整体仅907KB,结构紧凑。已有1700余人学习该资源,适合初中级C#开发者快速理解钉钉开放接口调用流程,并复制到实际项目中二次开发。

1. 这个项目是干什么的:一个 C# 实现的钉钉群机器人客户端

凌晨两点,某内部系统的批处理任务挂了,没人知道;生产环境磁盘冲到 95%,告警邮件躺在没人读的邮箱里。最省事的解决办法,就是往钉钉工作群推一条消息,让所有人掏出手机就能看到。这个 C# 钉钉消息推送项目,把“往钉钉群机器人发消息”做成了一套可直接用的源码:只要配置 Webhook 地址和密钥,几行代码就能把文本、Markdown、卡片消息推到指定群,还能 @ 成员、带跳转链接。它适合三类人:想给内部系统加告警通道的运维开发、需要把构建结果同步到工作群的业务开发,以及准备在自己的 C# 项目里接入钉钉能力的同学。这个项目把签名算法、消息构造、异常处理、单元测试一次补齐,拿来就能跑,也能拆出来并进自己的解决方案。

2. 项目骨架与技术选型:三个工程文件把职责拆清楚

2.1 解压后看到的目录结构

项目采用了我自己比较习惯的三层拆分方式,打开解决方案后看到的是这样一组工程:

DingTalkPush.sln ├─ DingTalkPush.Core // 核心类库:签名、消息模型、发送器 │ ├─ DingTalkRobot.cs │ ├─ Signer.cs │ ├─ Models/ │ │ ├─ DingTalkResponse.cs │ │ ├─ TextMessage.cs │ │ ├─ MarkdownMessage.cs │ │ └─ ActionCardMessage.cs │ └─ Exceptions/ │ └─ DingTalkApiException.cs ├─ DingTalkPush.App // 控制台演示程序:读配置、发消息 │ ├─ Program.cs │ └─ appsettings.json └─ DingTalkPush.Tests // 单元测试:签名向量、消息 JSON 结构 ├─ SignerTests.cs └─ MessageSerializationTests.cs

Core 不引用任何界面层,App 只负责从配置文件读取参数然后调用 Core,Tests 只关注 Core 的行为。这么拆的原因是:告警推送这类功能,最终大概率要被嵌进已有的后台服务、Windows 服务或者 Web API 里,而不是永远跑在控制台程序中。把发送逻辑和入口程序切开,将来别的项目引用 Core 编译出来的程序集就能用,不用把整个演示程序一起带过去。

依赖方面,我用的目标框架是 .NET 6,HTTP 请求走 HttpClient,JSON 序列化优先用 System.Text.Json。代码里构造消息体时使用的是对象,而不是预先把 JSON 写死成字符串,这样做既兼容了项目里可能存在的 Newtonsoft.Json,也保留了 System.Text.Json 的轻量特性。如果你还在维护 .NET Framework 4.x,把 Core 的目标框架改回去,HttpClient 换成 HttpWebRequest,或者引入 System.Net.Http 包,改动的范围集中在发送器内部,不会影响消息模型和签名逻辑。

2.2 核心类的职责边界

这个项目真正核心的类只有四个,我把它们各自的职责写在类注释里,比任何 README 都管用。

DingTalkRobot 是整个库的门面,外部代码基本只和它打交道。它接收 Webhook 地址和加签密钥,对外暴露 SendTextAsync、SendMarkdownAsync、SendActionCardAsync 三个方法,内部再去拼 URL、算签名、发请求。调用方不需要关心签名是怎么算的,也不需要理解钉钉接口的响应结构。

Signer 是纯静态类,只做一件事:根据毫秒时间戳和 secret 计算 HMAC-SHA256 签名,并完成 URL 编码。它不感知任何业务消息,所以可以单独被单元测试覆盖,这也是我后面做固定向量测试的基础。

Models 目录下是一组消息类型定义,对应钉钉自定义机器人接口里的 text、markdown、actionCard 等消息格式。每一类消息在序列化成 JSON 时都严格贴合接口要求的字段名,比如 msgtype、text.content、markdown.text、at.atMobiles。

DingTalkApiException 是自定义异常,用来包装钉钉返回的 errcode 和 errmsg。上层代码只需要捕获这一个异常,就能判断推送是否成功,而不是去解析一段晦涩的响应字符串。

为什么消息体不用字典而是用类?我在开发中发现,钉钉这种接口嵌套层级多、可空字段也多,用字典写起来确实快,但后面换一个消息类型就要重构一串字符串 Key,编译器完全帮不上忙。定义成类,字段拼错编译期就报错,而且新增消息类型只是新增一个文件,不影响已有调用方。

2.3 与常见示例相比,这个项目多做了哪几件事

网上很多 C# 推送钉钉的示例都是“一个函数解决战斗”:拿 HttpWebRequest 拼一段 JSON 发出去,然后就不管返回值了。这个项目把这类示例经常忽略的三件事补齐了。

第一,HTTP 状态码不等于业务成功。钉钉接口在业务失败的时候也经常返回 HTTP 200,真正的结果在响应体的 errcode 字段里。我在发送器里每次都会反序列化 DingTalkResponse,只有 errcode 为 0 才视为成功,否则抛异常。

第二,加签后的 URL 编码。官方文档示例里 sign 是 Base64 字符串,拼接到 Query 上时必须做 URL 编码,否则 Base64 里的加号在 URL 中会被解析成空格,服务端算出来的签名跟你本地完全对不上。Signer 里显式调用了 Uri.EscapeDataString,这一行就是很多踩坑帖的根源。

第三,发送超时和重试的边界。我在发送器里设置了合理的 HttpClient 超时时间,并预留了重试入口,但重试绝对不是无条件做——同一告警重复推送会刷屏,这一块在第 5 章避坑里会详细说。

3. 核心模块拆解:签名、消息构造、发送的完整链路

3.1 加签逻辑:HMAC-SHA256 和时间戳的细节

钉钉自定义机器人的加签安全设置,要求在请求 URL 上带两个额外参数:timestamp 和 sign。timestamp 是当前毫秒时间戳,sign 是把“timestamp + 换行符 + secret”用 HMAC-SHA256 计算后做 Base64 的结果。核心代码就这几行:

public static class Signer { public static string ComputeSign(long timestamp, string secret) { string stringToSign = timestamp + "\n" + secret; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); byte[] hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign)); return Convert.ToBase64String(hashBytes); } public static string BuildSignedUrl(string webhook, string secret) { long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); string sign = ComputeSign(timestamp, secret); string separator = webhook.Contains("?") ? "&" : "?"; return $"{webhook}{separator}timestamp={timestamp}&sign={Uri.EscapeDataString(sign)}"; } }

逻辑说明:第一步把时间戳和 secret 用换行符拼成待签字符串,这里必须用字符串类型的换行符 “\n”,而不是运行时环境相关的换行常量,否则在 Windows 和 Linux 下会算出不同的签名。第二步用 secret 作为 HMAC 算法的密钥,注意这里不是把 secret 拼进待签内容,而是当作密钥传入,两件事一旦混了签名就废了。第三步做 Base64 转码,最后通过 Uri.EscapeDataString 把 Base64 结果里的加号、等号、斜杠转成 URL 安全形式。

参数说明:secret 是创建机器人时得到的加签密钥,形如一串随机字符;timestamp 单位是毫秒,不是秒,我用 DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() 取值。钉钉服务端会校验时间窗口,本地时间与服务器时间误差超过一小时就会拒绝请求,这一点我放在避坑章节细说。

3.2 三类消息体构造:文本、Markdown、卡片

在动手发消息之前,先弄清楚三类消息分别该怎么选。我把它们整理成一个对照关系:

消息类型适用场景关键字段
text简短提醒、单行状态text.content
markdown结构化告警、构建结果、报表markdown.title、markdown.text
actionCard需要用户点击处理的告警actionCard.title、actionCard.btns

文本消息是最低成本的告警载体,结构最简单:

var payload = new { msgtype = "text", text = new { content = "磁盘使用率超过 90%,请检查" }, at = new { atMobiles = new[] { "13800000000" }, isAtAll = false } };

逻辑说明:字段名必须保持小写,System.Text.Json 的默认命名策略是原样输出,所以匿名对象直接用和小写字段同名的属性最省事。atMobiles 传手机号数组,isAtAll 控制是否 @ 所有人。如果你只希望指定的人看到,content 里最好同时带上对方的手机号或显示名,否则在部分客户端版本里,消息列表能看见但会话里被 @ 的人显示不出来,容易误以为功能有 bug。

Markdown 消息适合结构化告警,比如构建失败、发布结果:

var payload = new { msgtype = "markdown", markdown = new { title = "构建失败告警", text = "## 构建结果 \n- 项目:某跨平台系统 \n- 分支:release \n- 状态:<font color=\"red\">失败</font> \n[查看构建日志](https://ci.example.com/build/123)" }, at = new { atMobiles = new[] { "13800000000" }, isAtAll = false } };

逻辑说明:title 是钉钉会话列表里显示的消息摘要,text 是完整 Markdown 正文,两个字段都必填。文本里我用两个空格加换行实现 Markdown 换行,因为钉钉客户端对单个换行的渲染不太稳定,两个空格是兼容性最好的写法。

actionCard 消息适合带操作按钮的场景,例如“查看日志”“去处理”:

var payload = new { msgtype = "actionCard", actionCard = new { title = "线上事故", text = "订单接口超时率持续上升,请尽快处理", btnOrientation = "1", btns = new[] { new { title = "查看监控", actionURL = "https://monitor.example.com/orders" }, new { title = "进入聊聊", actionURL = "dingtalk://dingtalkclient/action/sendmsg?dingtalk_id=xxx" } } } };

逻辑说明:btnOrientation 为 “1” 表示按钮竖向排列,“0” 是横向。actionURL 支持普通 HTTPS 链接,也可以写钉钉客户端协议地址,后者能直接拉起某个群聊或联系人。实际使用中我一般把“查看详情”放在第一位,保证用户一眼看到主要操作。

3.3 发送与响应处理:把 errcode 当作第一判断标准

发送逻辑统一走 HttpClient,核心代码如下:

public async Task<DingTalkResponse> SendAsync(object payload, CancellationToken ct = default) { string url = _signedUrlProvider.GetSignedUrl(); using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); using var response = await _httpClient.SendAsync(request, ct); string body = await response.Content.ReadAsStringAsync(ct); var result = JsonSerializer.Deserialize<DingTalkResponse>(body) ?? throw new DingTalkApiException("响应为空"); if (!response.IsSuccessStatusCode || result.Errcode != 0) throw new DingTalkApiException(result.Errcode, result.Errmsg); return result; }

逻辑说明:这里把 HTTP 状态码和业务错误码分开判断,两个条件任何一个不满足都抛异常。我在一线见过不少线上事故,日志里明明显示 HTTP 200,群里却没消息,原因就是 errcode 非 0 的失败被调用方忽略。DingTalkResponse 只需要三个字段:errcode、errmsg、messageId,messageId 在后端排查时非常有价值。

参数说明:SendAsync 接收的 payload 可以是任何序列化后符合钉钉接口结构的类型,这意味着后面扩展新的消息类型,不需要修改发送器本身。HttpClient 我在上层用单例或者 IHttpClientFactory 管理,避免频繁创建导致 socket 耗尽。重试逻辑我放在发送器外面,由调用方根据业务场景决定,因为同一告警的无脑重试很快就能刷爆一个群。

4. 把项目跑起来:从建群机器人到收到第一条消息

4.1 在钉钉群里创建自定义机器人

打开钉钉 PC 端,进入目标群,点击群设置里的“智能群助手”,选择“添加机器人”,找到“自定义”机器人并添加。安全设置这一步,我建议直接选“加签”。添加完成后你会得到一个 Webhook 地址和密钥,地址形如:

https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxx

注意:这个 Webhook 地址和 secret 只在添加成功时完整展示一次,关掉窗口再进去就看不到 secret 了,务必先复制保存。我通常把这一对密钥放进专门的文件里,不提交到 Git 仓库。

4.2 修改配置并运行演示程序

appsettings.json 的配置结构如下:

{ "DingTalk": { "Webhook": "https://oapi.dingtalk.com/robot/send?access_token=你的token", "Secret": "你的加签密钥", "MessageType": "markdown", "AtMobiles": [ "13800000000" ] } }

配置项说明:

配置项含义填写建议
Webhook机器人完整的请求地址原样复制,不要截断 access_token
Secret加签用的密钥与 Webhook 一一对应,别混用
MessageType演示程序发送的消息类型支持 text、markdown、actionCard
AtMobiles需要 @ 的手机号列表可留空,留空表示不 @

填写真实配置后执行:

dotnet run --project DingTalkPush.App

如果你用的是 Visual Studio,直接把 DingTalkPush.App 设为启动项目,按 F5 运行即可。程序启动后会在控制台打印本次推送的响应内容,看到 errcode 为 0 后,回到钉钉群里看一眼,应该已经有一条 Markdown 消息躺在里面了。

4.3 如何确认不是“碰巧成功”

想确认这条链路真正稳定可复现,我会故意做两个负面测试。第一个:把 Secret 改错一位再运行,预期收到签名不匹配的报错;第二个:把 Webhook 里的 access_token 改成乱码,预期收到 token 无效的错误。两个测试都报预期错误,再把配置改回来跑通,说明你的代码和配置都在真实地工作,而不是消息被某个隐藏通道悄悄投递成功。

如果你在运行阶段就遇到问题,不要急着改代码。先看控制台打印的 errcode,绝大多数配置问题都可以在钉钉官方错误码表里直接查到,比如签名错误、token 不存在、消息内容不合法。日志里我特意把请求 URL 中的 timestamp 打了出来,方便和本地时间做对比。

5. 避坑记录:签名不匹配、发了没反应、报警刷屏的根因

5.1 “签名不匹配”先查这三处

现象:调用接口返回 errcode 310000,错误信息里带“签名不匹配”。按顺序排查三处。

第一,确认计算签名时用的字符串是“时间戳 + 换行 + secret”的顺序,有人会下意识写成“secret + 换行 + 时间戳”,顺序反了必失败。第二,确认拼接 URL 时 sign 有没有做 URL 编码。Base64 里的加号在 URL 中会被解析成空格,服务端解码后跟你本地算出的签名完全对不上。第三,确认 timestamp 用的是毫秒,而且本地时间与钉钉服务器时间偏差不能超过一小时,服务器时间慢了两分钟同样会被拒绝。

解决:先在本地用一个固定 timestamp 和 secret 算一遍签名,再拿同样一组值构造完整 URL 发起调用,这样能区分到底是算法问题还是时间问题。项目里的 SignerTests 就是干这件事的,跑一遍固定向量,对不上必然是代码被改坏了。

5.2 HTTP 200 但群里没消息:安全设置是叠加关系

现象:请求返回 errcode 0,群聊里却毫无动静。这个坑我见过很多次,典型场景是机器人同时开了“加签”和“关键字”两项安全设置,调用方只在 URL 里带了签名,消息内容却不包含任何关键字,钉钉就把这条消息静默丢弃了。

原因:自定义机器人的安全设置不是三选一,而是所有已开启的规则都要满足,任何一个不通过消息都进不了群。

解决:回到智能群助手的机器人设置里,确认为这个机器人开了哪些安全设置。如果要求的关键字是“告警”,那么消息文本里必须真实出现“告警”两个字。另外,如果开了 IP 白名单,要确认发出请求的服务公网出口 IP 在白名单里,很多开发环境的出口 IP 是浮动的,换一个网络就出问题。还有一点容易混淆:atMobiles 的 @ 功能与安全设置是两条独立逻辑,开了关键字但正文里没有关键字,即使 @ 了人也一样不推送。

5.3 告警风暴是怎么刷爆群的

现象:网络抖动一次,重试机制触发三次,三条一模一样的告警同时出现在群里;再赶上批量任务并发,一个晚上群里上百条消息,最后大家直接屏蔽这个群,告警通道彻底失效。

原因:重试策略太激进,把“推送失败重试”和“任务失败重试”混为一谈;或者上游并发任务量大,每一条都独立推送,没有做任何聚合。

解决:我在发送器外层加了一个轻量去重,同一告警消息的 DedupKey 在 60 秒窗口内只发送一次;告警文本相同时合并成一条,在内容里附上触发次数。压测的时候我还踩到过机器人每分钟 20 条消息的频率上限,所以批量推送前必须先聚合,而不是用 for 循环裸发。

5.4 服务器时间偏差的隐蔽问题

现象:签名算法、密钥、URL 编码全都核对无误,但每天总有几次推送失败,日志时间集中在凌晨两点到四点之间,白天正常。

原因:我遇到过一台内网服务器没有配置时间同步,系统时钟慢慢漂移,和钉钉服务器时间差一旦超过一小时,加签就会被拒绝。因为签名错误和网络错误混在一起,这种问题排查起来特别绕。

解决:给服务器配置 NTP 定期同步,同时把每次请求的 timestamp 写进日志。发现失败时刻的时间戳比本地时间慢了一小时以上,基本就能锁定是时钟漂移。另外在单元测试里固定 DateTimeOffset.UtcNow 的返回值,避免测试结果跟着机器时间跑,Signer 设计成纯静态方法,就是为了方便测试替身注入固定时间。

6. 二次开发:把单次推送改成模板化告警通道

6.1 新增一种消息类型,改动控制在 30 行以内

先别急着上策略模式,钉钉的 link、feedCard 消息本质上都是一个匿名对象就能解决的问题。最直接的做法是在 Core 里加一个静态消息工厂,每种类型对应一个方法,调用方想换格式就换方法,不用理解钉钉接口内部结构。例如新增 link 类型:

public static object BuildLink(string title, string text, string messageUrl, string picUrl = null) { return new { msgtype = "link", link = new { title, text, messageUrl, picUrl } }; }

这个函数不需要改动 DingTalkRobot 的发送逻辑,直接把它返回的对象传给 SendAsync 即可。我建议消息体构造全部走这类静态工厂,避免调用方到处拼匿名对象,将来钉钉接口字段发生变化时只改一处。

6.2 封装成后台服务,推送不再依赖人工触发

一个自然的演进方向,是把发送能力封装成后台队列消费者。业务模块只负责投递告警对象,后台进程统一负责签名、限流、重试与去重,批量告警被平滑地发出去。简化版的消费者核心代码如下:

while (await channel.Reader.WaitToReadAsync(ct)) { var alert = await channel.Reader.ReadAsync(ct); if (_deduplicator.ShouldSend(alert.DedupKey)) { try { await _robot.SendMarkdownAsync(alert.Title, alert.Text, alert.AtMobiles, ct); } catch (DingTalkApiException ex) { _logger.LogError(ex, "钉钉推送失败 alertId:{AlertId}", alert.AlertId); } } }

逻辑说明:队列的写端是各业务模块,读端每收到一条就尝试发送。ShouldSend 基于 DedupKey 做时间窗口去重,异常被记录进日志而不是让后台服务崩溃。这样改造后,上游即使一次性投递一百条告警,队列消费者也能按自己的节奏发送,不会把钉钉群瞬间刷爆。

6.3 改造完怎么验证不炸群

我每一次改完代码,都会在测试群里用同一套配置跑一遍全量消息类型,确认签名、消息格式、@ 功能都正常。然后再看一次程序日志里的 errcode,和钉钉后台的消息记录对一遍。最后做一次故障演练,把某条消息的 Webhook 故意改错,确认异常捕获链路是通的。

这个项目最麻烦的地方其实是“没有反馈”——发送成功了群里不一定有人注意,失败了也常常悄无声息。所以我把所有推送结果都做成结构化日志,任何一步失败都能在日志里找到对应的 errcode。从那以后我每次改完都先在测试群跑一遍完整流程,再动线上群,已经成了肌肉记忆,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询