
1. 先想清楚公众号机器人到底是怎么工作的做公众号机器人这件事我在 Mac 上折腾了好几个周末。最初的想法很简单把公众号当成一个自动回复入口用户发关键词机器人返回对应内容再高级一点就是从被动回复变成主动通知。但真做起来才发现网上教程大多是“Windows 开发 Linux 服务器部署”的路子Mac 本地方案散落在各种论坛角落里资料陈旧还互相矛盾。这篇文章就把我在 Mac 环境下用开源项目从零搭建微信公众号机器人的完整流程、选型理由和踩坑记录一次性写清楚。适合已经注册过公众号、手里有一台 Mac、想用开源框架快速跑通开发模式的人参考。先说明边界这里做的机器人走的是微信公众平台开放给开发者的官方接口用户发消息给公众号微信服务器把消息推送到你自己的服务上你的服务处理完再回复给用户。这不是那种通过逆向协议控制个人微信号的方案后者既不稳定也明显违反平台规则我不建议碰。开源项目方面我选的方案是 Python 生态里的 WeRoBot官方地址在 GitHub 上它封装了开发者模式下绝大部分重复劳动URL 验证、消息解析、回复构造都处理好了你只需要写业务逻辑。后面所有代码都基于它展开。在往下走之前先建立一张认知地图开发者模式下的公众号机器人本质上就是一个 HTTP 服务。用户聊天是入口消息以 XML 形式 POST 到你配置的回调地址你的程序解析后把回复文本再交回微信服务器用户就能看到。理解这一点后面所有配置和代码都是围绕“一个可被公网访问的 HTTP 服务”展开的。2. 账号、环境、调试链路开工前把这三样备齐2.1 测试号还是正式号如果你只是自己练手强烈建议先申请一个“微信公众号测试号”不需要提交资质材料入口在微信公众平台文档页里能找到扫码登录后立刻就能拿到 AppID 和 AppSecret。测试号最大的好处是权限完整自定义菜单、模板消息、客服接口这些能力基本都开放用来跑通流程和验证想法绰绰有余。正式号订阅号/服务号则需要走注册流程个人主体只能申请订阅号。订阅号在开发者模式下也能接收消息和自动回复但模板消息等高级接口的权限要看公众号类型服务号的权限远大于订阅号。我的建议是先用测试号把代码和部署流程全部跑通再决定要不要迁移到正式号两个账号的接口逻辑几乎没有差别迁移成本很低。2.2 Mac 本机环境Homebrew、Python 与虚拟环境Mac 上做 Python 开发环境管理是第一个坑。macOS 自带的 Python 版本很旧而且python3命令的指向可能和你心里想的不一样。我现在的习惯是先装 Homebrew再用它安装新版 Python/bin/bash -c $(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh) brew install python python3 --version注意这里有个细节Homebrew 装的 Python 一般会出现在/opt/homebrew/bin/python3Apple Silicon或/usr/local/bin/python3Intel后面配置 systemd 时我会强调为什么要用绝对路径Mac 上先养成这个习惯没坏处。接着为项目建一个独立的虚拟环境避免以后依赖版本互相打架mkdir ~/wechat-robot cd ~/wechat-robot python3 -m venv venv source venv/bin/activate pip install werobot2.3 让微信能访问到你的 Mac内网穿透与 ngrok开发阶段最大的障碍是微信服务器要往你的回调地址发请求可你的 Mac 在家里/办公室没有公网 IP。这时候需要一个内网穿透工具把本地的 8888 端口暴露成一个公网可访问的 HTTPS URL。我用的是 ngrokMac 上一条命令装完brew install ngrok ngrok http 8888运行后 ngrok 会输出一个形如https://xxxx.ngrok-free.app的地址这个地址就是微信服务器能访问到的入口。需要说明的是ngrok 免费版的域名每次重启都会变开发阶段无所谓重新去公众号后台改一下 URL 就行。如果你希望域名固定可以注册账号绑定自定义域名或者用 frp 自建内网穿透但那是另一个话题了这里不展开。3. 微信回调的底层逻辑你的服务器凭什么能被微信“信任”3.1 URL 和 Token 的一次握手在公众号后台配置服务器时要填一个 URL 和一个 Token。很多人第一次都卡在这一步填完之后点击提交平台提示“验证失败”。原因是微信为了确认“这个 URL 确实是你的”会向它发送一个 GET 请求带上signature、timestamp、nonce、echostr四个参数。你的服务器要做的验证是把 Token、timestamp、nonce 三个字符串按字典序排序拼成一个字符串后做 SHA1 哈希把结果和 signature 对比一致则说明请求来自微信原样返回 echostr。用 Python 手工实现也就几行import hashlib def check_signature(token, signature, timestamp, nonce): params [token, timestamp, nonce] params.sort() raw .join(params).encode(utf-8) return hashlib.sha1(raw).hexdigest() signatureWeRoBot 内部已经完整实现了这套逻辑你只需要在初始化时传入 Token。但我强烈建议你手动把它写一遍理解了这个握手过程遇到“Token 验证失败”时才会知道从哪里排查而不是瞎猜。3.2 用户消息是怎么送到你代码里的握手验证通过后用户每次在公众号对话框里发消息微信服务器都会向你的 URL 发送一个 POST 请求请求体是一段 XML长这样xml ToUserName![CDATA[gh_xxxxxx]]/ToUserName FromUserName![CDATA[oXXXXXX]]/FromUserName CreateTime1717200000/CreateTime MsgType![CDATA[text]]/MsgType Content![CDATA[你好]]/Content MsgId1234567890/MsgId /xml这里FromUserName是用户的 OpenID对每个公众号和用户组合来说是唯一且固定的你可以把它当成用户的 ID 存起来。你的代码收到这段 XML 后构造一段回复 XML 返回给微信服务器微信再把内容推给用户。注意回复的时候ToUserName和FromUserName要互换WeRoBot 已经帮你处理好了但理解这一点有助于排查“为什么回复发不到用户手里”。3.3 明文模式与安全模式怎么选公众平台服务器配置里可以选择消息加解密方式明文模式、兼容模式、安全模式。明文模式下消息体就是上面那段 XML调试起来最直观安全模式则需要对消息体做 AES 解密EncodingAESKey 让你在公众号后台生成WeRoBot 也支持传递 EncodingAESKey 参数来自动处理加解密。我个人的实践是开发调试阶段用明文模式省掉加解密的复杂度快速把业务逻辑跑通上了生产环境如果回调 URL 用的是公网 HTTP 而非 HTTPS至少打开安全模式防止消息内容明文在链路上裸奔。如果 URL 已经配置了 HTTPS那么明文模式也可以接受但你要自己权衡。4. 用 WeRoBot 跑通第一个能聊天的机器人4.1 最小可运行实例虚拟环境准备好之后写一个最简服务只需要十来行代码。在项目目录里新建app.py# -*- coding: utf-8 -*- import werobot robot werobot.WeRoBot(token你的Token) robot.text def echo(message): return f你发送了{message.content} robot.config[HOST] 0.0.0.0 robot.config[PORT] 8888 robot.run()0.0.0.0而不是127.0.0.1是因为要让外部流量进来ngrok 转发请求到本机时必须监听所有网卡接口。运行python app.py看到日志输出后服务就起来了。这时候去浏览器访问http://localhost:8888确认没有报错再用微信开发者工具或者直接 curl 试一下 URL 验证接口是否响应。4.2 在公众平台配置服务器参数如果你用测试号登录测试号管理页面找到“接口配置信息”填入URLhttps://你的ngrok域名.ngrok-free.app或者加上具体路径如https://xxx.ngrok-free.app/wechatToken和代码里初始化WeRoBot(token你的Token)保持一致。填好后提交如果一切正常页面会提示“配置成功”。这里有一个经验提交前先确认 ngrok 还活着免费版偶尔会自动断开你刚填完 URL 它挂了自然验证失败。我第一次就栽在这里以为代码写错了实际是 ngrok 重连导致地址变化。配置成功后在测试号页面扫码关注这个公众号然后随便发一条消息例如“你好”你的程序会收到推送并回复“你发送了你好”。到这一步一个最朴素的公众号机器人已经可以用了。4.3 关键词路由与事件处理光会回声没什么价值接下来做关键词路由。微信公众号机器人最常见的形态是用户发“帮助”返回指令列表发“天气”返回对应城市天气发“订阅”则记录 OpenID 后续推送。先用一个简单字典实现RULES { 你好: 你好呀欢迎关注这个机器人。, 帮助: 回复【你好】跟我打招呼回复【时间】获取当前时间。, 时间: 现在是北京时间具体请调用接口获取。, } robot.text def keyword_reply(message): content message.content.strip() for key, reply in RULES.items(): if key in content: return reply return 我没听懂试试回复【帮助】查看指令。注意这里我用的是“包含匹配”而不是“完全匹配”用户发“你好呀”也能触发“你好”的规则更贴近实际聊天场景。另一个高频场景是关注事件用户第一次关注公众号时自动推送一条欢迎语。WeRoBot 用robot.subscribe装饰器处理robot.subscribe def on_subscribe(message): return 欢迎关注回复【帮助】查看功能。还有点击自定义菜单触发的事件后面会讲到用的也是类似的事件处理器。4.4 让代码在改动后自动重载开发阶段你一定会频繁改代码每次手动重启进程很烦。WeRoBot 底层用的是 Werkzeug 开发服务器本身没有热重载。我的做法是写一个简单的 shell 脚本#!/bin/bash while true; do python app.py sleep 0.5 done或者直接用watchdog监听文件变化自动重启。更省事的办法是用--reload参数跑在 Flask/gunicorn 上但这就偏离 WeRoBot 内置服务器了。开发阶段凑合着手动CtrlC重启也没问题等部署到服务器之后我会用 systemd 的Restart机制解决生产环境的重启问题。5. 让机器人更实用菜单、模板消息与主动通知5.1 自定义菜单的创建与更新公众号底部的菜单栏是用户和机器人交互的重要入口。创建一个菜单需要调用微信接口带上 access_token。WeRoBot 的Client对象封装好了这些调用from werobot.client import Client client Client(configrobot.config)然后创建菜单menu { button: [ { type: click, name: 今日推荐, key: DAILY, }, { type: view, name: 官网, url: https://example.com, }, ] } client.create_menu(menu)click类型的菜单点一下会推一个事件过来事件里的key就是DAILY可以在代码里用robot.click捕获robot.click def on_click(message): if message.key DAILY: return 今天推荐早点睡觉。注意view类型的菜单不需要你处理事件直接跳转网页。还有一个经验菜单修改后不是立刻生效微信有自己的缓存最长可能需要 24 小时才能看到新菜单所以调试菜单时要有耐心。5.2 模板消息从被动回复到主动推送模板消息是公众号机器人的核心能力之一它允许你的服务器主动向用户推送一条结构化通知比如订单发货、打卡提醒、日报推送。和自动回复不一样模板消息是“不被用户提问也能发”这会让你的机器人从“应答机”进化为“通知工具”。使用模板消息前需要先在公众平台申请一个模板拿到模板 ID。测试号页面里有一些内置模板可以直接使用。发送一条模板消息的代码如下data { first: 你有新的待办事项, keyword1: 发布任务, keyword2: 2025-06-01 10:00, keyword3: 请尽快处理, remark: 点开详情查看, } client.send_template_message( openid用户的OpenID, template_id模板ID, datadata, )这里的关键是 OpenID它是在用户和公众号交互时拿到的。最简单的方式是记录收到消息时的message.source也就是FromUserName存到本地文件或者数据库。发送频率要注意微信对模板消息有频次限制测试号虽然宽松但生产号如果一天内给同一个用户发太多条会被平台限流甚至封禁接口。5.3 一个完整的“订阅提醒”场景把前面几块拼起来做一个“订阅提醒”功能用户发送“订阅”机器人记录用户的 OpenID到指定时间程序主动给所有订阅用户发送模板消息。import json SUBSCRIBERS_FILE subscribers.json robot.text def handle_subscribe(message): if message.content.strip() 订阅: subscribers load_subscribers() if message.source not in subscribers: subscribers.append(message.source) save_subscribers(subscribers) return 订阅成功后续我会给你推送提醒。 return 你已经订阅过了。 return 试试回复【订阅】。 def load_subscribers(): try: with open(SUBSCRIBERS_FILE, r) as f: return json.load(f) except FileNotFoundError: return [] def save_subscribers(subscribers): with open(SUBSCRIBERS_FILE, w) as f: json.dump(subscribers, f)你用 cron 定时任务或者 Python 的APScheduler每天定时去扫描待办事项然后遍历订阅列表发送模板消息。这就完成了一个有实战价值的公众号机器人。文件存储只适合数据量和并发很低的场景等用户多了就换成 SQLite 或者 MySQL。6. 从 Mac 本地到 Linux 服务器部署实录6.1 为什么最终要放到服务器上开发阶段用 Mac 一直开着加上 ngrok确实能让机器人跑起来但这种状态撑不起生产环境。原因有两个第一Mac 是个人电脑关机、休眠、重启、网络波动都会导致服务中断用户消息就直接丢失第二ngrok 免费版的域名不稳定正式环境要的是一个固定的回调地址。所以当你的机器人要长期运行时最好迁移到一台云服务器上。部署的时候并不需要在某一家特定服务商之间做选择任何一台有公网 IP 的云主机都可以。系统我建议用 Ubuntu 22.04 或者 Debian资料多、踩坑少。迁移过程其实不复杂代码用 scp 或者 rsync 传到服务器Python 环境重新装一份再用 systemd 把服务守护起来最后配好域名和 HTTPS。6.2 服务器端部署五步走第一步安装 Python 和虚拟环境apt update apt install -y python3 python3-venv nginx mkdir -p /opt/wechat-robot cd /opt/wechat-robot python3 -m venv venv source venv/bin/activate pip install werobot gunicorn第二步把代码上传。在 Mac 本地执行scp app.py user你的服务器IP:/opt/wechat-robot/第三步写一个 WSGI 入口。WeRoBot 内置的开发服务器只适合调试生产环境换成 gunicorn 更稳。在app.py末尾不要调用robot.run()改成暴露一个 WSGI 应用application robot.wsgi然后新建一个wsgi.pyfrom app import application if __name__ __main__: import gunicorn第四步用 gunicorn 启动gunicorn -w 1 -b 127.0.0.1:8888 wsgi:application注意 gunicorn 只监听内网端口外面用 Nginx 反向代理到 8888这样 HTTPS 证书、请求日志都可以统一在 Nginx 层处理。第五步配置 Nginx。创建一个站点配置server { listen 80; server_name your.domain.com; location /wechat { proxy_pass http://127.0.0.1:8888; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }之后在公众号后台把 URL 改成http://your.domain.com/wechat重新提交就能生效。如果想让回调走 HTTPS用 certbot 申请免费证书把 80 端口请求 301 到 443 即可。6.3 用 systemd 守护进程gunicorn 直接在终端里跑一旦 SSH 断开进程可能就没了。用 systemd 把它注册成系统服务就能实现开机自启和崩溃自动重启。编辑/etc/systemd/system/wechat-robot.service[Unit] DescriptionWeChat Robot Service Afternetwork.target [Service] Userwww-data WorkingDirectory/opt/wechat-robot ExecStart/opt/wechat-robot/venv/bin/gunicorn -w 1 -b 127.0.0.1:8888 wsgi:application Restartalways RestartSec3 EnvironmentTZAsia/Shanghai [Install] WantedBymulti-user.target然后执行systemctl daemon-reload systemctl enable wechat-robot systemctl start wechat-robot systemctl status wechat-robot这里我特意加了EnvironmentTZAsia/Shanghai因为很多云服务器默认时区是 UTC模板消息里的时间字段会显示成英国时间用户看到的推送时间差 8 小时。这个坑很隐蔽后面我会专门讲。7. 我踩过的五个坑以及排查顺序7.1 Token 验证失败先别怀疑代码第一次配服务器的时候我在公众平台点提交页面直接提示“token验证失败”。最初的半小时我一直在检查代码里的 Token 和签名逻辑后来才发现是 ngrok 断开了地址变成了新的。验证失败时要按这个顺序排查第一步确认 ngrok 还活着浏览器访问 ngrok 给的地址看有没有响应第二步确认 URL 填的是公网地址而不是localhost第三步确认服务监听在0.0.0.0而不是127.0.0.1第四步确认代码里的 Token 和后台填的一字不差。绝大多数验证失败都能在四步内解决。7.2 消息发过去没反应检查“启用”和白名单配置成功之后我在测试号里发消息等了好一会儿没有任何回复。排查发现是公众号后台“服务器配置”那里有一个启用开关配置好 URL 和 Token 之后还必须手动点一下“启用”开发模式下才会开始推送消息。这是个特别低级但特别容易忽略的开关。另一个容易忽略的是“IP 白名单”。测试号的 AppID 和 AppSecret 在调用接口比如获取 access_token时会校验来源 IP如果你的代码要在本地 Mac 上跑就必须把 Mac 的公网 IP 加进白名单不然会报invalid ip。改了 Mac 的网络或者换了热点IP 又变了还得回来更新。这也是为什么生产环境建议直接在服务器上运行省去 IP 白名单反复调整的麻烦。7.3 5 秒超时机器人一查数据库就卡死微信服务器要求开发者在 5 秒内返回响应。我后来加了一个“查天气”的功能处理逻辑里要请求第三方接口结果经常超过 5 秒微信服务器那边认为请求超时自动重试用户那边就会收到“该公众号暂时无法提供服务”或者重复消息。解决方法有两条路一是保证同步处理足够快所有请求都控制在百毫秒级别二是先返回一个“正在处理中”的文本真正的结果通过客服消息接口再发给用户。客服接口需要在 48 小时内和用户有互动才可以用但对于一个刚发了消息的用户来说这个条件天然满足。异步处理逻辑要特别注意消息幂等因为微信重试时会再推一条消息进来你可能会给用户发两次结果。7.4 用户收到轰炸微信的重试机制微信对未成功推送的事件是有重试策略的。有一次我代码里有个临时 bug处理消息时抛了异常WeRoBot 返回了一个 500 状态码。微信服务器发现它没收到正常响应就隔几秒重试一次用户那边看到的是好几条一模一样的报错提示。解决这个问题的核心是保证消息处理的幂等性。对于文本回复这种场景简单的做法是用message.idMsgId做去重维护一个最近处理过的消息 ID 集合重复消息直接忽略。另一个思路是即使代码内部报错也要保证 HTTP 层返回 200不让微信触发重试。WeRoBot 可以捕获异常并返回一个默认回复但更稳妥的是你自己的业务代码做好异常兜底。7.5 Mac 上跑得好好的服务器上一运行就报错最后一个坑是环境不一致。Mac 上python3指向的可能是/opt/homebrew/bin/python3服务器上指向/usr/bin/python3两边的 Python 小版本和包管理都不一样。代码里如果用了路径拼接比如os.path.dirname(__file__)在两边还表现不同尤其是你从 Mac 传到服务器时文件路径大小写不一致也可能出问题。我的建议是本地用 virtualenv服务器也用 virtualenv并维护好requirements.txt每次部署之后都执行一遍pip install -r requirements.txt保证两边依赖版本一致。不要依赖系统级 Python 环境也不要用sudo pip直接装到系统。部署完成后跑一遍python -c import werobot确认框架能正常导入再做一次全链路测试再开放给用户使用。最后再分享一个小技巧开发阶段调试公众号消息不必每次都走微信真机。你可以在本地用 curl 直接向自己的服务发一段 XML 模拟微信推送比如curl -X POST http://localhost:8888/wechat \ -H Content-Type: text/xml \ -d xmlToUserName![CDATA[gh_test]]/ToUserNameFromUserName![CDATA[oTestUser]]/FromUserNameCreateTime1717200000/CreateTimeMsgType![CDATA[text]]/MsgTypeContent![CDATA[你好]]/ContentMsgId123456/MsgId/xml但要注意真实微信推送会带签名参数如果你在代码里开启了严格验签curl 模拟的请求会被拒绝。我的做法是开发模式默认关闭验签只在接收到来自微信服务器的请求时才开启两套逻辑用环境变量开关控制这样既方便本地调试又不影响线上安全。真正上线前还是要用真机完整测一遍尤其是关注和菜单点击这些事件curl 模拟不了全流程。