暂未播放
0:00
0:00

从企业微信到QQ重构博客机器人

1406 字
7 分钟
从企业微信到QQ重构博客机器人

博客机器人最早只接了企业微信。发条消息就能把随笔/动态推到 GitHub 走构建发布,功能上没什么问题。

但当时写回调那会儿真磨人——AES 加 SHA1、全链路加密,还得自己手写 PKCS#7 分块。好在调通之后就不用再碰了。

后来我才发现 QQ 的机器人平台更好用,Webhook 也开放了好久。签名模型用 Ed25519,比企微那套清爽得多,所以想直接迁过去。

迁移下来最大的收获不是多接了个 QQ,而是把接入层和业务逻辑拆开了:想用 QQ 就用 QQ,想留企微就留企微,以后想接 Telegram 也只是多写个适配器,核心的「发博文/动态/友链」一行都不用动。

QQ 的签名模型清爽在哪#

QQ 机器人走 Webhook,核心是 Ed25519(而不是企微的 SHA1 + 自定义 AES)。两件事:

  • 握手(op13):QQ 给你发一个 plain_tokenevent_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 干的是同一件事:解析用户意图 → 发博文 / 动态 / 友链。平台差异只在「收消息、验身份、回消息」这三步。

于是我把结构改成了这样:

AES解密+SHA1验签

Ed25519验签

dispatch/publish/githubPut

handleFriendLink

企业微信回调

wxbot/wx.js 适配器

QQ Webhook

worker.js 内联 QQ 适配器

共享业务层 src/shared.js

GitHub 仓库 构建发布

check-flink 真源

AES解密+SHA1验签

Ed25519验签

dispatch/publish/githubPut

handleFriendLink

企业微信回调

wxbot/wx.js 适配器

QQ Webhook

worker.js 内联 QQ 适配器

共享业务层 src/shared.js

GitHub 仓库 构建发布

check-flink 真源

共享业务层 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 开放平台)来说,什么都没变。

wxbot/wx.js
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|$)/ 才行。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
从企业微信到QQ重构博客机器人
https://x1anyu.cn/posts/8/
作者
羡鱼
发布于
2026-08-21
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
羡鱼
临渊空慕水中鱼, 不如携风自渡河.
分类
标签
最新动态
站点统计
文章
14
分类
7
标签
35
总字数
19,969
运行时长
0
最后活动
0 天前
文章目录