☰
企业微信机器人定时发送消息:Webhook+APScheduler实现全解析
2026/10/23 3:53:25 网站建设 项目流程

说实话,第一次接触“企业微信机器人定时发送信息”这个需求时,我第一反应是:这不就是调个接口、扔到 crontab 里吗?后来真到自己做才发现,里面藏着不少细节——机器人怎么建、消息用什么格式、加签怎么算、定时任务为什么偶尔不执行、@人为什么失效,每一个坑都够新人折腾半天。这篇就把我实际跑通的方案完整拆开讲,从零开始,一直聊到部署上线和排查问题,希望能帮你少走几步弯路。

这个方案适合谁?如果你的团队每天要靠人工往群里发日报、周报、值班提醒、监控告警,或者你想把某个系统的通知自动推到企业微信群,这篇的内容可以直接抄作业。不需要申请企业微信应用管理员权限,不需要写前端页面,一个群机器人加一段 Python 脚本就能跑起来。

1. 从需求到方案:为什么选了“群机器人 + Webhook”路线

1.1 这个功能到底能解决什么问题

很多人对消息机器人的理解停留在“定时发一句话”,其实它真正解决的问题是:把低价值的重复劳动自动化,同时把“人可能遗忘”的风险降为零。

我见过最典型的场景有两个。一个是运维值班提醒:每周一到周五早上九点半,需要有人在值班群里同步当天有没有发布窗口、有没有已知故障。以前这事靠值班同学手动发,有人出差忘了,群里就一片安静,最后还得挨个问,非常尴尬。另一个是业务数据播报:每天早上把前一天的订单量、转化率、异常单数汇总成几条要点推到管理群。数据可以从数据库查,但汇总和排版如果靠人工,每天至少花十五分钟。

这两类场景有个共同点:内容高度模板化,只是数据在变。所以只要写一个脚本去做“取数 + 拼消息 + 调接口”这三件事,再配上定时触发,一个能稳定干活的机器人就诞生了。企业微信群里收到消息的人不会关心它是谁发的,只觉得“这个小助手还挺准时”。

1.2 常见实现方案的横向对比

做定时发消息,市面上不只有群机器人一条路,我把常见的几条路线都列出来对比过,各有取舍。

方案实现难度维护成本适用场景
企业微信群机器人(Webhook)低,不需要审批权限低,直接在群里管理往固定群发通知、日报、告警,最推荐
企业微信自建应用高,需要配置应用、获取access_token、管理通讯录权限中高,涉及企业级权限需要定向发给指定成员、需要读取成员信息、需要更复杂的交互
第三方推送平台低,但需要额外服务中,依赖外部平台且要花钱不太推荐,数据要过第三方,链路更长
邮件/短信通知低低,但企业微信群内阅读体验差仅作为补充通道

我自己最终选了群机器人 Webhook。原因很简单:需求的核心是“把消息发到一个固定的企业微信群里”,Webhook 天生就是干这个的。它不需要像自建应用那样去获取 access_token、处理回调、配置可见范围,只需要一个 URL,POST 一段 JSON 就能完成发送。从“写代码”到“看到消息出现在群里”,最快可能只需要十分钟。

另外它对个人开发者也很友好:不需要有企业管理员后台权限,只要你是某个企业微信群成员,就能在群设置里创建一个机器人。这意味着很多非研发岗位的同学也能自己动手做一个小工具,不用在 IT 流程里等审批。

1.3 方案选型时要提前想清楚的边界

选 Webhook 机器人虽然省事,但它也有两个边界你最好提前知道。

第一,它只能主动往群里推消息,不能接收群里的消息来做智能回复。如果要做“群内自动应答”那种双向交互,就必须走企业微信自建应用 + 接收消息回调的路子,复杂度会上升一个量级。第二,Webhook 机器人的权限很粗,它没有“只发给张三不发给李四”这种精细控制。虽然消息里可以 @ 指定人,但本质上是全员可见,只是提示特定成员看。这和数据权限不是一回事,敏感信息慎发。

如果你评估后发现这两条都不影响核心需求,那 Webhook 机器人就是最优解。接下来所有内容都围绕这条路展开。

2. 准备工作:创建机器人、认识消息格式与安全限制

2.1 三分钟在企业微信群里添加一个机器人

创建群机器人不需要写代码,纯界面操作。进入目标企业微信群,点击右上角的“...”菜单,找到“群机器人”,再点“添加”,这时会看到机器人的头像和名字,你可以给它起个有辨识度的名字,比如“日报小助手”“告警机器人”。创建完成后,页面里会出现一个 Webhook 地址,格式大概长这样:

https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的唯一标识

这个地址一定要保存好,它相当于机器人的钥匙。任何人拿到这个 URL 都能往这个群里发消息,所以在使用过程中要把它当密码一样对待,不要提交到公开的代码仓库里。

添加完成后,系统会要求选择安全设置,这里有三种选项:不设置、加签、IP 白名单。我的建议是:本地测试可以先用不加签的方式快速跑通流程,一旦要把脚本部署到服务器上,立刻加上签名校验或者 IP 白名单,不然总感觉把家门钥匙挂在门口。

2.2 消息格式与频率限制:别等发送失败才回头查

拿到 Webhook 地址后,最容易踩的第一个坑就是不理解消息格式。企业微信机器人支持的消息类型有文本(text)、Markdown(markdown)、图片(image)、图文(news)和模板卡片(template_card)。日常用得最多的是前两种。

文本消息的 JSON 结构很简单,就是一个 POST 请求,把消息内容放在 content 字段里:

{ "msgtype": "text", "text": { "content": "这是一条测试消息" } }

Markdown 消息稍微灵活一点,可以加标题、加粗、引用和链接,适合做有点样式的日报或者告警推送:

{ "msgtype": "markdown", "markdown": { "content": "## 线上告警 \n > 支付接口成功率低于99% \n **请值班同学关注**" } }

这里要特别提醒:企业微信的 Markdown 不是完整版 Markdown,只支持标题、加粗、引用、字体颜色、链接和列表,表格、图片嵌入、代码块这类高级语法是不支持的。我第一次按 GitHub 上的 Markdown 习惯写了表格,发出来直接变成纯文本,完全没渲染。后来学乖了,所有消息模板都只用它官方文档里列出的那几种语法,稳得很。

频率限制方面,每个机器人每分钟最多发送 20 条消息,每条消息内容上限是 4096 字节。从这个限制能看到,它不是给你做海量群发的,而是做精准通知的。如果你的场景是“每分钟几十条告警”那种,就要在源头先做聚合,比如把十台机器的异常合并成一条消息,而不是一台机器发一条。

2.3 安全设置里的大坑:加签到底怎么算

创建机器人时如果选了“加签”模式,系统会给你一串 Secret。这个 Secret 不是直接放到 URL 上就能用,而是要参与签名计算。大致逻辑是:取当前时间戳(秒级),拼上 Secret 和换行符,通过 HMAC-SHA256 算法得到签名,再把时间戳和签名拼到 Webhook 地址后面。

我实际用 Python 实现过这个逻辑,核心代码不长:

import hmac import hashlib import base64 import time def gen_sign(secret: str) -> tuple[str, str]: timestamp = str(round(time.time())) string_to_sign = f"{timestamp}\n{secret}" hmac_code = hmac.new( secret.encode("utf-8"), string_to_sign.encode("utf-8"), digestmod=hashlib.sha256 ).digest() sign = base64.b64encode(hmac_code).decode("utf-8") return timestamp, sign

调用的时候,把返回的 timestamp 和 sign 拼到原 Webhook 地址后面:

https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key&timestamp=1700000000&sign=计算出来的签名

这个方案我最开始觉得麻烦,为什么不能像某些平台一样直接塞一个 Token 进 Header?后来想明白了,企业微信这是想让请求具备“防重放”能力:签名里带了时间戳,服务端可以拒绝时间偏差过大的请求,就算 URL 被截获,攻击者也没法拿旧地址一直刷。理解了这个设计,再写加签代码就不觉得绕了。

开发时有个小技巧:如果你只想快速联调,可以先不加签,用“无签名”模式测试通,等脚本整体跑顺了,再去创建机器人那里改成加签模式,重新生成一个新的 Webhook 地址替换就行。签名逻辑单独封装成一个函数,后面接进来成本很低。

3. 核心实现:写一个真正可靠的定时发送机器人

3.1 最小可用的发送脚本:先把“发消息”这一环跑通

写任何自动化脚本,我都习惯先把最核心的一步用最简单的方式跑通,再做调度和部署。对机器人需求来说,最核心的一步就是“发出一条消息”。

下面是一个可以直接保存运行的 Python 脚本,依赖只有一个 requests:

import requests WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key" def send_text(content: str) -> dict: payload = { "msgtype": "text", "text": { "content": content } } resp = requests.post(WEBHOOK_URL, json=payload, timeout=10) result = resp.json() if result.get("errcode") != 0: raise RuntimeError(f"发送失败: {result}") return result if __name__ == "__main__": send_text("Hello,这是一条测试消息")

这里有一个我认为很重要的细节:发完请求一定要检查返回值。企业微信接口正常返回时 errcode 是 0,一旦你写错 key、消息超长、触发频率限制,服务端都会返回非 0 的 errcode。如果脚本不检查,requests 也不会报错,结果就是你以为发出去了,群里却什么都没有,排查的时候特别冤枉。

我自己在封装时习惯让发送函数在失败时直接抛异常,这样上层定时任务能感知到失败,配合日志和重试逻辑,整个链路才算是完整的。

3.2 定时调度:为什么我推荐 APScheduler 而不是 sleep 循环

消息发送跑通后,下一个问题是“怎么到点自动发”。不少人第一反应是写一个死循环加 time.sleep,比如每 60 秒检查一次时间,到点了就发。这个写法在脚本简单时够用,但有两个明显的毛病:一是 sleep 阻塞导致程序没法同时处理其他任务;二是如果程序中途崩了,没有任何机制让它自动恢复,下次到点它就“失约”了。

我更常用的方案是 APScheduler 这个 Python 定时任务库。它支持 cron 表达式,可以精确到“每周一到周五 9 点 30 分执行”,还内置了后台调度器,能跟发送逻辑优雅地共处一个进程。

示例代码如下,这段代码能直接跑起来:

from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger def job_daily_report(): content = "今日日报:完成需求 3 个,线上无故障。" send_text(content) scheduler = BackgroundScheduler(timezone="Asia/Shanghai") scheduler.add_job( job_daily_report, CronTrigger(hour=9, minute=30, day_of_week="mon-fri") ) scheduler.start() # 保持主进程存活 try: while True: import time time.sleep(60) except (KeyboardInterrupt, SystemExit): scheduler.shutdown()

这里有个特别容易忽略的点:timezone 参数。我第一次部署到一台海外服务器时,服务器默认时区是 UTC,而我想发的是北京时间 9 点半,结果任务每天都在北京时间下午 17 点半执行,连续错了三天才发现。所以要显式指定timezone="Asia/Shanghai",不要依赖服务器系统时区。如果你的服务器本来就设置成了北京时间,这个是白写的,但写上总归保险。

CronTrigger 里的参数也很灵活。比如:

  • 每天 8 点整:hour=8, minute=0
  • 每个工作日 18 点:day_of_week="mon-fri", hour=18, minute=0
  • 每月 1 号上午 10 点:day="1", hour=10, minute=0

3.3 在发送内容里嵌入动态数据:做一个像样的日报

固定文本发多了你会发现,机器人最大的价值不是“定时”,而是“定时 + 有料”。要让消息每天自动更新,就需要在发送前做一些数据拼接工作。

一个典型的日报脚本是这样组织的:

from datetime import date def get_summary_data() -> dict: # 这里连接数据库或调用内部接口,取昨日订单数、异常数等 return { "order_count": 12345, "error_count": 2, "oncall": "张三" } def build_report_content(data: dict) -> str: today = date.today().isoformat() return ( f"## {today} 运营日报\n" f"> 订单总量:{data['order_count']} 单\n" f"> 异常订单:{data['error_count']} 单\n" f"值班人:{data['oncall']}" ) def job_daily_report(): data = get_summary_data() content = build_report_content(data) send_text(content)

这种写法把“取数”“拼内容”“发消息”分成三个独立函数,好处是将来换数据源、改模板,各改各的,互不影响。

在拼内容时有个小毛病很常见:Python 的 f-string 里如果用大括号包了 JSON 样式的文本,会和 f-string 本身的语法冲突。我踩过一次后养成了习惯,如果模板比较复杂,就用 string.Template 或者 format 占位符,别硬薅 f-string。

3.4 把脚本从“前台运行”变成“后台守护进程”

脚本写到能定时发送后,下一步是部署。如果你把它放在电脑上直接python main.py跑,一关终端它就没了,这肯定不行。放到服务器上当成常驻进程管理才是正经做法。

在 Linux 服务器上,我一般用 systemd 来管理这类脚本,它自带开机自启和崩溃重启,比 nohup 那种野路子可靠得多。写一个 service 配置文件,大概是这个样子:

[Unit] Description=wecom robot sender After=network.target [Service] ExecStart=/usr/bin/python3 /opt/wecom-robot/main.py WorkingDirectory=/opt/wecom-robot Restart=always RestartSec=5 Environment=PYTHONUNBUFFERED=1 [Install] WantedBy=multi-user.target

把文件放到/etc/systemd/system/wecom-robot.service,然后执行:

sudo systemctl daemon-reload sudo systemctl enable wecom-robot sudo systemctl start wecom-robot

这里有三个细节值得解释一下。Restart=always表示进程异常退出后 5 秒自动拉起,对定时任务脚本来说很有用,因为脚本一旦挂掉,任务就永远不发了,此时最需要的就是“拉起来继续跑”。Environment=PYTHONUNBUFFERED=1是为了让 Python 的 print 输出不被缓冲,不然日志里看到的最后一条永远比实际进度滞后好几条。还有一个我没写进配置但强烈建议的:输出日志用journalctl -u wecom-robot -f查看,比写日志文件省心。

如果你的脚本没法长时间占着一台服务器,也可以考虑云函数加定时触发器的方式。相当于把“到点执行”交给云平台,脚本本身不需要常驻进程,成本更低,也更适合个人项目。不过云函数有执行时间限制,如果你的日报脚本要跑几分钟甚至更久,就得评估一下了。

4. 进阶玩法:让机器人更像“团队小助手”

4.1 精准提醒:@指定成员而不是全员刷屏

定时消息发到群里后,如果只是一条普通文本,群里人很容易刷过去没看到。想让特定同事必须看到,最好的方式是 @ 对方。

企业微信机器人的文本消息里,可以使用mentioned_list或mentioned_mobile_list两个字段。区别在于:前者传企业微信的 userid,后者直接传手机号。如果你不想调通讯录接口去查 userid,用手机号最省事,但这个手机号必须是成员在企业微信里绑定的手机号。

实际用法是在 text 对象里加字段:

{ "msgtype": "text", "text": { "content": "值班提醒:今天轮到你处理线上问题", "mentioned_mobile_list": ["13800000000"] } }

对应的 Python 代码:

def send_text(content: str, mentioned_mobiles: list[str] | None = None): payload = { "msgtype": "text", "text": { "content": content } } if mentioned_mobiles: payload["text"]["mentioned_mobile_list"] = mentioned_mobiles # 发送逻辑同上

透露一个容易踩坑的点:如果你同时传了 mentioned_list 和 mentioned_mobile_list,有的版本会只生效其中一个。为了避免模棱两可,我统一用手机号方案,维护成本低,也不用去后台翻 userid。

4.2 不只是文字:Markdown、图片、图文卡片

定时发文字只是基础,很多通知场景对表现力有要求,比如发一张趋势截图、放一个链接卡片。企业微信机器人其实都支持。

Markdown 消息的发送方式和文本只差一个字段名,我常用的是带标题、引用、加粗和链接的组合:

def send_markdown(md_content: str): payload = { "msgtype": "markdown", "markdown": { "content": md_content } } # 发送逻辑同上

图片消息稍微讲究一点,它要求传图片内容的 base64 编码和 md5 值:

import base64 import hashlib def send_image(image_path: str): with open(image_path, "rb") as f: image_data = f.read() payload = { "msgtype": "image", "image": { "base64": base64.b64encode(image_data).decode("utf-8"), "md5": hashlib.md5(image_data).hexdigest() } } # 发送逻辑同上

图片大小限制在 2MB 以内,超过会报错。如果你要发的是截图,建议先压缩一下,不然脚本里多一步失败处理会显得特别啰嗦。

图文卡片(news)适合推送“带标题、带摘要、可以跳转链接”的内容,比如安全公告或者产品更新。它的结构是 msgtype 为 news,articles 里放一个或一组卡片。不过这类卡片在企业微信里的展示样式比较固定,算不上华丽,如果你追求更丰富的交互,可以研究一下 template_card,但那就是另一个话题了。

4.3 从“单机脚本”到“常驻服务”的工程化考虑

脚本真正跑起来后,我建议你把配置和代码分离开。Webhook 地址、加签密钥、收件人手机号这些都是“环境相关”的信息,不要硬编码在 .py 文件里。常用的做法是放到环境变量或者一个单独的配置文件里。

例如用环境变量:

import os WEBHOOK_URL = os.environ.get("WECOM_WEBHOOK_URL", "") SECRET = os.environ.get("WECOM_SECRET", "") ONCALL_MOBILE = os.environ.get("WECOM_ONCALL_MOBILE", "")

这样换机器人、换收件人,只需要改环境变量,不用改动代码,也更安全。尤其是 WEBHOOK_URL 和 SECRET,千万别打印到日志里,也不要提交到 Git 仓库。我有一次不小心把测试 key 提交到了公开仓库,几分钟后收到一堆用这个 key 发的垃圾消息,只能去后台删掉机器人重建,算是一个挺深刻的教训。

日志方面,建议每次发送都记录时间、消息类型、目标群和返回结果。不要觉得这很啰嗦,等真的出问题时,你会庆幸至少有日志可以翻。我用的是最朴素的 print 加 systemd 日志,重定向之后可以用 journalctl 查,完全够用。

如果你一个人维护多个群的多个机器人,还可以做一个简单的“路由”:把每个机器人的名字和 Webhook 地址放在配置里,发送时指定群名,脚本负责找到对应的 URL。这样一个工程文件就能管所有群的消息,不用一个群复制一份代码。

5. 踩坑实录:常见问题与排查清单

5.1 发送失败常见原因对照表

这个项目看着简单,真跑起来会遇到各种“明明代码没报错,群里就是没消息”的情况。我把遇到的典型问题整理成了对照表,方便你直接对号入座:

现象可能原因解决办法
返回 errcode 为 93000Webhook 地址里的 key 写错,或机器人已被删除回到群机器人设置里复制最新地址
返回 errcode 为 93001加签模式下签名计算错误或缺少 timestamp/sign 参数检查签名算法、时间戳是否是秒级、URL 拼接是否正确
返回 errcode 为 45009触发频率限制,超过每分钟 20 条增加发送间隔、合并多条消息、做消息聚合
请求超时或网络错误服务器无法访问企业微信接口域名,或出网代理异常在服务器上用 curl 直接试一下接口地址,确认网络链路
消息发送成功但没有 @ 到人mentioned_mobile_list 里的手机号未被企业微信绑定让同事确认企业微信里绑定的手机号,或改用 userid
Markdown 消息没有按预期渲染用了企业微信不支持的语法只用标题、加粗、引用、链接等基础语法

其中频率限制是最常见的一种。很多人会在脚本里写一个循环,尝试把 50 条数据逐个发出去,结果发到第 20 条就报错。正确的做法是在发送前就做聚合:把 50 条消息拼成一段文本,一条发出去。实在需要分批,也要控制每分钟不超过 20 条,并且加上 sleep(3) 之类的间隔。

5.2 定时任务到了但没发消息:先查时区再查进程

这类问题最让人头疼,因为它不是“每次都不发”,而是“今天发了明天没发”或者“比预期晚了几个小时”。排查顺序我总结成三步。

第一步,查时区。前面说过,APScheduler 默认用系统时区,如果服务器是 UTC,你写的 9:30 实际会变成 9:30 UTC,换算过来就是北京时间 17:30。看脚本里有没有显式指定timezone="Asia/Shanghai",没有就先补上。检查方法很简单,在脚本里打印一条datetime.now().isoformat(),看看打印出来的时间是不是北京时间。

第二步,查进程状态。执行systemctl status wecom-robot,确认服务状态是 active (running)。如果显示 failed,说明进程崩了,配合journalctl -u wecom-robot -n 50看最后几十行日志,基本能定位到是异常还是被系统杀掉。

第三步,查任务是否被重复注册。如果脚本被写了循环启动 APScheduler,或者 systemd 里配了 Restart=always,进程拉起后调度器又注册了一遍同一个任务,就可能出现“一条日报发两次”的情况。解决方法是保证任务注册在模块加载时只执行一次,或者在发送函数里做一个简单的幂等判断。

5.3 几个容易忽略的体验细节

除了硬报错,还有几个不影响功能、但影响体验的细节值得一说。

第一个是同一个群里如果存在多个机器人,它们会被群里的人视为不同的“成员”,群成员列表会变长。命名时最好风格统一,比如“日报助手”“告警助手”,让人一眼能看出来历。

第二个是机器人被群成员删除后,Webhook 地址会立即失效。如果有同事误删了,你在脚本端看到的现象就是 93000 错误。这时候别急着改代码,去群里重新添加一个机器人,把新地址换到配置里就行。这个误操作并不少,因为群管理菜单里的“删除机器人”和“编辑机器人”离得很近。

第三个是关于消息去向。Webhook 机器人发消息不需要在群里的人在线,消息会保留在企业微信群聊天记录里,新加入的群成员也能看到历史记录。所以如果你在消息里写了临时性的值班联系方式,要记得定期清理或者提醒大家变更。

6. 部署上线后的一些个人体会

这个项目做完之后,我最大的感受是:技术难度确实不高,真正花时间的地方全在那些“想当然”的细节上。比如时区、频率限制、加签、key 泄露,每一个单独拎出来都不是大问题,但串在一起,就是新手从“能跑通 demo”到“能稳定上线”之间的那段距离。

如果让我给后来者一个最核心的建议,那就是把 Webhook 地址当成数据库密码一样保护。群里所有能看到机器人设置的人,理论上都能拿到这个地址,都能往群里发消息。一个泄露的 key 不仅会导致垃圾消息,还会让整个群的成员对机器人失去信任。加签和 IP 白名单不是可选项,是上线前的必选项。

另一个建议是,先从一个最小的场景跑起来,比如每天早上发一条固定的值班提醒,跑一个礼拜稳定了,再往里加动态数据、加多人 @、加图片推送。步子迈得小一点,出问题也好定位。我做这个项目时第一版只做了文字提醒,第二版加了日报拼装,第三版才加图片和@人,每一次改动都只用几分钟,但基础已经打得很稳了。

最后再多说一句:这个方案不仅限于企业微信。同样的思路——“提供一个入口 + 定时任务 + 把动态数据拼成消息”,换一个 Webhook 平台,比如钉钉群机器人、飞书群机器人,代码结构基本不用大改。学会了这一套,你在任何聊天工具里都能做自己的“定时小助手”。以后团队里再有人说“每天手动发消息好烦”,你可以直接告诉他:这事十分钟就能自动化。

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

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

立即咨询