微信插件 1.0.2 → 1.0.3 源码 Diff 深度分析
> 包名: @tencent-weixin/openclaw-weixin
> 版本对比: 1.0.2 → 1.0.3
> 变更文件: 11 个
> 分析方法: npm pack 下载两个版本 tgz,解压后逐文件 diff -u
一句话总结
1.0.3 的核心改动是让微信插件从"只能回复"变成"能主动发消息"——通过 contextToken 持久化 + 容错降级,解决了 cron 定时任务和 gateway 重启后消息发不出去的问题。
背景:什么是 contextToken?
contextToken 是微信 iLink Bot 协议中的会话凭证。微信的防骚扰机制要求:
- 每次用户给 Bot 发消息时,微信会附带一个 contextToken
- Bot 必须拿着这个 token 才能回复对方
- 相当于微信发了一张"回复许可证"
这与 Telegram/Discord 完全不同:
| 平台 | 首次主动发消息 | 对方聊过后定时发 |
|---|---|---|
| **Telegram** | ✅ 知道 chat_id 就行 | ✅ |
| **Discord** | ✅ 知道 user_id 就行 | ✅ |
| **QQ Bot** | ✅ | ✅ |
| **微信 iLink** | ❌ 必须对方先说话 | ✅(1.0.3 持久化 token 后) |
变更文件清单
| 文件 | 改动类型 |
|---|---|
| `package.json` | 版本号 |
| `src/auth/accounts.ts` | 🆕 stale 账号清理 |
| `src/auth/login-qr.ts` | 二维码提示改中文 |
| `src/channel.ts` | 🆕 多账号发送路由 |
| `src/log-upload.ts` | 路径跨平台兼容 |
| `src/messaging/error-notice.ts` | 错误通知容错 |
| `src/messaging/inbound.ts` | 🔑 token 持久化 |
| `src/messaging/process-message.ts` | 回复逻辑简化 |
| `src/messaging/send.ts` | 🔑 发送容错降级 |
| `src/util/logger.ts` | 路径跨平台兼容 |
| `src/util/redact.ts` | 🔒 日志脱敏增强 |
改动一:contextToken 持久化到磁盘(核心)
问题:1.0.2 的 contextToken 只存在内存 Map 中,gateway 重启就全丢了。
1.0.2 实现:
// 纯内存,重启归零
const contextTokenStore = new Map<string, string>();
1.0.3 实现:每次收到新 token,同时写一份 JSON 到磁盘:
// 持久化路径: accounts/{accountId}.context-tokens.json
// 内容格式: { "用户A的userId": "token_xxx", "用户B的userId": "token_yyy" }
function persistContextTokens(accountId: string): void {
const prefix = `${accountId}:`;
const tokens: Record<string, string> = {};
for (const [k, v] of contextTokenStore) {
if (k.startsWith(prefix)) {
tokens[k.slice(prefix.length)] = v;
}
}
fs.writeFileSync(filePath, JSON.stringify(tokens, null, 0), "utf-8");
}
Gateway 启动时调用 restoreContextTokens() 从磁盘恢复到内存 Map。
新增函数:
persistContextTokens(accountId)— 写盘restoreContextTokens(accountId)— 启动时恢复clearContextTokensForAccount(accountId)— 清理指定账号的所有 tokenfindAccountIdsByContextToken(token)— 通过 token 反查 accountId
文件: src/messaging/inbound.ts
改动二:发送时 contextToken 容错降级
问题:1.0.2 在发送消息时如果没有 contextToken,直接 throw Error 拒绝发送。这导致 cron 定时任务、主动推送等场景完全无法工作。
1.0.2 实现(四个发送函数全部如此):
if (!opts.contextToken) {
logger.error(`sendMessageWeixin: contextToken missing, refusing to send to=${to}`);
throw new Error("sendMessageWeixin: contextToken is required"); // 💥 直接炸
}
1.0.3 实现:
if (!opts.contextToken) {
logger.warn(`sendMessageWeixin: contextToken missing for to=${to}, sending without context`);
// ⚠️ 不再 throw,继续尝试发送
}
影响的函数(全部从 throw 改为 warn):
sendMessageWeixin()— 文本消息sendImageMessageWeixin()— 图片消息sendVideoMessageWeixin()— 视频消息sendFileMessageWeixin()— 文件消息
错误通知也同步改了(error-notice.ts):
// 1.0.2: 没 token 就不发错误通知
if (!params.contextToken) {
logger.warn(`no contextToken, cannot notify user`);
return; // 静默吞掉
}
// 1.0.3: 没 token 也尝试发
if (!params.contextToken) {
logger.warn(`no contextToken, sending without context`);
// 继续执行
}
文件: src/messaging/send.ts, src/messaging/error-notice.ts
改动三:清理重复账号(Stale Account Cleanup)
问题:同一个微信号重复扫码登录会产生多个 account 记录,导致 contextToken 匹配歧义。
1.0.3 新增 clearStaleAccountsForUserId():
export function clearStaleAccountsForUserId(
currentAccountId: string,
userId: string,
onClearContextTokens?: (accountId: string) => void,
): void {
if (!userId) return;
const allIds = listIndexedWeixinAccountIds();
for (const id of allIds) {
if (id === currentAccountId) continue;
const data = loadWeixinAccount(id);
if (data?.userId?.trim() === userId) {
// 发现同一微信号的旧账号,清除
onClearContextTokens?.(id);
clearWeixinAccount(id);
unregisterWeixinAccountId(id);
}
}
}
配套新增 unregisterWeixinAccountId() 从索引文件中移除旧账号。
文件: src/auth/accounts.ts
改动四:多账号发送路由
问题:cron 发消息时没有指定 accountId(因为 cron 不知道用哪个微信账号发),1.0.2 无法处理这种情况。
1.0.3 新增 resolveOutboundAccountId():
发送消息(无 accountId)
↓
单账号?→ 直接用它
↓
多账号?→ 通过 contextToken 反查:哪个账号跟这个收件人聊过天?→ 用那个
↓
都匹配不到?→ 抛出描述性错误
文件: src/channel.ts
改动五:日志敏感信息脱敏
问题:1.0.2 打日志时,HTTP body 中的 token 等敏感信息是明文的:
{"context_token":"sk-abc123xyz真实token","bot_token":"secret_456"}
1.0.3 改进:用正则匹配敏感字段名,自动替换值为 :
const SENSITIVE_FIELDS = /\b(context_token|bot_token|token|authorization|Authorization)\b/;
export function redactBody(body: string | undefined, maxLen = DEFAULT_BODY_MAX_LEN): string {
if (!body) return "(empty)";
const redacted = body.replace(
/"(context_token|bot_token|token|authorization|Authorization)"\s*:\s*"[^"]*"/g,
'"$1":"<redacted>"',
);
if (redacted.length <= maxLen) return redacted;
return `${redacted.slice(0, maxLen)}…(truncated, totalLen=${redacted.length})`;
}
效果:
{"context_token":"<redacted>","bot_token":"<redacted>"}
为什么重要:日志文件经常被收集到监控系统(Datadog、Loki 等),或排查问题时直接分享给他人。明文 token 躺在日志里 = 把钥匙贴在公告栏上。
文件: src/util/redact.ts
改动六:其他小改
路径跨平台兼容
/tmp/openclaw 硬编码路径全部改为 resolvePreferredOpenClawTmpDir(),适配不同操作系统。
影响文件: src/util/logger.ts, src/log-upload.ts, src/channel.ts
二维码登录提示改中文
// 1.0.2
process.stdout.write(`QR Code URL: ${qrResponse.qrcode_img_content}\n`);
// 1.0.3
process.stdout.write(`二维码未加载成功,请用浏览器打开以下链接扫码:\n`);
process.stdout.write(`${qrResponse.qrcode_img_content}\n`);
文件: src/auth/login-qr.ts
发送成功日志提级
sendImageMessageWeixin 和 sendVideoMessageWeixin 的成功日志从 debug 提升为 info,方便排查。
回复流式设置
process-message.ts 中回复时显式设置 disableBlockStreaming: false。
错误处理简化
process-message.ts 移除了 contextToken is required 的特殊分支(因为 send 层不再 throw 这个错误了)。
组合效果:完整流程
1. 用户跟 Bot 聊天
→ 微信附带 contextToken
→ 存内存 Map + 写磁盘 JSON
2. 三天后 cron 定时任务触发
→ resolveOutboundAccountId() 找到正确账号
→ 从磁盘读到该用户的 contextToken
→ 正常发送 ✅
3. Gateway 重启
→ restoreContextTokens() 从磁盘恢复所有 token
→ 不影响后续发送 ✅
4. 万一 token 过期或丢失
→ 不 crash,降级为 warn
→ 尝试发送,能发就发 ✅
5. 同一微信号重新扫码登录
→ clearStaleAccountsForUserId() 清除旧账号
→ 不会出现 token 匹配歧义 ✅
升级建议
建议升级。1.0.3 解决的都是实际使用中会遇到的问题:
- 如果你用了 cron 定时任务 → 必须升级
- 如果你经常重启 gateway → 必须升级
- 如果你关注安全(日志脱敏)→ 建议升级
- 如果你只是简单聊天 → 影响不大,但升了没坏处
升级命令:
cd ~/.openclaw/extensions/openclaw-weixin
npm install @tencent-weixin/openclaw-weixin@1.0.3
openclaw gateway restart
评分
| 维度 | 评分(/10) |
|---|---|
| 问题修复价值 | 9.0 — contextToken 持久化解决了真实痛点 |
| 代码质量 | 8.0 — 改动清晰,向后兼容,无 breaking change |
| 安全改进 | 7.5 — 日志脱敏是该做的基本功 |
| 文档/提示 | 6.0 — 二维码中文化不错,但没有 CHANGELOG |
| **综合** | **8.0** |
报告基于 npm pack 下载的 1.0.2 与 1.0.3 tgz 包逐文件 diff 生成 | 2026-03-24