从企业微信到QQ重构博客机器人
博客机器人最早只接了企业微信。发条消息就能把随笔/动态推到 GitHub 走构建发布,功能上没什么问题。
但当时写回调那会儿真磨人——AES 加 SHA1、全链路加密,还得自己手写 PKCS#7 分块。好在调通之后就不用再碰了。
后来我才发现 QQ 的机器人平台更好用,Webhook 也开放了好久。签名模型用 Ed25519,比企微那套清爽得多,所以想直接迁过去。
迁移下来最大的收获不是多接了个 QQ,而是把接入层和业务逻辑拆开了:想用 QQ 就用 QQ,想留企微就留企微,以后想接 Telegram 也只是多写个适配器,核心的「发博文/动态/友链」一行都不用动。
QQ 的签名模型清爽在哪
QQ 机器人走 Webhook,核心是 Ed25519(而不是企微的 SHA1 + 自定义 AES)。两件事:
- 握手(op13):QQ 给你发一个
plain_token和event_ts,你用私钥对"plain_token" + plain_token + event_ts签名返回,平台确认你持有私钥就算验证通过。 - 消息事件:每条推送带
X-Signature头,值是Ed25519(私钥, "https://bots.qq.com" + 请求路径 + 时间戳 + body)的 base64。你本地按同样规则算一遍比对即可。
没全链路加密、没自定义块长的 AES、没 XML 套娃。验签就是验签,发消息走官方 API。
import * as ed from "@noble/ed25519";import { sha512 } from "@noble/hashes/sha2.js";ed.hashes.sha512 = sha512; // ⚠️ Workers 里必须手动注入,否则 ed.signAsync 会找不到哈希函数
// 握手const sd = "plain_token" + plain_token + event_ts;const sig = await ed.signAsync(new TextEncoder().encode(sd), privKey);return Response.json({ plain_token, signature: b64(sig) });
// 消息验签const concat = "https://bots.qq.com" + url.pathname + timestamp + body;const sigBytes = base64ToBytes(signatureHeader); // X-Signature 头取出的 base64 解码const ok = await ed.verifyAsync(sigBytes, new TextEncoder().encode(concat), pubKey);一个差点栽的坑:@noble/ed25519 在 Cloudflare Workers 里必须手动把 sha512 挂上去(ed.hashes.sha512 = sha512)。不挂的话签名走的是它内部默认实现,在 Workers 运行时下会报「hash 未定义」——这个报错信息非常误导性,查了半天才反应过来是注入没做。
关键一步:把「接平台」和「写业务」拆开
我一开始的想法是「再加个 Worker 专门跑 QQ」。一想不对——企微和 QQ 干的是同一件事:解析用户意图 → 发博文 / 动态 / 友链。平台差异只在「收消息、验身份、回消息」这三步。
于是我把结构改成了这样:
共享业务层 src/shared.js 只认一条统一结构 {msgType, content, fromUser},根本不关心消息来自企微还是 QQ。两个平台各自把自家协议翻译成这条结构,丢给 dispatch 就完事。
// worker.js 入口(节选)export default { async fetch(request, env, ctx) { const url = new URL(request.url); if (url.pathname === env.QQ_CALLBACK_PATH) return handleQQCallback(request, env, ctx); if (url.pathname === "/__qqmenu") return qqSetMenu(env); // QQ 自定义菜单 if (url.pathname === "/__qqpanel") return qqSetPanel(env); // QQ 指令面板 if (url.pathname === "/api/apply-friend") return handleApplyFriend(request, env); if (url.pathname === "/friends.json") return r2Friends(env); // 其余交给企微适配器;它内部对「非企微请求」返回 null,由这里兜底 404 const wxRes = await handleWechat(request, env, url, ctx); if (wxRes) return wxRes; return new Response("not found", { status: 404 }); }};这里有个我挺满意的小设计:企微适配器不负责「路由归属」。它 export 一个 handleWechat,把企微相关的 GET 验签、POST 解密、各调试端点全收进去;对于「这请求根本不是企微的」情况返回 null,交给 root 兜底 404。这样 root 的 fetch 永远是「先 QQ、再各业务端点、再丢给企微、最后 404」,顺序清晰,互不越界。
代码分离的取舍
我原本把企微逻辑整个抽成了独立部署的 wxbot/worker.js,后来觉得没必要——独立部署意味着企微回调地址要改、要管两份绑定、出问题时查两个地方。
最后方案是只分代码目录,不分架构:把企微那一坨挪进 wxbot/wx.js,root 用 import { handleWechat } 调它。线上还是同一个 Worker,企微回调地址一个字都不用动,QQ 和企微在同一进程里共存。对外部(企微后台、QQ 开放平台)来说,什么都没变。
import { dispatch } from "../src/shared.js";export async function handleWechat(request, env, url, ctx) { // 企微全套:验签 / 解密 / 被动回包 / cf+hybrid 双模式 / 调试端点 // 非企微请求 → return null}哪天真不想留企微了,删掉这一行 import 和这个文件夹就行,QQ 半边一行都不用碰。反过来想留企微、砍 QQ 也一样。平台成了可插拔的模块。
几个踩过的坑
- Ed25519 的 sha512 注入:上面提过,Workers 里忘了
ed.hashes.sha512 = sha512会莫名其妙报错,记住就好。 - X-Signature 的拼接串:必须是
"https://bots.qq.com" + path + timestamp + body原样拼,少一段、顺序错、把 query 也算进去,验签全失败。建议直接拿官方示例的拼接方式对齐,别自己发挥。 - 企微收不到图片:企微的
media/get受「可信 IP 名单」限制,Worker 出口 IP 不在名单里,下载素材会报 60020。所以图片一律改成链接写(封面:外链、),适配器里根本不存图,只引导。这点 QQ 反而省心,能直接发图。 - 命令路由的正则:早期我用
/^\/(友链|friend|fl)(\s.*)?$/匹配,结果.不匹配换行,一发多行信息块就被当成普通博文吞了。改成「前缀式」/^\/(友链|friend|fl)(?:\s|$)/才行。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!











