GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-05-12-weixin-db-channel.md
2026-05-20 21:39:12 +08:00

96 KiB

Weixin DB 渠道(weixin-db)实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

版本说明: 2026-05-12 架构师评审后定稿。原 .NET bridge 版本(33 Task)已 superseded,本文档仅保留纯 Node + PowerShell 架构 C 实施计划。

实施里程碑:

  • M1 · 读 + 展现(Phase C-0 / C-1 / C-2 / C-3 / C-4 / 部分 C-5):最小可演示版本。用户添加群名 → backend 实时解密读消息 → 前端 chat 页看到群对话流。
  • M2 · 完整 UX(Phase C-5 / C-6 / C-7 / C-8):前端"+ 添加群"UI、installer、E2E。
  • M3 · 自动回复(Phase C-9,另起 spec):replyToGroup 真实实现。

架构 C · 纯 Node + PowerShell 实施

Goal: 实现 spec 2026-05-08-weixin-group-channel-design.md "方案 5 / 架构 C" 的群聊接入:backend 内部完成 WCDB 解密 + 增量读消息 + 白名单过滤;用户主动添加群名才被监管;自动回复(5.7)留占位待后续 spec。

Architecture: 不再有独立 bridge 进程。weixin-db channel 在 backend 启动时:spawn 一次性 PowerShell 脚本抽 key → 缓存到内存 → better-sqlite3 解密 + 查 DB → setInterval 轮询 WAL → 命中白名单的新行 直接调 agentChannelService.routeInboundMessage(同进程函数调用,无 HTTP)。回复路径 v1 抛 NotImplementedError,留接口给下个迭代填充。

Tech Stack: Node 22 + Midway 3 + TypeORM + better-sqlite3(已有依赖)+ @mongodb-js/zstd(新增,纯 N-API);Vue 3 + Element Plus;PowerShell 5+(Windows 自带,提取 key 用,无需安装)。

Spec: docs/superpowers/specs/2026-05-08-weixin-group-channel-design.md § 5.0-5.12。

前置依赖:

  • 2026-05-08 Phase 1-5 已合并(chat_scope / errors / agent_channel_group / agent_executor chatScope / 前端 channel-management + group-panel 主体)
  • 2026-05-09 UIA Plan A-D 的 .NET bridge 产物 + backend 侧 weixin_uia / wechat_archive 代码 整体作废,本 plan Phase 1 一次性删干净

关键约束:

  • 架构 C 决策:不复活 .NET bridge,全部 Node;Win32 调用走 PowerShell spawn 脚本(无管理员要求)
  • 群白名单语义:netaclaw_agent_channel_group.status 默认 1 由用户主动添加;不再有"被动发现" —— 删除 upsertOnInbound;不再有 -1=ignored 状态
  • DB 解密阶段白名单过滤:incremental_reader.ts 用 group repo 查白名单 → 只读匹配的 Msg_<sha> 表;非白名单群的消息bot 物理上读不到(隐私保护)
  • 5.7 回复路径占位:weixin_db.ts 暴露 replyToGroup(channelId, roomName, text) 但 throw Error('weixin-db reply: not implemented yet');agent_channel.handleDbInbound 捕获后写入 sessionEntry "[占位:回复未实现]" 不阻塞读链路
  • PowerShell 脚本作为产物:packages/backend/tools/win32/extract-weixin-key.ps1 作为 backend 包的一部分被 installer 拷贝到运行目录
  • 跨平台兜底:backend 跨 Windows/Linux/Mac 编译;weixin-db channel 在非 Windows 上 bindChannel 必须直接 return + 标 loginStatus='unsupported_platform',不 spawn powershell 不 crash
  • 脚本路径 robust 解析:env NETA_WEIXIN_KEY_SCRIPT → 模块相对路径 __dirname/../../../../tools/win32/*.ps1 → installer 部署路径 → cwd 相对 四级 fallback
  • 重连机制:WeixinDbService 每 60s 健康探针(探 Weixin 进程 + DB 可读),失败 unbindChannel + bindChannel 重试
  • DEV ≠ 打包 exe:改代码 pnpm dev HMR;改 ps1 下次 spawn 直接读最新版;完全不需要打包成 backend.exe 来开发
  • commit 粒度:每 Task 一个 commit
  • 不做(留 v2):自动回复实际实现(5.7)、图片/文件下载、跨账号
  • 测试:Node 单测覆盖 wcdb_codec / room_resolver / 白名单过滤;集成测试用 fixture DB(条件 skip)

DEV 工作流(重要)

开发过程完全不需要打包 backend.exe

开发者机器 (Windows + 已登录 Weixin 4.x):
  ├─ cd packages/backend && pnpm dev       ← Midway tsx HMR,改 wcdb_codec.ts 自动重载
  ├─ cd packages/frontend && pnpm dev       ← Vite HMR
  ├─ packages/backend/tools/win32/*.ps1    ← 改 ps1,下次 spawn 直接用新版
  └─ Weixin.exe 保持登录

需要打包 exe 的场景(只有这三个):

  1. Phase C-8 E2E 验证 installer 路径是否正确(跑一次确认)
  2. 给测试用户分发(M2 结束后)
  3. CI 发版(不在本 plan 范围)

Linux/Mac 开发者也能完整 dev backend/frontend/单测,只是 weixin-db channel 会直接标 unsupported_platform,其他功能不受影响。


文件结构

新增

文件 责任
packages/backend/tools/win32/extract-weixin-key.ps1 一次性 PowerShell:OpenProcess + 抽 96-char hex literal,按 dbFile→salt 反向匹配,JSON stdout
packages/backend/src/modules/netaclaw/runtime/weixin_db/wcdb_codec.ts WCDB 解密公式(直接搬 poc-7f-reserve80.mjs):派 hmacKey、解密单 page、解密整 DB
packages/backend/src/modules/netaclaw/runtime/weixin_db/db_paths.ts xwechat_files/<seed>/db_storage/ 各 DB 路径;AutoFindSeedDir()
packages/backend/src/modules/netaclaw/runtime/weixin_db/key_extractor.ts spawn ps1 → parse JSON → 返回 {dbFile: rawKey32B};失败兜底
packages/backend/src/modules/netaclaw/runtime/weixin_db/message_repo.ts better-sqlite3 readonly:listMsgTables() / maxCreateTime(t) / listSince(t, ts)
packages/backend/src/modules/netaclaw/runtime/weixin_db/room_resolver.ts Msg_<sha> 表名映射到群名;从 session.db 反查;白名单匹配判断
packages/backend/src/modules/netaclaw/runtime/weixin_db/zstd_decode.ts 包装 @mongodb-js/zstd:28 b5 2f fd 魔数识别 + 解压 → UTF-8
packages/backend/src/modules/netaclaw/runtime/weixin_db/wal_watcher.ts setInterval(500ms) 轮询 wal mtime,变化触发 callback
packages/backend/src/modules/netaclaw/runtime/weixin_db/incremental_reader.ts 整合:解密整 DB → 白名单表过滤 → SELECT WHERE create_time > lastTs → zstd 解 → 输出 row
packages/backend/src/modules/netaclaw/runtime/weixin_db/types.ts WeixinDbInboundRow(Msg 表一行 + roomName + senderWxid 已解析)、PseudoMessage 投影类型
packages/backend/src/modules/netaclaw/runtime/weixin_db/build_pseudo.ts buildPseudoMessageFromDb(row, channelId) 纯函数
packages/backend/src/modules/netaclaw/service/weixin_db.ts 主服务:@Init 启动钩子(抽 key → 启 watcher);replyToGroup(占位 throw NotImplemented)
packages/backend/test/modules/netaclaw/runtime/weixin_db/wcdb_codec.test.ts 已知向量验证派生 hmacKey 算法
packages/backend/test/modules/netaclaw/runtime/weixin_db/room_resolver.test.ts 白名单匹配 + roomName 解析
packages/backend/test/modules/netaclaw/runtime/weixin_db/build_pseudo.test.ts row → PseudoMessage 投影
packages/backend/test/modules/netaclaw/service/weixin_db.test.ts 启动钩子 mock ps1 + watcher 启停
packages/backend/test/modules/netaclaw/service/agent_channel.weixin_db.test.ts routeInboundMessage 走 weixin-db 分支

修改

文件 改动
packages/backend/src/modules/netaclaw/service/agent_channel.ts weixin-uia → weixin-db 全替换;新增 handleDbInbound(99% 复用 handleIlinkInbound,只在 sendText 处改调 weixinDbService.replyToGroup + try/catch NotImplementedError)
packages/backend/src/modules/netaclaw/service/agent_channel_group.ts upsertOnInbound;新增 addByName(channelId, roomName, triggerMode),UNIQUE(channelId, roomName) 校验;status 默认 1
packages/backend/src/modules/netaclaw/controller/admin/agent_channel_group.ts /upsertOnInbound endpoint;新增 /add;/toggle 仅接受 status ∈ {0,1};删除 -1 相关分支
packages/backend/package.json @mongodb-js/zstd 依赖
packages/backend/scripts/build-windows-installer.js 删除 dotnet publish bridge 步骤 + bridgeOutputDir 相关引用;新增"拷贝 tools/win32/*.ps1 到 dist 包"步骤
packages/backend/installer/setup.iss 删除 bridge.exe 项;新增 tools\*.ps1 文件项
packages/windows-tray/Neta.Tray/TrayApplicationContext.cs 删除 BridgeProcessManager 调用、bridge 菜单项、StartBridgeIfNeededAsync 整段;Tray 只负责 backend.exe
packages/frontend/src/modules/agent/types/index.d.ts type 改 'weixin' | 'weixin-db';AgentGroupItem.status0 | 1
packages/frontend/src/modules/agent/views/channel-management.vue type 下拉文案改"微信本地代理(群聊 · 需 Windows + PC 微信)";所有 UIA 字样去除
packages/frontend/src/modules/agent/components/channel-group-panel.vue 重构 UX:删"待审批横幅"+"忽略"按钮;新增"+ 添加群"按钮 + 添加弹窗 + 群卡片(启用 toggle / 触发策略 / 删除)
packages/frontend/src/modules/agent/composables/useUiaChannelValidation.ts 改名 useDbChannelValidation.ts

删除

文件 理由
packages/backend/src/modules/netaclaw/service/weixin_uia.ts UIA 作废
packages/backend/src/modules/netaclaw/service/wechat_archive.ts 归档作废
packages/backend/src/modules/netaclaw/controller/open/weixin_uia.ts bridge HTTP 入口作废(架构 C 无 bridge)
packages/backend/src/modules/netaclaw/controller/admin/wechat_archive.ts 归档作废
packages/backend/src/modules/netaclaw/runtime/wechat_uia_routing.ts UIA 作废
packages/backend/src/modules/netaclaw/runtime/wechat_archive_schema.ts 归档作废
packages/backend/test/modules/netaclaw/service/weixin_uia.test.ts 对应 service 已删
packages/backend/test/modules/netaclaw/service/wechat_archive.test.ts
packages/backend/test/modules/netaclaw/service/agent_channel.uia.test.ts agent_channel.weixin_db.test.ts 替代
packages/backend/test/modules/netaclaw/controller/open_weixin_uia.test.ts
packages/backend/test/modules/netaclaw/controller/admin_wechat_archive.test.ts
packages/backend/test/modules/netaclaw/runtime/wechat_uia_routing.test.ts
packages/backend/src/comm/path.tspWechatUploadsPath 函数 不再需要
packages/backend/src/config/config.default.ts 的 staticFile.wechatUploads 不再需要
packages/frontend/src/modules/agent/components/wechat-archive-panel.vue 归档面板作废
packages/windows-tray/Neta.WeChatBridge/ 整个目录 架构 C 不需要独立 bridge 进程
packages/windows-tray/Neta.WeChatBridge.Tests/ 整个目录 同上
packages/windows-tray/Neta.Tray/BridgeProcessManager.cs 不再拉 bridge
packages/windows-tray/Neta.Tray/BridgeHealthPoller.cs 同上
packages/windows-tray/Neta.Tray.Tests/BridgeProcessManagerTests.cs 对应类已删

Phase C-0 · session.db schema PoC(必须先做)

为什么第一步做这个:plan Task 12 的 room_resolver.loadFromSessionDb 实现依赖 session.db 的表名/字段名;上面 plan 里的 SQL 是假设版。若实际 schema 不同,Task 12/14/15/16 整条链路会连锁改动。 先用 PoC 脚本(手工,20 行)把真实 schema 摸清楚,RE 完再写实施代码。 产出:一份 PoC 结果文档,确认:(a) 存群/DM session 的表名;(b) 哪些列存 username (wxid 或 wxid@chatroom);(c) 哪些列存显示名(群名);(d) 确认 Msg_<sha> 表名的 sha 是 md5(username) 还是别的算法。

Task C0-1: PoC 脚本探 session.db schema

Files:

  • Create: packages/backend/poc/weixin-4x/poc-13-session-schema.mjs
  • Create: packages/backend/poc/weixin-4x/README-session-schema.md(记录发现)

前置: backend/poc/weixin-4x/ 下应已有 message_0_decrypted.db(Phase 5 解密 PoC 输出);同目录下类似方法解密 session.dbsession_decrypted.db

  • Step 1: 先拿到 session.db 的 raw key

Run: 已知 session.db 的 raw key 在 poc-8-collect-db-keys.mjs 输出里。重跑一次,确认当前 seedDir 下 session.db 的 key。

  • Step 2: 解密 session.db 到明文

复用 poc-10-decrypt-and-read.mjs 的 decrypt 逻辑,改 SRC 为 session.db,OUT 为 session_decrypted.db

  • Step 3: 写 session schema PoC 脚本

写入 poc-13-session-schema.mjs:

import { DatabaseSync } from 'node:sqlite';
import { createHash } from 'node:crypto';

const db = new DatabaseSync('./session_decrypted.db', { readOnly: true });

console.log('=== 所有表 ===');
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all();
for (const t of tables) console.log(' -', t.name);

// 对每个表打 schema + sample 3 行
for (const t of tables) {
  console.log(`\n=== ${t.name} schema ===`);
  const cols = db.prepare(`PRAGMA table_info("${t.name}")`).all();
  for (const c of cols) console.log(`  ${c.name} ${c.type}`);
  console.log(`--- sample 3 rows ---`);
  try {
    const rows = db.prepare(`SELECT * FROM "${t.name}" LIMIT 3`).all();
    for (const r of rows) console.log('   ', JSON.stringify(r).slice(0, 300));
  } catch (e) { console.log('   error:', e.message); }
}

// 验证 Msg 表名 hash 算法: 从 message_0_decrypted.db 取一个 Msg_<sha> 表,
// 从 session_decrypted.db 找对应 username, 验证 md5(username) === sha
console.log('\n=== Msg 表名 hash 算法验证 ===');
const msgDb = new DatabaseSync('./message_0_decrypted.db', { readOnly: true });
const msgTables = msgDb.prepare(
  "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg\\_%' ESCAPE '\\' LIMIT 5"
).all();
msgDb.close();

// 尝试: 对 session 各表里的 "username" 字段, md5 后看是否匹配 Msg 表名
// 这里候选字段名: username / user_name / chat_id / room_id / wxid
// 需要看第一步输出后再确定具体字段名
for (const { name: msgTable } of msgTables) {
  const sha = msgTable.slice(4); // 去 "Msg_"
  console.log(`\n${msgTable} → sha=${sha}`);
  // 示例(字段名需替换为实际发现):
  // const candidates = db.prepare(`SELECT username FROM session_table`).all();
  // for (const c of candidates) {
  //   const m = createHash('md5').update(c.username, 'utf8').digest('hex');
  //   if (m === sha) { console.log('  ✅ matched username=', c.username); break; }
  // }
}

db.close();
  • Step 4: 跑 PoC + 观察 + 记录
cd packages/backend/poc/weixin-4x
node poc-13-session-schema.mjs 2>&1 | tee session-schema-output.log

看输出:

  • 哪些表(通常有 session_table / Session / ContactSessionTable 之类)

  • username 列叫什么名(username / user_name / nick / strUsrName)

  • 群名/显示名列叫什么(display_name / nickname / conversation_name)

  • md5(username) 是否等于 Msg_ 表名后缀

  • Step 5: 写 README-session-schema.md

把发现记录下来:

# session.db schema RE 结果(2026-05-12)

## 实际表清单
- `session_table`: ...
- `session_attachment`: ...

## 主 session 表字段
| 字段名 | 类型 | 含义 |
|---|---|---|
| username | TEXT | wxid 或 wxid@chatroom |
| display_name | TEXT | 用户/群显示名 |
| ... | ... | ... |

## Msg 表名 hash 验证
- md5("wxid_alice") === "xxxxx"
- Msg_xxxxx 表存在 → ✅ 验证通过

## SQL 查询模板(供 room_resolver.ts 使用)
SELECT username, display_name FROM <实际表名>
  • Step 6: Commit
git add packages/backend/poc/weixin-4x/poc-13-session-schema.mjs \
        packages/backend/poc/weixin-4x/README-session-schema.md \
        packages/backend/poc/weixin-4x/session-schema-output.log
git commit -m "docs(poc): session.db schema RE — 确认 Msg 表名 hash 算法 + 字段名"

Phase C-1 · 清理作废代码

先清干净 UIA + bridge .NET + archive 三块。 删除完成后 type-check / build 会红,Phase C-2 起逐步修复;不写临时占位让编译先过。

Task 1: 删除后端 UIA + archive 源码 + 测试

Files:

  • Delete: packages/backend/src/modules/netaclaw/service/weixin_uia.ts

  • Delete: packages/backend/src/modules/netaclaw/service/wechat_archive.ts

  • Delete: packages/backend/src/modules/netaclaw/controller/open/weixin_uia.ts

  • Delete: packages/backend/src/modules/netaclaw/controller/admin/wechat_archive.ts

  • Delete: packages/backend/src/modules/netaclaw/runtime/wechat_uia_routing.ts

  • Delete: packages/backend/src/modules/netaclaw/runtime/wechat_archive_schema.ts

  • Delete: packages/backend/test/modules/netaclaw/service/weixin_uia.test.ts

  • Delete: packages/backend/test/modules/netaclaw/service/wechat_archive.test.ts

  • Delete: packages/backend/test/modules/netaclaw/service/agent_channel.uia.test.ts

  • Delete: packages/backend/test/modules/netaclaw/controller/open_weixin_uia.test.ts

  • Delete: packages/backend/test/modules/netaclaw/controller/admin_wechat_archive.test.ts

  • Delete: packages/backend/test/modules/netaclaw/runtime/wechat_uia_routing.test.ts

  • Step 1: 批量删除

cd packages/backend
rm -f src/modules/netaclaw/service/weixin_uia.ts
rm -f src/modules/netaclaw/service/wechat_archive.ts
rm -f src/modules/netaclaw/controller/open/weixin_uia.ts
rm -f src/modules/netaclaw/controller/admin/wechat_archive.ts
rm -f src/modules/netaclaw/runtime/wechat_uia_routing.ts
rm -f src/modules/netaclaw/runtime/wechat_archive_schema.ts
rm -f test/modules/netaclaw/service/weixin_uia.test.ts
rm -f test/modules/netaclaw/service/wechat_archive.test.ts
rm -f test/modules/netaclaw/service/agent_channel.uia.test.ts
rm -f test/modules/netaclaw/controller/open_weixin_uia.test.ts
rm -f test/modules/netaclaw/controller/admin_wechat_archive.test.ts
rm -f test/modules/netaclaw/runtime/wechat_uia_routing.test.ts
  • Step 2: 验证

Run: find packages/backend -name "*uia*" -o -name "*archive*" 2>&1 | head Expected: 输出为空

  • Step 3: Commit
git add -A
git commit -m "chore(netaclaw): 删除 UIA / wechat_archive 后端源码 + 测试 (架构 C 作废)"

Task 2: 删除整个 Neta.WeChatBridge .NET 项目

Files:

  • Delete: packages/windows-tray/Neta.WeChatBridge/ 整个目录

  • Delete: packages/windows-tray/Neta.WeChatBridge.Tests/ 整个目录

  • Step 1: 删除两个 .NET 项目目录

cd packages/windows-tray
rm -rf Neta.WeChatBridge
rm -rf Neta.WeChatBridge.Tests
  • Step 2: 检查根目录有无 .sln 引用要清

Run: find packages/windows-tray -maxdepth 2 -name "*.sln" -exec grep -l "WeChatBridge" {} \; 若有命中,从 sln 删除对应 ProjectSection 段(手动编辑或用 dotnet sln remove)。

  • Step 3: Commit
git add -A
git commit -m "chore(bridge): 删除 Neta.WeChatBridge 整个 .NET 项目 (架构 C 不需要独立 bridge)"

Task 3: 清理 Tray 中的 bridge 拉起逻辑

Files:

  • Delete: packages/windows-tray/Neta.Tray/BridgeProcessManager.cs

  • Delete: packages/windows-tray/Neta.Tray/BridgeHealthPoller.cs

  • Delete: packages/windows-tray/Neta.Tray.Tests/BridgeProcessManagerTests.cs(若存在)

  • Modify: packages/windows-tray/Neta.Tray/TrayApplicationContext.cs(删除 bridge 拉起 + 菜单)

  • Step 1: 删除 BridgeProcessManager + BridgeHealthPoller

cd packages/windows-tray
rm -f Neta.Tray/BridgeProcessManager.cs
rm -f Neta.Tray/BridgeHealthPoller.cs
rm -f Neta.Tray.Tests/BridgeProcessManagerTests.cs
  • Step 2: 修 TrayApplicationContext.cs

打开文件,删除:

  • 顶部 using 或字段引用 BridgeProcessManager / BridgeHealthPoller 的行

  • 构造函数里的 _bridgeManager 注入

  • _bridgeProcess_bridgePort 字段

  • StartBridgeIfNeededAsync 整个方法

  • RestartBridgeAsync / ShowBridgeStatus / OpenBridgeLogs 方法

  • 菜单初始化里"微信桥接"子菜单的 4 个 ToolStripMenuItem

  • EnsureBackendAttachedAsync 末尾的 await StartBridgeIfNeededAsync(); 调用

  • ExitAllAsync 里 kill bridge 进程的代码

  • Step 3: 跑 Tray 单测确保仍能编译

Run: dotnet test packages/windows-tray/Neta.Tray.Tests 2>&1 | tail -10 Expected: Build succeeded;若 BridgeProcessManagerTests 已删,其他测试全 pass

  • Step 4: Commit
git add -A
git commit -m "chore(tray): 删除 bridge 拉起逻辑 (架构 C 不需要 bridge.exe)"

Task 4: 清理 backend config 的 wechat-uploads 静态映射 + path 辅助

Files:

  • Modify: packages/backend/src/config/config.default.ts

  • Modify: packages/backend/src/comm/path.ts

  • Step 1: 删除 config 中 wechatUploads 项

打开 config.default.ts,在 staticFile.dirs 对象删除:

      wechatUploads: {
        prefix: '/wechat-uploads',
        dir: pWechatUploadsPath(),
      },

顶部 import 改回:

import { pCachePath, pDataPath, pUploadPath, pWorkspacePath } from '../comm/path';
  • Step 2: 删除 path.ts 中 pWechatUploadsPath 函数

打开 path.ts,删除整段:

export const pWechatUploadsPath = () => {
  const wechatUploadsPath = path.join(pDataPath(), 'wechat-uploads');
  if (!fs.existsSync(wechatUploadsPath)) fs.mkdirSync(wechatUploadsPath, { recursive: true });
  return wechatUploadsPath;
};
  • Step 3: 验证 + Commit

Run: grep -rn "wechat-uploads\|pWechatUploadsPath" packages/backend/src/ | head Expected: 空

git add -A
git commit -m "chore(netaclaw): 移除 /wechat-uploads 静态映射 (架构 C 不需要)"

Task 5: 删除前端 wechat-archive-panel.vue + 引用

Files:

  • Delete: packages/frontend/src/modules/agent/components/wechat-archive-panel.vue

  • Modify: packages/frontend/src/modules/agent/components/channel-group-panel.vue(删 archive 引用)

  • Step 1: 删除文件

rm -f packages/frontend/src/modules/agent/components/wechat-archive-panel.vue
  • Step 2: 修 channel-group-panel.vue

Run: grep -n "wechat-archive\|WechatArchive\|archivePanel\|openArchive" packages/frontend/src/modules/agent/components/channel-group-panel.vue

把命中的 import / template / openArchive 函数 / "查看归档" 按钮全部删除。

  • Step 3: 跑 type-check 确认无遗留

Run: cd packages/frontend && pnpm type-check 2>&1 | grep -i "wechat-archive" | head Expected: 空

  • Step 4: Commit
git add -A
git commit -m "chore(agent-fe): 删除 wechat-archive-panel 组件及引用"

Phase C-2 · runtime/weixin_db/ 纯函数模块(TDD)

本 Phase 把已验证的 PoC 代码翻译成 production module,全部 TDD。每个模块独立可测,不依赖 DB / 微信进程。

Task 6: 加 @mongodb-js/zstd 依赖

Files:

  • Modify: packages/backend/package.json

  • Step 1: 安装依赖

cd packages/backend
pnpm add @mongodb-js/zstd@^2.0.0
  • Step 2: 验证版本锁到 package.json

Run: grep "mongodb-js/zstd" packages/backend/package.json Expected: 出现依赖项

  • Step 3: Commit
git add packages/backend/package.json packages/backend/pnpm-lock.yaml ../../pnpm-lock.yaml 2>/dev/null
git commit -m "build(backend): 加 @mongodb-js/zstd 依赖 (weixin-db message_content 解压)"

Task 7: wcdb_codec.ts — WCDB 解密核心(TDD)

Files:

  • Create: packages/backend/src/modules/netaclaw/runtime/weixin_db/wcdb_codec.ts
  • Test: packages/backend/test/modules/netaclaw/runtime/weixin_db/wcdb_codec.test.ts

核心算法来自 packages/backend/poc/weixin-4x/poc-7f-reserve80.mjs,已通过已知向量验证。

  • Step 1: 写失败测试

写入 packages/backend/test/modules/netaclaw/runtime/weixin_db/wcdb_codec.test.ts:

import {
  PAGE_SIZE,
  RESERVED_SIZE,
  IV_SIZE,
  HMAC_SIZE,
  deriveHmacKey,
} from '../../../../../src/modules/netaclaw/runtime/weixin_db/wcdb_codec.js';

describe('wcdb_codec constants', () => {
  it('matches WCDB cipher_compatibility=4 defaults', () => {
    expect(PAGE_SIZE).toBe(4096);
    expect(RESERVED_SIZE).toBe(80);
    expect(IV_SIZE).toBe(16);
    expect(HMAC_SIZE).toBe(64);
  });
});

describe('deriveHmacKey', () => {
  // 已知向量(来自 poc-7f-reserve80.mjs 实测通过):
  // rawKey = 374c4e1a... / salt = 91f3c314...
  // 期望: hmacKey = 2266b72430201a5b412e227461d7d5a596b7addc57ba9ddde9e4becbdff08e01
  it('derives hmacKey via PBKDF2-SHA512 with salt XOR 0x3a, 2 rounds', () => {
    const rawKey = Buffer.from(
      '374c4e1a2da5a7bee6e0de020a1ad24e9234c9db709e93e4c01dd1eda9e40b50', 'hex');
    const salt = Buffer.from('91f3c3147a62cd0f9d1b96e8f50004f1', 'hex');
    const hmacKey = deriveHmacKey(rawKey, salt);
    expect(hmacKey.toString('hex'))
      .toBe('2266b72430201a5b412e227461d7d5a596b7addc57ba9ddde9e4becbdff08e01');
  });
});
  • Step 2: 跑测试确认失败

Run: pnpm --filter @neta/backend test -- runtime/weixin_db/wcdb_codec 2>&1 | tail -10 Expected: FAIL(module 不存在)

  • Step 3: 写实现

写入 packages/backend/src/modules/netaclaw/runtime/weixin_db/wcdb_codec.ts:

import { createDecipheriv, createHmac, pbkdf2Sync } from 'node:crypto';

/**
 * WCDB (Tencent SQLCipher fork) cipher_compatibility=4 解密。
 * 已通过 packages/backend/poc/weixin-4x/poc-7f-reserve80.mjs 实测验证。
 *
 * 关键参数:
 *   PageSize     = 4096
 *   ReservedSize = 80 (IV 16 + HMAC-SHA512 64)
 *   encKey       = raw 32B (不再派生,直接作为 AES key)
 *   hmacKey      = PBKDF2-HMAC-SHA512(rawKey, salt XOR 0x3a, 2 rounds, 32B)
 */
export const PAGE_SIZE = 4096;
export const RESERVED_SIZE = 80;
export const IV_SIZE = 16;
export const HMAC_SIZE = 64;
export const KEY_SIZE = 32;
export const HMAC_SALT_MASK = 0x3a;

/** 派生 HMAC 密钥:salt XOR 0x3a 后 PBKDF2-SHA512 2 轮,输出 32B。 */
export function deriveHmacKey(rawKey: Buffer, salt: Buffer): Buffer {
  if (rawKey.length !== KEY_SIZE) throw new Error('rawKey must be 32B');
  if (salt.length !== IV_SIZE) throw new Error('salt must be 16B');
  const hmacSalt = Buffer.from(salt);
  for (let i = 0; i < hmacSalt.length; i++) hmacSalt[i] ^= HMAC_SALT_MASK;
  return pbkdf2Sync(rawKey, hmacSalt, 2, KEY_SIZE, 'sha512');
}

/**
 * 解密单 page。pageBuf.length 必须 === PAGE_SIZE。
 * HMAC 验证失败时返回 null(全零 page 也会 fail,调用方按需跳过)。
 */
export function decryptPage(
  pageBuf: Buffer,
  pageNum: number,
  rawKey: Buffer,
  hmacKey: Buffer,
): Buffer | null {
  if (pageBuf.length !== PAGE_SIZE) throw new Error('page must be 4096B');
  const encStart = pageNum === 1 ? 16 : 0;
  const encEnd = PAGE_SIZE - RESERVED_SIZE;
  const ciphertext = pageBuf.subarray(encStart, encEnd);
  const iv = pageBuf.subarray(encEnd, encEnd + IV_SIZE);
  const storedHmac = pageBuf.subarray(encEnd + IV_SIZE, encEnd + IV_SIZE + HMAC_SIZE);

  // HMAC 验证: HMAC-SHA512(hmacKey, ciphertext || iv || pageNumLE_u32)
  const pgLE = Buffer.alloc(4);
  pgLE.writeUInt32LE(pageNum, 0);
  const h = createHmac('sha512', hmacKey);
  h.update(ciphertext);
  h.update(iv);
  h.update(pgLE);
  const computed = h.digest();
  if (!computed.equals(storedHmac)) return null;

  // AES-256-CBC
  const dec = createDecipheriv('aes-256-cbc', rawKey, iv);
  dec.setAutoPadding(false);
  const plaintext = Buffer.concat([dec.update(ciphertext), dec.final()]);

  // 组装输出 page
  const result = Buffer.alloc(PAGE_SIZE);
  if (pageNum === 1) {
    Buffer.from('SQLite format 3\0', 'utf8').copy(result, 0);
  }
  plaintext.copy(result, encStart);
  pageBuf.subarray(encEnd).copy(result, encEnd);
  return result;
}

/**
 * 解密整个 DB 文件 buffer。输出明文 SQLite DB。
 * 全零 / HMAC 失败的 page 保留 0(未使用 page 正常情况)。
 */
export function decryptDatabase(dbBuf: Buffer, rawKey: Buffer): Buffer {
  if (dbBuf.length < PAGE_SIZE || dbBuf.length % PAGE_SIZE !== 0) {
    throw new Error(`db size invalid: ${dbBuf.length}`);
  }
  const salt = dbBuf.subarray(0, 16);
  const hmacKey = deriveHmacKey(rawKey, salt);
  const total = dbBuf.length / PAGE_SIZE;
  const out = Buffer.alloc(dbBuf.length);
  let okPages = 0;
  for (let n = 1; n <= total; n++) {
    const start = (n - 1) * PAGE_SIZE;
    const page = dbBuf.subarray(start, start + PAGE_SIZE);
    const decrypted = decryptPage(page, n, rawKey, hmacKey);
    if (decrypted) {
      decrypted.copy(out, start);
      okPages++;
    }
  }
  if (okPages === 0) throw new Error('No page decrypted — wrong key?');
  return out;
}
  • Step 4: 跑测试通过

Run: pnpm --filter @neta/backend test -- runtime/weixin_db/wcdb_codec 2>&1 | tail -10 Expected: 2 passed

  • Step 5: Commit
git add -A
git commit -m "feat(weixin-db): wcdb_codec.ts WCDB 解密 (SQLCipher 4 公式, 已知向量通过)"

Task 8: zstd_decode.ts — 包装 @mongodb-js/zstd

Files:

  • Create: packages/backend/src/modules/netaclaw/runtime/weixin_db/zstd_decode.ts

  • Test: packages/backend/test/modules/netaclaw/runtime/weixin_db/zstd_decode.test.ts

  • Step 1: 写测试

写入 test/.../zstd_decode.test.ts:

import { compress } from '@mongodb-js/zstd';
import { tryDecompressToString } from '../../../../../src/modules/netaclaw/runtime/weixin_db/zstd_decode.js';

describe('tryDecompressToString', () => {
  it('decompresses zstd bytes with magic to UTF-8', async () => {
    const original = '你好 WCDB 群消息';
    const compressed = await compress(Buffer.from(original, 'utf8'));
    expect(compressed[0]).toBe(0x28);
    expect(compressed[1]).toBe(0xb5);
    const result = tryDecompressToString(compressed);
    expect(result).toBe(original);
  });

  it('passes through plain UTF-8 bytes without magic', () => {
    const buf = Buffer.from('plain text', 'utf8');
    expect(tryDecompressToString(buf)).toBe('plain text');
  });

  it('returns empty string for null/empty input', () => {
    expect(tryDecompressToString(null as any)).toBe('');
    expect(tryDecompressToString(Buffer.alloc(0))).toBe('');
  });
});
  • Step 2: 写实现

写入 src/.../zstd_decode.ts:

import { decompress } from '@mongodb-js/zstd';

/**
 * 若 buf 以 zstd 魔数 28 b5 2f fd 起始,解压后按 UTF-8 解码;否则直接当 UTF-8。
 * 失败返回兜底标识。注意 @mongodb-js/zstd.decompress 是 async,本函数内部用 sync wrapper —
 * 实际场景下 message_content 较小,spawn decompress 太重。改用已同步 API:
 *   @mongodb-js/zstd@2 提供 decompressSync? 查文档。若无,改为 async 版本 `decompressSyncOrThrow`。
 * v1 假设有同步 API;若无则下一步改 Task 8 签名为 async。
 */
export function hasZstdMagic(buf: Buffer): boolean {
  return buf.length >= 4
    && buf[0] === 0x28 && buf[1] === 0xb5 && buf[2] === 0x2f && buf[3] === 0xfd;
}

export function tryDecompressToString(buf: Buffer | null | undefined): string {
  if (!buf || buf.length === 0) return '';
  if (hasZstdMagic(buf)) {
    try {
      // @mongodb-js/zstd v2 async API; v1 plan 假设用 decompressSync 若存在
      // 若只有 async,Task 8.1 Follow up 将函数改为 async
      const output = (decompress as any).sync?.(buf) ?? syncCompatWrapper(buf);
      return output.toString('utf8');
    } catch {
      return `<zstd-decode-failed ${buf.length}B>`;
    }
  }
  return buf.toString('utf8');
}

// 若 @mongodb-js/zstd 无同步 API,用 deasync 或 spawn child 解;先占位 throw
function syncCompatWrapper(_buf: Buffer): Buffer {
  throw new Error('zstd sync API not available — upgrade to decompressSync or make callers async');
}

注意: @mongodb-js/zstd v2 目前只有 async API。若 Task 8 跑测试时 syncCompatWrapper 抛错,把 tryDecompressToString 改为 async + 返回 Promise<string>,同时调整调用方。Follow-up 任务 Task 8.1 专门处理同步/异步不匹配

  • Step 3: 跑测试

Run: pnpm --filter @neta/backend test -- runtime/weixin_db/zstd_decode 2>&1 | tail -10 Expected: 3 passed 或测试暴露 async 问题 → 进入 Task 8.1

  • Step 4: Commit
git add -A
git commit -m "feat(weixin-db): zstd_decode.ts 解 message_content zstd 压缩"

Task 8.1(若需):把 tryDecompressToString 改为 async

只在 Task 8 测试失败时执行。

  • Step 1: 改签名
export async function tryDecompressToString(buf: Buffer | null | undefined): Promise<string> {
  if (!buf || buf.length === 0) return '';
  if (hasZstdMagic(buf)) {
    try { return (await decompress(buf)).toString('utf8'); }
    catch { return `<zstd-decode-failed ${buf.length}B>`; }
  }
  return buf.toString('utf8');
}
  • Step 2: 测试也改 await,确认通过

  • Step 3: Commit

git add -A
git commit -m "fix(weixin-db): zstd_decode 改 async (@mongodb-js/zstd v2 只有 async API)"

Task 9: db_paths.ts — xwechat_files 目录解析

Files:

  • Create: packages/backend/src/modules/netaclaw/runtime/weixin_db/db_paths.ts

  • Test: packages/backend/test/modules/netaclaw/runtime/weixin_db/db_paths.test.ts

  • Step 1: 写测试

import { DbPaths } from '../../../../../src/modules/netaclaw/runtime/weixin_db/db_paths.js';

describe('DbPaths', () => {
  it('resolves paths under seed dir', () => {
    const p = new DbPaths('C:/seed');
    expect(p.messageDb.replace(/\\/g, '/')).toBe('C:/seed/db_storage/message/message_0.db');
    expect(p.contactDb.replace(/\\/g, '/')).toBe('C:/seed/db_storage/contact/contact.db');
    expect(p.sessionDb.replace(/\\/g, '/')).toBe('C:/seed/db_storage/session/session.db');
  });

  it('allDbs returns all 17 DB paths', () => {
    const p = new DbPaths('C:/seed');
    expect(p.allDbs().length).toBeGreaterThanOrEqual(15);
  });
});
  • Step 2: 写实现
import * as path from 'node:path';
import * as fs from 'node:fs';

export class DbPaths {
  readonly messageDb: string;
  readonly bizMessageDb: string;
  readonly mediaDb: string;
  readonly messageFtsDb: string;
  readonly messageResourceDb: string;
  readonly contactDb: string;
  readonly contactFtsDb: string;
  readonly sessionDb: string;
  readonly generalDb: string;
  readonly favoriteDb: string;
  readonly favoriteFtsDb: string;
  readonly hardlinkDb: string;
  readonly headImageDb: string;
  readonly emoticonDb: string;
  readonly bizChatDb: string;
  readonly snsDb: string;
  readonly solitaireDb: string;

  constructor(public readonly seedDir: string) {
    const d = (sub: string) => path.join(seedDir, 'db_storage', sub);
    this.messageDb         = d(path.join('message', 'message_0.db'));
    this.bizMessageDb      = d(path.join('message', 'biz_message_0.db'));
    this.mediaDb           = d(path.join('message', 'media_0.db'));
    this.messageFtsDb      = d(path.join('message', 'message_fts.db'));
    this.messageResourceDb = d(path.join('message', 'message_resource.db'));
    this.contactDb         = d(path.join('contact', 'contact.db'));
    this.contactFtsDb      = d(path.join('contact', 'contact_fts.db'));
    this.sessionDb         = d(path.join('session', 'session.db'));
    this.generalDb         = d(path.join('general', 'general.db'));
    this.favoriteDb        = d(path.join('favorite', 'favorite.db'));
    this.favoriteFtsDb     = d(path.join('favorite', 'favorite_fts.db'));
    this.hardlinkDb        = d(path.join('hardlink', 'hardlink.db'));
    this.headImageDb       = d(path.join('head_image', 'head_image.db'));
    this.emoticonDb        = d(path.join('emoticon', 'emoticon.db'));
    this.bizChatDb         = d(path.join('bizchat', 'bizchat.db'));
    this.snsDb             = d(path.join('sns', 'sns.db'));
    this.solitaireDb       = d(path.join('solitaire', 'solitaire.db'));
  }

  allDbs(): string[] {
    return [
      this.messageDb, this.bizMessageDb, this.mediaDb, this.messageFtsDb, this.messageResourceDb,
      this.contactDb, this.contactFtsDb, this.sessionDb, this.generalDb, this.favoriteDb,
      this.favoriteFtsDb, this.hardlinkDb, this.headImageDb, this.emoticonDb, this.bizChatDb,
      this.snsDb, this.solitaireDb,
    ];
  }

  /** 读 DB 文件前 16B 作为 salt。 */
  static readSalt(dbPath: string): Buffer | null {
    if (!fs.existsSync(dbPath)) return null;
    const fd = fs.openSync(dbPath, 'r');
    try {
      const buf = Buffer.alloc(16);
      if (fs.readSync(fd, buf, 0, 16, 0) !== 16) return null;
      return buf;
    } finally {
      fs.closeSync(fd);
    }
  }

  /** 自动找用户 Documents/xwechat_files/ 下最近修改的 seed 目录 (排除 all_users / Backup)。 */
  static autoFindSeedDir(): string | null {
    const home = process.env.USERPROFILE || process.env.HOME || '';
    const root = path.join(home, 'Documents', 'xwechat_files');
    if (!fs.existsSync(root)) return null;
    const entries = fs.readdirSync(root, { withFileTypes: true })
      .filter(d => d.isDirectory())
      .map(d => d.name)
      .filter(n => n !== 'all_users' && n !== 'Backup');
    if (entries.length === 0) return null;
    const withMtime = entries.map(name => {
      const p = path.join(root, name);
      return { name, p, mtime: fs.statSync(p).mtimeMs };
    }).sort((a, b) => b.mtime - a.mtime);
    return withMtime[0].p;
  }
}
  • Step 3: 跑测试 + Commit
pnpm --filter @neta/backend test -- runtime/weixin_db/db_paths 2>&1 | tail -10
git add -A
git commit -m "feat(weixin-db): db_paths.ts 解析 xwechat_files seed 目录各 DB 路径"

Task 10: extract-weixin-key.ps1 + key_extractor.ts

Files:

  • Create: packages/backend/tools/win32/extract-weixin-key.ps1
  • Create: packages/backend/src/modules/netaclaw/runtime/weixin_db/key_extractor.ts

PowerShell 脚本直接移植自 packages/backend/poc/weixin-4x/poc-6a-memdump.ps1 + poc-8-collect-db-keys.mjs 的合体(dump + 匹配一步完成,JSON 输出)。

  • Step 1: 写 PowerShell 脚本

写入 packages/backend/tools/win32/extract-weixin-key.ps1:

# extract-weixin-key.ps1
# 输入参数: -SeedDir <path>  (可选, 默认自动找 Documents/xwechat_files/ 最近目录)
# 输出 (stdout): JSON { wxid: ..., dbKeys: { "message_0.db": "<hex>", ... } }
# 退出码: 0 OK / 1 无 Weixin 进程 / 2 seed 目录不存在 / 3 内存读取失败

param([string]$SeedDir = "")

$ErrorActionPreference = 'Stop'

# 1. 找 Weixin 主进程
$weixin = Get-Process Weixin -ErrorAction SilentlyContinue | ? MainWindowHandle -ne 0 | Select -First 1
if (-not $weixin) { Write-Error "No Weixin process"; exit 1 }

# 2. 确定 seed 目录
if (-not $SeedDir) {
    $root = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'xwechat_files'
    if (-not (Test-Path $root)) { Write-Error "xwechat_files not found"; exit 2 }
    $SeedDir = Get-ChildItem $root -Directory | ? { $_.Name -ne 'all_users' -and $_.Name -ne 'Backup' } |
        Sort-Object LastWriteTime -Descending | Select -First 1 -Expand FullName
}

# 3. 收集各 DB 的 salt (前 16B)
$dbStorage = Join-Path $SeedDir 'db_storage'
$dbFiles = Get-ChildItem $dbStorage -Recurse -Filter '*.db' -File |
    Where-Object { $_.Name -notmatch '-wal$|-shm$' }
$saltMap = @{}
foreach ($f in $dbFiles) {
    $bytes = New-Object byte[] 16
    $fs = [System.IO.File]::OpenRead($f.FullName)
    try { $fs.Read($bytes, 0, 16) | Out-Null } finally { $fs.Close() }
    $saltMap[$f.Name] = [BitConverter]::ToString($bytes).Replace('-', '').ToLowerInvariant()
}

# 4. P/Invoke — 枚举 + 读内存
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class W {
    [StructLayout(LayoutKind.Sequential)] public struct MBI {
        public IntPtr BaseAddress; public IntPtr AllocationBase;
        public uint AllocationProtect; public IntPtr RegionSize;
        public uint State; public uint Protect; public uint Type;
    }
    [DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr OpenProcess(uint a, bool i, int p);
    [DllImport("kernel32.dll", SetLastError=true)] public static extern bool CloseHandle(IntPtr h);
    [DllImport("kernel32.dll", SetLastError=true)] public static extern int VirtualQueryEx(IntPtr h, IntPtr a, out MBI m, uint l);
    [DllImport("kernel32.dll", SetLastError=true)] public static extern bool ReadProcessMemory(IntPtr h, IntPtr a, byte[] b, IntPtr s, out IntPtr r);
}
"@

$handle = [W]::OpenProcess(0x410, $false, $weixin.Id)
if ($handle -eq [IntPtr]::Zero) { Write-Error "OpenProcess failed"; exit 3 }

# 扫描 + 提取 96-char hex literal
$addr = [IntPtr]::Zero
$max = 0x7FFFFFFEFFFF
$regex = [regex]::new("x'([a-f0-9]{96})'", 'IgnoreCase')
$literals = New-Object System.Collections.Generic.HashSet[string]
try {
    while ([Int64]$addr -lt $max) {
        $mbi = New-Object W+MBI
        if ([W]::VirtualQueryEx($handle, $addr, [ref]$mbi, 48) -eq 0) { break }
        $isCommit = ($mbi.State -band 0x1000) -ne 0
        $isPrivate = ($mbi.Type -band 0x20000) -ne 0
        $isRW = ($mbi.Protect -eq 0x04) -or ($mbi.Protect -eq 0x40)
        $noGuard = ($mbi.Protect -band 0x100) -eq 0
        if ($isCommit -and $isPrivate -and $isRW -and $noGuard) {
            $size = [Int64]$mbi.RegionSize
            if ($size -gt 0 -and $size -lt 1GB) {
                $buf = New-Object byte[] $size
                $read = [IntPtr]::Zero
                $ok = [W]::ReadProcessMemory($handle, $mbi.BaseAddress, $buf, [IntPtr]::new($size), [ref]$read)
                if ($ok) {
                    $text = [System.Text.Encoding]::Latin1.GetString($buf, 0, [Int64]$read)
                    foreach ($m in $regex.Matches($text)) {
                        [void]$literals.Add($m.Groups[1].Value.ToLowerInvariant())
                    }
                }
            }
        }
        $addr = [IntPtr]::new([Int64]$mbi.BaseAddress + [Int64]$mbi.RegionSize)
    }
} finally { [W]::CloseHandle($handle) | Out-Null }

# 5. 反向匹配 salt → key
$dbKeys = @{}
foreach ($lit in $literals) {
    $keyHex = $lit.Substring(0, 64)
    $saltHex = $lit.Substring(64)
    foreach ($entry in $saltMap.GetEnumerator()) {
        if ($entry.Value -eq $saltHex) {
            $dbKeys[$entry.Key] = $keyHex
            break
        }
    }
}

# 6. 输出 JSON
$result = @{
    seedDir       = $SeedDir
    wxid          = (Split-Path $SeedDir -Leaf).Split('_')[0]
    wechatVersion = $weixin.MainModule.FileVersionInfo.FileVersion
    pid           = $weixin.Id
    dbKeys        = $dbKeys
}
$result | ConvertTo-Json -Compress
  • Step 2: 写 key_extractor.ts

写入 src/.../key_extractor.ts:

import { spawn } from 'node:child_process';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';

export interface KeyExtractResult {
  seedDir: string;
  wxid: string;
  wechatVersion: string;
  pid: number;
  dbKeys: Record<string, string>; // dbFile → rawKey hex
}

/**
 * robust 地定位 extract-weixin-key.ps1。按以下顺序 fallback:
 *   1. env NETA_WEIXIN_KEY_SCRIPT
 *   2. 模块自身相对路径(DEV / pkg 都适用):
 *      key_extractor.ts 在 src/modules/netaclaw/runtime/weixin_db/
 *      → ../../../../tools/win32/extract-weixin-key.ps1
 *   3. installer 部署路径 `<installDir>/tools/win32/...`
 *   4. 当前工作目录
 */
export function resolveExtractorScript(): string {
  const candidates: string[] = [];

  if (process.env.NETA_WEIXIN_KEY_SCRIPT) candidates.push(process.env.NETA_WEIXIN_KEY_SCRIPT);

  // 模块相对(DEV)
  try {
    const here = path.dirname(fileURLToPath(import.meta.url));
    candidates.push(path.resolve(here, '..', '..', '..', '..', 'tools', 'win32', 'extract-weixin-key.ps1'));
  } catch { /* CommonJS fallback */ }

  // installer 部署 & pkg
  const installDir = process.env.NETA_INSTALL_DIR
    || (process.execPath ? path.dirname(process.execPath) : '');
  if (installDir) {
    candidates.push(path.join(installDir, 'tools', 'win32', 'extract-weixin-key.ps1'));
  }

  // cwd 兜底
  candidates.push(path.join(process.cwd(), 'tools', 'win32', 'extract-weixin-key.ps1'));

  for (const c of candidates) {
    if (fs.existsSync(c)) return c;
  }
  throw new Error(
    `extract-weixin-key.ps1 not found. Tried:\n  - ${candidates.join('\n  - ')}\n`
    + `Set NETA_WEIXIN_KEY_SCRIPT env var to override.`
  );
}

export async function extractWeixinKeys(seedDir?: string): Promise<KeyExtractResult> {
  if (process.platform !== 'win32') {
    throw new Error(`extractWeixinKeys only works on win32; current=${process.platform}`);
  }
  const scriptPath = resolveExtractorScript();
  const args = ['-ExecutionPolicy', 'Bypass', '-NoProfile', '-File', scriptPath];
  if (seedDir) { args.push('-SeedDir', seedDir); }

  return await new Promise<KeyExtractResult>((resolve, reject) => {
    const child = spawn('powershell.exe', args, { windowsHide: true });
    let stdout = ''; let stderr = '';
    child.stdout.on('data', d => stdout += d.toString('utf8'));
    child.stderr.on('data', d => stderr += d.toString('utf8'));
    child.on('error', reject);
    child.on('close', code => {
      if (code !== 0) {
        reject(new Error(`extract-weixin-key.ps1 exit ${code}: ${stderr || stdout}`));
        return;
      }
      try {
        resolve(JSON.parse(stdout.trim()) as KeyExtractResult);
      } catch (e: any) {
        reject(new Error(`parse JSON failed: ${e.message} / raw=${stdout.slice(0, 200)}`));
      }
    });
  });
}

DEV 手工验证(在 pnpm dev backend 跑着的情况下):

# 应能自动找到脚本(模块相对路径命中)
cd packages/backend
node -e "import('./src/modules/netaclaw/runtime/weixin_db/key_extractor.js').then(m => console.log(m.resolveExtractorScript()))"

Expected: 输出 packages/backend/tools/win32/extract-weixin-key.ps1 绝对路径。

  • Step 3: 手工冒烟(依赖 Weixin 运行,无法单测)
# Weixin 登录后执行
powershell.exe -ExecutionPolicy Bypass -NoProfile -File packages/backend/tools/win32/extract-weixin-key.ps1

Expected: 输出 JSON,dbKeys.message_0.db 是 64 位 hex

  • Step 4: Commit
git add -A
git commit -m "feat(weixin-db): extract-weixin-key.ps1 + key_extractor.ts (spawn ps1 抽 key)"

Task 11: message_repo.ts — better-sqlite3 readonly 读 Msg 表

Files:

  • Create: packages/backend/src/modules/netaclaw/runtime/weixin_db/message_repo.ts

  • Test: packages/backend/test/modules/netaclaw/runtime/weixin_db/message_repo.test.ts(条件跳过,无 fixture 时 skip)

  • Step 1: 写实现

写入 src/.../message_repo.ts:

import Database from 'better-sqlite3';
import { tryDecompressToString, hasZstdMagic } from './zstd_decode.js';

export interface MessageRow {
  localId: number;
  serverId: bigint | null;
  localType: number;
  realSenderId: bigint | null;
  createTime: number;  // Unix 秒
  content: string;     // zstd 解压后
  tableName: string;
}

export class MessageRepo {
  private readonly db: Database.Database;
  constructor(private readonly dbPath: string) {
    this.db = new Database(dbPath, { readonly: true, fileMustExist: true });
  }

  listMsgTables(): string[] {
    const rows = this.db.prepare(
      "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg\\_%' ESCAPE '\\'"
    ).all() as { name: string }[];
    return rows.map(r => r.name);
  }

  maxCreateTime(tableName: string): number {
    const row = this.db.prepare(
      `SELECT IFNULL(MAX(create_time), 0) AS ts FROM "${tableName}"`
    ).get() as { ts: number };
    return row.ts;
  }

  async listSince(tableName: string, lastTs: number, limit = 500): Promise<MessageRow[]> {
    const rows = this.db.prepare(
      `SELECT local_id, server_id, local_type, real_sender_id, create_time, message_content
       FROM "${tableName}" WHERE create_time > @ts ORDER BY create_time ASC LIMIT ${limit}`
    ).all({ ts: lastTs }) as any[];
    const result: MessageRow[] = [];
    for (const r of rows) {
      const raw = r.message_content instanceof Buffer
        ? r.message_content
        : r.message_content != null
          ? Buffer.from(String(r.message_content), 'utf8')
          : Buffer.alloc(0);
      // 若 zstd_decode 是 sync,这里直接调;若改成 async,整个 listSince 签名已是 async
      const content = hasZstdMagic(raw) ? await tryDecompressToString(raw) : raw.toString('utf8');
      result.push({
        localId: r.local_id,
        serverId: r.server_id != null ? BigInt(r.server_id) : null,
        localType: r.local_type,
        realSenderId: r.real_sender_id != null ? BigInt(r.real_sender_id) : null,
        createTime: r.create_time,
        content,
        tableName,
      });
    }
    return result;
  }

  close(): void { this.db.close(); }
}

注:tryDecompressToString 按 Task 8 / 8.1 结果为 sync 或 async,这里保守用 await(async 兼容 sync 返回值)。

  • Step 2: 简单冒烟测试(无 fixture 时 skip)
import { MessageRepo } from '../../../../../src/modules/netaclaw/runtime/weixin_db/message_repo.js';
import { existsSync } from 'node:fs';

const FIXTURE = './poc/weixin-4x/message_0_decrypted.db';

(existsSync(FIXTURE) ? describe : describe.skip)('MessageRepo (integration)', () => {
  it('lists Msg tables from decrypted DB', () => {
    const repo = new MessageRepo(FIXTURE);
    const tables = repo.listMsgTables();
    expect(tables.length).toBeGreaterThan(0);
    expect(tables.every(t => t.startsWith('Msg_'))).toBe(true);
    repo.close();
  });
});
  • Step 3: Commit
git add -A
git commit -m "feat(weixin-db): message_repo.ts readonly 读 Msg 表 + zstd 解 content"

Task 12: room_resolver.ts — Msg 表名 ↔ 群名 映射 + 白名单过滤

Files:

  • Create: packages/backend/src/modules/netaclaw/runtime/weixin_db/room_resolver.ts
  • Test: packages/backend/test/modules/netaclaw/runtime/weixin_db/room_resolver.test.ts

关键职责:

  1. 从明文 session.db 读出所有会话的 (表名hash → roomName) 映射
  2. 提供 isWhitelisted(tableName, whitelist: Set<string>) 判断

研究待办:Msg 表名是 md5(username) 还是 md5(chatroom_id)?session.db 中实际存的关系需要 Phase 实施时 RE(反向工程)确认。本 Task 先实现接口骨架,具体 SQL 在集成测试阶段补全。

  • Step 1: 写实现(骨架 + 待确认的 SQL)

写入 src/.../room_resolver.ts:

import Database from 'better-sqlite3';

/**
 * 从 session.db 解析 Msg_<sha> 表名到群名的映射。
 * RE 待确认: Msg 表名后缀是 md5(session.username)? md5(chat room id)?
 * 本实现假设 session 表有 username + display_name 字段,Msg 表名 = 'Msg_' + md5(username)。
 * 运行时通过集成测试验证;不符则调整 SQL。
 */
import { createHash } from 'node:crypto';

export interface RoomInfo {
  tableName: string;
  username: string;    // 群 chatroom id (wxid@chatroom) 或对方 wxid(DM)
  roomName: string;    // 显示名
  isGroup: boolean;
}

export class RoomResolver {
  private readonly roomsByTable = new Map<string, RoomInfo>();

  loadFromSessionDb(sessionDbPath: string): void {
    const db = new Database(sessionDbPath, { readonly: true, fileMustExist: true });
    try {
      // SQL schema 待 RE 确认;下面是假设版:
      // 尝试从 session.db 查 username + display_name
      let rows: any[] = [];
      try {
        rows = db.prepare(
          `SELECT username, display_name FROM session_table` // 表名/字段名待确认
        ).all();
      } catch {
        // 若上面的表不存在,试其它可能的 schema
        const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
        // eslint-disable-next-line no-console
        console.warn('[RoomResolver] session.db schema 未知,表清单:', tables.map(t => t.name).join(','));
        return;
      }
      for (const r of rows) {
        const username = String(r.username || '');
        const displayName = String(r.display_name || username);
        const tableName = 'Msg_' + createHash('md5').update(username, 'utf8').digest('hex');
        this.roomsByTable.set(tableName, {
          tableName,
          username,
          roomName: displayName,
          isGroup: username.endsWith('@chatroom'),
        });
      }
    } finally {
      db.close();
    }
  }

  lookup(tableName: string): RoomInfo | undefined {
    return this.roomsByTable.get(tableName);
  }

  /** 返回 tableName 列表中所有"群 + 用户白名单匹配"的子集。 */
  filterWhitelistedGroupTables(
    tableNames: string[],
    whitelistRoomNames: ReadonlySet<string>,
  ): string[] {
    return tableNames.filter(t => {
      const info = this.roomsByTable.get(t);
      if (!info || !info.isGroup) return false;
      return whitelistRoomNames.has(info.roomName);
    });
  }
}
  • Step 2: 写 unit test(不依赖 DB)
import { RoomResolver } from '../../../../../src/modules/netaclaw/runtime/weixin_db/room_resolver.js';
import { createHash } from 'node:crypto';

describe('RoomResolver.filterWhitelistedGroupTables', () => {
  it('keeps only whitelist-matching group tables', () => {
    const r = new RoomResolver();
    const h = (s: string) => 'Msg_' + createHash('md5').update(s, 'utf8').digest('hex');
    (r as any).roomsByTable = new Map([
      [h('gA@chatroom'), { tableName: h('gA@chatroom'), username: 'gA@chatroom', roomName: '产品研发群', isGroup: true }],
      [h('gB@chatroom'), { tableName: h('gB@chatroom'), username: 'gB@chatroom', roomName: '家庭群', isGroup: true }],
      [h('wxid_dm1'),    { tableName: h('wxid_dm1'),    username: 'wxid_dm1',    roomName: '张三',      isGroup: false }],
    ]);
    const all = [h('gA@chatroom'), h('gB@chatroom'), h('wxid_dm1')];
    const whitelist = new Set(['产品研发群']);
    const kept = r.filterWhitelistedGroupTables(all, whitelist);
    expect(kept).toEqual([h('gA@chatroom')]);
  });
});
  • Step 3: 跑测试 + Commit
pnpm --filter @neta/backend test -- runtime/weixin_db/room_resolver 2>&1 | tail -10
git add -A
git commit -m "feat(weixin-db): room_resolver.ts Msg 表名↔群名 映射 + 白名单过滤"

Follow-up: loadFromSessionDb 的 SQL schema 需要在 Phase C-5 端到端验证时,用真实 session.db 做 RE 确认并补完。若反向匹配失败,接口不变,调 loadFromSessionDb 的 SQL 即可;上层调用者 API 兼容。


Task 13: wal_watcher.ts — setInterval 轮询 wal mtime

Files:

  • Create: packages/backend/src/modules/netaclaw/runtime/weixin_db/wal_watcher.ts

  • Step 1: 写实现

import * as fs from 'node:fs';

export class WalWatcher {
  private timer: NodeJS.Timeout | null = null;
  private lastMtime = 0;

  constructor(
    private readonly mainDbPath: string,
    private readonly onChange: () => Promise<void>,
    private readonly intervalMs = 500,
  ) {}

  get walPath(): string { return this.mainDbPath + '-wal'; }

  start(): void {
    if (this.timer) return;
    this.lastMtime = this.readMtime();
    this.timer = setInterval(() => this.tick().catch(() => void 0), this.intervalMs);
  }

  stop(): void {
    if (!this.timer) return;
    clearInterval(this.timer);
    this.timer = null;
  }

  private readMtime(): number {
    try { return fs.statSync(this.walPath).mtimeMs; }
    catch { return 0; }
  }

  private async tick(): Promise<void> {
    const m = this.readMtime();
    if (m === 0 || m === this.lastMtime) return;
    this.lastMtime = m;
    await this.onChange();
  }
}
  • Step 2: Commit
git add -A
git commit -m "feat(weixin-db): wal_watcher.ts setInterval 轮询 .db-wal mtime"

Task 14: incremental_reader.ts — 整合解密 + 白名单 + 增量读

Files:

  • Create: packages/backend/src/modules/netaclaw/runtime/weixin_db/incremental_reader.ts

  • Step 1: 写实现

import * as fs from 'node:fs';
import * as path from 'node:path';
import { decryptDatabase } from './wcdb_codec.js';
import { MessageRepo, MessageRow } from './message_repo.js';
import { RoomResolver } from './room_resolver.js';
import { DbPaths } from './db_paths.js';

export interface IncrementalReaderConfig {
  dbPaths: DbPaths;
  messageDbKey: Buffer;
  sessionDbKey: Buffer;
  workDir: string;                 // 临时解密目录
  whitelistRoomNames: () => Set<string>; // 每次读调用,支持用户实时改白名单
}

export class IncrementalReader {
  private lastTsByTable = new Map<string, number>();
  private roomResolver = new RoomResolver();

  constructor(private readonly cfg: IncrementalReaderConfig) {}

  /** 一次性:解密 session.db 加载 room 映射 + 解密 message_0.db + 初始化每表 baseline。 */
  async initialize(): Promise<void> {
    // 1. 解 session.db → 加载 room map
    const sessionPlain = this.decryptToWork(this.cfg.dbPaths.sessionDb, this.cfg.sessionDbKey);
    this.roomResolver.loadFromSessionDb(sessionPlain);

    // 2. 解 message_0.db → 确定 baseline (每白名单表的 MAX create_time)
    const msgPlain = this.decryptToWork(this.cfg.dbPaths.messageDb, this.cfg.messageDbKey);
    const repo = new MessageRepo(msgPlain);
    try {
      const allTables = repo.listMsgTables();
      const whitelist = this.cfg.whitelistRoomNames();
      const tables = this.roomResolver.filterWhitelistedGroupTables(allTables, whitelist);
      for (const t of tables) this.lastTsByTable.set(t, repo.maxCreateTime(t));
    } finally {
      repo.close();
    }
  }

  /** WalWatcher 触发:重解 message_0.db → 按白名单表 SELECT 新行。 */
  async readIncrement(): Promise<MessageRow[]> {
    const msgPlain = this.decryptToWork(this.cfg.dbPaths.messageDb, this.cfg.messageDbKey);
    const repo = new MessageRepo(msgPlain);
    const result: MessageRow[] = [];
    try {
      const allTables = repo.listMsgTables();
      const whitelist = this.cfg.whitelistRoomNames();
      const tables = this.roomResolver.filterWhitelistedGroupTables(allTables, whitelist);
      for (const t of tables) {
        const last = this.lastTsByTable.get(t) ?? 0;
        const rows = await repo.listSince(t, last);
        for (const row of rows) {
          result.push(row);
          if (row.createTime > last) this.lastTsByTable.set(t, row.createTime);
        }
      }
    } finally {
      repo.close();
    }
    return result;
  }

  /** 返回 tableName → roomName 映射供上层用(投影 pseudo message 时需要)。 */
  resolveRoom(tableName: string) {
    return this.roomResolver.lookup(tableName);
  }

  private decryptToWork(srcDb: string, rawKey: Buffer): string {
    // 先把 src + wal + shm 拷到 workDir/src/
    const srcDir = path.join(this.cfg.workDir, 'src');
    const outDir = path.join(this.cfg.workDir, 'decrypted');
    fs.mkdirSync(srcDir, { recursive: true });
    fs.mkdirSync(outDir, { recursive: true });
    const name = path.basename(srcDb);
    const srcCopy = path.join(srcDir, name);
    fs.copyFileSync(srcDb, srcCopy);
    for (const suffix of ['-wal', '-shm']) {
      const s = srcDb + suffix;
      if (fs.existsSync(s)) fs.copyFileSync(s, srcCopy + suffix);
    }
    const encrypted = fs.readFileSync(srcCopy);
    const decrypted = decryptDatabase(encrypted, rawKey);
    const outPath = path.join(outDir, name);
    fs.writeFileSync(outPath, decrypted);
    for (const suffix of ['-wal', '-shm']) {
      const p = outPath + suffix;
      if (fs.existsSync(p)) fs.unlinkSync(p);
    }
    return outPath;
  }
}
  • Step 2: Commit
git add -A
git commit -m "feat(weixin-db): incremental_reader.ts 整合 解密+白名单+增量读"

Task 15: build_pseudo.ts + types.ts — MessageRow → PseudoMessage 投影

Files:

  • Create: packages/backend/src/modules/netaclaw/runtime/weixin_db/types.ts

  • Create: packages/backend/src/modules/netaclaw/runtime/weixin_db/build_pseudo.ts

  • Test: packages/backend/test/modules/netaclaw/runtime/weixin_db/build_pseudo.test.ts

  • Step 1: 写 types.ts

import type { MessageRow } from './message_repo.js';
import type { RoomInfo } from './room_resolver.js';

export interface WeixinDbInboundRow {
  channelId: number;
  row: MessageRow;
  room: RoomInfo;            // 必须是 isGroup=true(非白名单 DM 已被 room_resolver 过滤)
}

/** 投影到 agent_channel routeInboundMessage 入参 shape。 */
export interface WeixinDbPseudoMessage {
  from_user_id: string;
  room_id: string;
  room_name: string;
  message_id: string;
  item_list: Array<{ type: number; text_item: { text: string } }>;
  __weixin_db: true;
}
  • Step 2: 写 build_pseudo.ts
import { createHash } from 'node:crypto';
import type { WeixinDbInboundRow, WeixinDbPseudoMessage } from './types.js';

/**
 * 把 DB row + roomInfo 投影成 PseudoMessage。
 * sender wxid: 群消息的 message_content 开头形如 "wxid_xxx:\n..." → 解析;
 *   若没这个前缀(系统消息 / 非标准消息),fallback realSenderId 字符串。
 */
export function buildPseudoMessageFromDb(input: WeixinDbInboundRow): WeixinDbPseudoMessage {
  const { channelId, row, room } = input;
  const parsed = parseSenderPrefix(row.content);
  const senderWxid = parsed?.senderWxid
    ?? (row.realSenderId != null ? String(row.realSenderId) : 'unknown');
  const bodyText = parsed?.body ?? row.content;

  const messageId = row.serverId != null
    ? String(row.serverId)
    : createHash('sha1')
        .update(`${channelId}|${room.username}|${row.createTime}|${row.content}`, 'utf8')
        .digest('hex');

  return {
    from_user_id: senderWxid,
    room_id: `${channelId}:room:${room.username}`,
    room_name: room.roomName,
    message_id: messageId,
    item_list: [{ type: row.localType ?? 1, text_item: { text: bodyText } }],
    __weixin_db: true,
  };
}

function parseSenderPrefix(content: string): { senderWxid: string; body: string } | null {
  const idx = content.indexOf(':\n');
  if (idx <= 0) return null;
  const prefix = content.slice(0, idx);
  if (!prefix.startsWith('wxid_')) return null;
  return { senderWxid: prefix, body: content.slice(idx + 2) };
}
  • Step 3: 写测试
import { buildPseudoMessageFromDb } from '../../../../../src/modules/netaclaw/runtime/weixin_db/build_pseudo.js';

describe('buildPseudoMessageFromDb', () => {
  const room = { tableName: 'Msg_x', username: 'gA@chatroom', roomName: '产品研发群', isGroup: true };

  it('parses wxid_xxx:\\n prefix into sender + body', () => {
    const m = buildPseudoMessageFromDb({
      channelId: 7,
      room,
      row: {
        localId: 1, serverId: 999n, localType: 1, realSenderId: 42n, createTime: 1700000000,
        content: 'wxid_alice:\n@小神 hi', tableName: 'Msg_x',
      } as any,
    });
    expect(m.from_user_id).toBe('wxid_alice');
    expect(m.item_list[0].text_item.text).toBe('@小神 hi');
    expect(m.room_id).toBe('7:room:gA@chatroom');
    expect(m.message_id).toBe('999');
    expect(m.__weixin_db).toBe(true);
  });

  it('falls back to realSenderId when no prefix', () => {
    const m = buildPseudoMessageFromDb({
      channelId: 1,
      room,
      row: {
        localId: 1, serverId: null, localType: 1, realSenderId: 99n, createTime: 1,
        content: '系统消息', tableName: 'Msg_x',
      } as any,
    });
    expect(m.from_user_id).toBe('99');
    expect(m.item_list[0].text_item.text).toBe('系统消息');
    expect(m.message_id.length).toBeGreaterThan(0); // sha1 fallback
  });
});
  • Step 4: 跑测试 + Commit
pnpm --filter @neta/backend test -- runtime/weixin_db/build_pseudo 2>&1 | tail -10
git add -A
git commit -m "feat(weixin-db): build_pseudo.ts + types.ts — DB row → PseudoMessage 投影"

Phase C-3 · weixin_db.ts 主服务装配

Task 16: WeixinDbService 主服务(启动钩子 + watcher + replyToGroup 占位)

Files:

  • Create: packages/backend/src/modules/netaclaw/service/weixin_db.ts

  • Test: packages/backend/test/modules/netaclaw/service/weixin_db.test.ts

  • Step 1: 写实现

写入 service/weixin_db.ts:

import * as path from 'node:path';
import * as fs from 'node:fs';
import { Provide, Scope, ScopeEnum, Init, Logger, Inject } from '@midwayjs/core';
import type { ILogger } from '@midwayjs/logger';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { resolveDataDir } from '../../../comm/data-dir.js';
import { DbPaths } from '../runtime/weixin_db/db_paths.js';
import { extractWeixinKeys } from '../runtime/weixin_db/key_extractor.js';
import { IncrementalReader } from '../runtime/weixin_db/incremental_reader.js';
import { WalWatcher } from '../runtime/weixin_db/wal_watcher.js';
import { buildPseudoMessageFromDb } from '../runtime/weixin_db/build_pseudo.js';
import { NetaClawAgentChannelEntity } from '../entity/agent_channel.js';
import { NetaClawAgentChannelGroupEntity } from '../entity/agent_channel_group.js';

interface ChannelRuntime {
  channelId: number;
  reader: IncrementalReader;
  watcher: WalWatcher;
  weixinPid: number;
  paths: DbPaths;
  onInbound: (channelId: number, pseudo: unknown) => Promise<void>;
}

const HEALTH_INTERVAL_MS = 60_000;

/**
 * weixin-db 主服务 (架构 C)。
 * - bindChannel: 抽 key → 启 WalWatcher;在 channel 启用时由 agent_channel.runLoop 调用
 * - unbindChannel: 停 watcher + 清缓存
 * - replyToGroup: 占位 throw NotImplementedError(等待 spec 5.7 实施)
 * - 健康探针: 每 60s 检测 Weixin 进程仍在 + DB 可读;失败自动 unbind+rebind
 * - 跨平台: 非 Windows 直接 skip (loginStatus='unsupported_platform'),不让 backend crash
 */
@Provide()
@Scope(ScopeEnum.Singleton)
export class WeixinDbService {
  @Logger() logger: ILogger;

  @InjectEntityModel(NetaClawAgentChannelGroupEntity)
  groupRepo: Repository<NetaClawAgentChannelGroupEntity>;

  @InjectEntityModel(NetaClawAgentChannelEntity)
  channelRepo: Repository<NetaClawAgentChannelEntity>;

  private readonly runtimes = new Map<number, ChannelRuntime>();
  private healthTimer: NodeJS.Timeout | null = null;

  @Init()
  async onInit(): Promise<void> {
    if (process.platform !== 'win32') {
      this.logger.info('[weixin-db] non-Windows platform (%s), service idle', process.platform);
      return;
    }
    // 启健康探针
    this.healthTimer = setInterval(
      () => this.healthCheck().catch(err => this.logger.error('[weixin-db] healthCheck err: %s', err.message)),
      HEALTH_INTERVAL_MS
    );
  }

  /** 由 agent_channel.runLoop 启动 weixin-db channel 时调用。 */
  async bindChannel(
    channel: NetaClawAgentChannelEntity,
    onInbound: (channelId: number, pseudo: unknown) => Promise<void>,
  ): Promise<void> {
    if (process.platform !== 'win32') {
      this.logger.warn('[weixin-db] non-Windows platform, channel %s skipped', channel.id);
      await this.channelRepo.update({ id: channel.id }, { loginStatus: 'unsupported_platform' as any });
      return;
    }
    if (this.runtimes.has(channel.id)) return;

    const seedDir = DbPaths.autoFindSeedDir();
    if (!seedDir) {
      this.logger.warn('[weixin-db] xwechat_files 未找到, channel %s loginStatus=disconnected', channel.id);
      await this.channelRepo.update({ id: channel.id }, { loginStatus: 'disconnected' });
      return;
    }
    const paths = new DbPaths(seedDir);

    let keys;
    try {
      keys = await extractWeixinKeys(seedDir);
    } catch (err: any) {
      this.logger.warn('[weixin-db] key 抽取失败 cid=%s: %s', channel.id, err.message);
      await this.channelRepo.update({ id: channel.id }, { loginStatus: 'disconnected', lastError: err.message });
      return;
    }
    const msgKeyHex = keys.dbKeys['message_0.db'];
    const sessionKeyHex = keys.dbKeys['session.db'];
    if (!msgKeyHex || !sessionKeyHex) {
      this.logger.warn('[weixin-db] 抽不到 message/session key, channel %s', channel.id);
      await this.channelRepo.update({ id: channel.id }, { loginStatus: 'disconnected' });
      return;
    }

    const dataDir = resolveDataDir();
    const workDir = path.join(dataDir, 'weixin-db-work', `cid-${channel.id}`);
    fs.mkdirSync(workDir, { recursive: true });

    const reader = new IncrementalReader({
      dbPaths: paths,
      messageDbKey: Buffer.from(msgKeyHex, 'hex'),
      sessionDbKey: Buffer.from(sessionKeyHex, 'hex'),
      workDir,
      whitelistRoomNames: () => this.currentWhitelist(channel.id),
    });
    await reader.initialize();

    const watcher = new WalWatcher(paths.messageDb, async () => {
      try {
        const rows = await reader.readIncrement();
        for (const row of rows) {
          const room = reader.resolveRoom(row.tableName);
          if (!room) continue;
          const pseudo = buildPseudoMessageFromDb({ channelId: channel.id, row, room });
          await onInbound(channel.id, pseudo);
        }
      } catch (err: any) {
        this.logger.error('[weixin-db] incremental read failed cid=%s: %s', channel.id, err.message);
      }
    });
    watcher.start();

    this.runtimes.set(channel.id, {
      channelId: channel.id, reader, watcher,
      weixinPid: keys.pid, paths, onInbound,
    });
    await this.channelRepo.update({ id: channel.id }, {
      loginStatus: 'connected',
      credential: { ...(channel.credential || {}), wxid: keys.wxid, wechatVersion: keys.wechatVersion } as any,
      lastConnectedAt: new Date(),
    });
    this.logger.info('[weixin-db] channel %s bound, wxid=%s ver=%s pid=%s',
      channel.id, keys.wxid, keys.wechatVersion, keys.pid);
  }

  unbindChannel(channelId: number): void {
    const r = this.runtimes.get(channelId);
    if (!r) return;
    r.watcher.stop();
    this.runtimes.delete(channelId);
    this.logger.info('[weixin-db] channel %s unbound', channelId);
  }

  /** ★ 占位 — 等待 spec 5.7 实现。 */
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  async replyToGroup(channelId: number, roomName: string, text: string): Promise<void> {
    throw new Error(
      `[weixin-db] replyToGroup not implemented yet (spec 5.7 TODO). cid=${channelId} room="${roomName}" text-len=${text.length}`
    );
  }

  /** 健康探针:每 60s 调一次,检测 Weixin 进程仍在 + DB 可读;失败 unbind+rebind。 */
  private async healthCheck(): Promise<void> {
    for (const [cid, runtime] of this.runtimes) {
      const ok = this.probeAlive(runtime);
      if (!ok) {
        this.logger.warn('[weixin-db] cid=%s health failed, rebinding', cid);
        this.unbindChannel(cid);
        try {
          const channel = await this.channelRepo.findOne({ where: { id: cid } });
          if (channel) await this.bindChannel(channel, runtime.onInbound);
        } catch (err: any) {
          this.logger.error('[weixin-db] rebind cid=%s failed: %s', cid, err.message);
        }
      }
    }
  }

  private probeAlive(runtime: ChannelRuntime): boolean {
    // 1. message_0.db 仍可读 (Weixin 没退出会保持文件)
    try {
      fs.accessSync(runtime.paths.messageDb, fs.constants.R_OK);
    } catch { return false; }
    // 2. Weixin 进程还在 (Windows: process.kill(pid, 0))
    try {
      process.kill(runtime.weixinPid, 0);
      return true;
    } catch { return false; }
  }

  private async currentWhitelist(channelId: number): Promise<Set<string>> {
    const groups = await this.groupRepo.find({
      where: { channelId, status: 1 } as any,
    });
    return new Set(groups.map(g => g.roomName).filter(Boolean) as string[]);
  }
}

关键变更 vs 上一版:

  • @Init 注入跨平台 guard + 健康探针 setInterval
  • bindChannel 全程包 try/catch,失败时 update channel.loginStatus 而不是抛 error
  • 新增 probeAlive()process.kill(pid, 0) 探活
  • runtimes 持有 weixinPid + paths + onInbound,重连时复用
  • Step 2: 写测试(mock extractWeixinKeys + groupRepo)
import { WeixinDbService } from '../../../../src/modules/netaclaw/service/weixin_db.js';

jest.mock('../../../../src/modules/netaclaw/runtime/weixin_db/key_extractor.js', () => ({
  extractWeixinKeys: jest.fn(),
}));
jest.mock('../../../../src/modules/netaclaw/runtime/weixin_db/db_paths.js', () => ({
  DbPaths: class { messageDb='m'; sessionDb='s'; static autoFindSeedDir() { return 'seed'; } },
}));
jest.mock('../../../../src/modules/netaclaw/runtime/weixin_db/incremental_reader.js', () => ({
  IncrementalReader: jest.fn().mockImplementation(() => ({
    initialize: jest.fn().mockResolvedValue(undefined),
    readIncrement: jest.fn().mockResolvedValue([]),
    resolveRoom: jest.fn(),
  })),
}));
jest.mock('../../../../src/modules/netaclaw/runtime/weixin_db/wal_watcher.js', () => ({
  WalWatcher: jest.fn().mockImplementation(() => ({ start: jest.fn(), stop: jest.fn() })),
}));

describe('WeixinDbService', () => {
  let svc: WeixinDbService;
  beforeEach(() => {
    svc = new WeixinDbService();
    (svc as any).logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() };
    (svc as any).groupRepo = { find: jest.fn().mockResolvedValue([]) };
  });

  it('replyToGroup throws NotImplementedError', async () => {
    await expect(svc.replyToGroup(1, 'room', 'hi')).rejects.toThrow(/not implemented/);
  });

  it('bindChannel registers runtime when keys found', async () => {
    const { extractWeixinKeys } = require('../../../../src/modules/netaclaw/runtime/weixin_db/key_extractor.js');
    extractWeixinKeys.mockResolvedValue({
      seedDir: 'seed', wxid: 'wxid', wechatVersion: '4.1', pid: 1,
      dbKeys: {
        'message_0.db': '00'.repeat(32),
        'session.db': '11'.repeat(32),
      },
    });
    await svc.bindChannel({ id: 7 } as any, jest.fn());
    expect((svc as any).runtimes.has(7)).toBe(true);
  });

  it('bindChannel skips when keys missing', async () => {
    const { extractWeixinKeys } = require('../../../../src/modules/netaclaw/runtime/weixin_db/key_extractor.js');
    extractWeixinKeys.mockResolvedValue({
      seedDir: 'seed', wxid: 'wxid', wechatVersion: '4.1', pid: 1, dbKeys: {}
    });
    await svc.bindChannel({ id: 7 } as any, jest.fn());
    expect((svc as any).runtimes.has(7)).toBe(false);
  });
});
  • Step 3: 跑测试 + Commit
pnpm --filter @neta/backend test -- service/weixin_db 2>&1 | tail -10
git add -A
git commit -m "feat(netaclaw): WeixinDbService 装配 (bindChannel 启动 reader+watcher; replyToGroup 占位)"

Phase C-4 · agent_channel.ts 集成 weixin-db

Task 17: agent_channel.ts weixin-uia → weixin-db 全替换 + handleDbInbound

Files:

  • Modify: packages/backend/src/modules/netaclaw/service/agent_channel.ts

下面是完整改动步骤,不要跳过

  • Step 1: 改 import

把:

import { WeixinUiaService } from './weixin_uia.js';
import { WechatArchiveService } from './wechat_archive.js';
import {
  buildPseudoMessageFromUia,
  resolveReplyIdentity,
  type UiaInboundPayload,
} from '../runtime/wechat_uia_routing.js';

改为:

import { WeixinDbService } from './weixin_db.js';

(不需要 import 旧 routing 文件,新 PseudoMessage 直接由 weixin_db 内部投影)

  • Step 2: 改字段
@Inject() weixinDbService: WeixinDbService;

(删 weixinUiaServicewechatArchiveService)

  • Step 3: 全文 sed 替换字面量
cd packages/backend
sed -i "s/'weixin-uia'/'weixin-db'/g" src/modules/netaclaw/service/agent_channel.ts
sed -i 's/"weixin-uia"/"weixin-db"/g' src/modules/netaclaw/service/agent_channel.ts
  • Step 4: 删除已废方法

搜索 + 整体删除:

  • ingestUiaInbound 方法(架构 C 不需要 HTTP 入站,改为内部调 routeInboundMessage)

  • findAllUiaChannelsByWxid 方法(同样,bridge 没了)

  • onUiaHandshake 方法(同上)

  • runLoopcase 'weixin-uia': 整个分支

  • Step 5: runLoop 加 weixin-db 启动逻辑

定位 runLoop 方法,在 type='weixin' 分支后加:

case 'weixin-db': {
  // 不再 long-poll;weixin_db service 内部启 wal watcher
  await this.weixinDbService.bindChannel(channel, async (cid, pseudo) => {
    const state = this.ensureChannelState(cid);
    await this.routeInboundMessage(channel, state, pseudo as any);
  });
  // bindChannel 失败已经在内部 update channel.loginStatus,这里只 log
  this.logger.info('[agent_channel] cid=%s weixin-db bind requested', channel.id);
  break;
}
  • Step 6: routeInboundMessage 加 weixin-db 分流

定位 routeInboundMessage,在 switch(channel.type) 中加:

case 'weixin-db': {
  if (!message.room_id) {
    // weixin-db 只处理群消息;DM 让 ClawBot 渠道处理(若有)
    this.logger.debug('[weixin-db] drop DM, use ClawBot for DM');
    return;
  }
  return this.handleDbInbound(channel, state, message);
}
  • Step 7: 新增 handleDbInbound 方法

照搬 handleIlinkInbound 主体到新方法,只改 sendText 处:

private async handleDbInbound(channel, state, message): Promise<void> {
  // 99% 复用 handleIlinkInbound:
  //   - decideChatScope
  //   - 群路径: pendingClarify 短路 → senderQueue 入队 → upsertOnInbound 由 group service.addByName 替代(不在这里 upsert)
  //   - decideGroupAcceptance(白名单已经在 weixin_db 解密阶段过滤;这层只判 trigger)
  //   - agentExecutor.execute({ chatScope: 'group', ... })

  // ... [此处复制 handleIlinkInbound 主体的 group 路径代码] ...

  // 唯一差别:发送
  const finalContent = result.content;
  if (finalContent && finalContent.trim() && !finalContent.startsWith('[SKIP]')) {
    try {
      // roomName 从 message.room_name 拿(buildPseudoMessageFromDb 已填)
      await this.weixinDbService.replyToGroup(channel.id, message.room_name, finalContent);
      await this.groupService.touchActive(channel.id, scope.chatId);
    } catch (err: any) {
      if (err?.message?.includes('not implemented')) {
        // ★ 5.7 占位:不阻塞读链路,只 warn
        this.logger.warn('[weixin-db] reply 暂未实现, cid=%s room=%s skipped', channel.id, message.room_name);
        // assistant entry 已在 agentExecutor 内部写入 sessionEntry,这里啥都不做
      } else {
        throw err;
      }
    }
  }
}
  • Step 8: channel 删除路径补 unbindChannel

定位 channel.delete()(或 cascade 删除入口),在 weixinUiaService.unregisterBridge(id) 改为:

this.weixinDbService.unbindChannel(id);
// (不再有 wechatArchiveService.closeForChannel)
  • Step 9: 跑 type-check
cd packages/backend && pnpm tsc --noEmit 2>&1 | head -30

Expected: 无 weixin-uia / wechat_archive / WeixinUiaService / UiaInboundPayload 相关 error。剩余 baseline error 不管。

  • Step 10: 跑 chat_scope + agent_channel_group 测试,确认 Phase 1-5 成果未破坏
pnpm --filter @neta/backend test -- runtime/chat_scope service/agent_channel_group 2>&1 | tail -10

Expected: 全 pass

  • Step 11: Commit
git add -A
git commit -m "refactor(netaclaw): agent_channel 切 weixin-db, runLoop 内 bindChannel + handleDbInbound 占位 reply"

Task 18: agent_channel.weixin_db.test.ts

Files:

  • Create: packages/backend/test/modules/netaclaw/service/agent_channel.weixin_db.test.ts

测试 routeInboundMessage 收到 __weixin_db pseudo 走群分支。

  • Step 1: 写测试
import { NetaClawAgentChannelService } from '../../../../src/modules/netaclaw/service/agent_channel.js';

describe('agent_channel weixin-db routing', () => {
  let svc: NetaClawAgentChannelService;
  beforeEach(() => {
    svc = new NetaClawAgentChannelService();
    (svc as any).logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
  });

  it('routes __weixin_db pseudo to group flow', async () => {
    const channel = { id: 5, type: 'weixin-db', config: {}, credential: {} } as any;
    const state = { senderQueues: new Map() } as any;
    const pseudo = {
      from_user_id: 'wxid_a', room_id: '5:room:gA@chatroom', room_name: '产品研发群',
      message_id: 's1', item_list: [{ type: 1, text_item: { text: '@小神 hi' } }],
      __weixin_db: true,
    };
    const route = jest.spyOn(svc as any, 'handleDbInbound').mockResolvedValue(undefined);
    await svc.routeInboundMessage(channel, state, pseudo as any);
    expect(route).toHaveBeenCalled();
  });

  it('drops DM in weixin-db channel (no room_id)', async () => {
    const channel = { id: 5, type: 'weixin-db' } as any;
    const state = {} as any;
    const noRoom = { from_user_id: 'wxid_a', message_id: '1', item_list: [] };
    const handler = jest.spyOn(svc as any, 'handleDbInbound').mockResolvedValue(undefined);
    await svc.routeInboundMessage(channel, state, noRoom as any);
    expect(handler).not.toHaveBeenCalled();
  });
});
  • Step 2: 跑测试 + Commit
pnpm --filter @neta/backend test -- service/agent_channel.weixin_db 2>&1 | tail -10
git add -A
git commit -m "test(netaclaw): agent_channel weixin-db 路由端到端"

Phase C-5 · agent_channel_group 改"用户主动添加"语义

Task 19: agent_channel_group service 加 addByName,删 upsertOnInbound

Files:

  • Modify: packages/backend/src/modules/netaclaw/service/agent_channel_group.ts

  • Modify: packages/backend/test/modules/netaclaw/service/agent_channel_group.test.ts

  • Step 1: 删除 upsertOnInbound 方法

定位 upsertOnInbound,整个方法删除。

  • Step 2: 新增 addByName 方法
async addByName(
  channelId: number,
  roomName: string,
  triggerMode: 'at_mention' | 'all',
): Promise<{ id: number; created: boolean }> {
  const trimmed = (roomName || '').trim();
  if (!trimmed) throw new Error('roomName required');
  if (trimmed.length > 128) throw new Error('roomName too long');

  const existing = await this.groupRepo.findOne({
    where: { channelId, roomName: trimmed } as any,
  });
  if (existing) {
    return { id: existing.id, created: false };
  }
  const now = new Date();
  const entity = this.groupRepo.create({
    channelId,
    roomId: trimmed,         // 架构 C: roomId 就是用户输入群名,作为白名单 key
    roomName: trimmed,
    status: 1,
    triggerMode,
    firstSeenAt: now,
    lastSeenAt: now,
  });
  const saved = await this.groupRepo.save(entity);
  return { id: saved.id, created: true };
}
  • Step 3: 删除 status=-1 相关代码

搜全文 status: -1 / status === -1 / status = -1,删掉。toggle 方法只接受 0/1:

async toggle(id: number, status: 0 | 1): Promise<void> {
  if (status !== 0 && status !== 1) throw new Error('status must be 0 or 1');
  await this.groupRepo.update({ id }, { status, updateTime: new Date() });
}
  • Step 4: 更新单测

把原 upsertOnInbound 首次插入 disabled 测试删除;改为:

it('addByName creates new group with status=1', async () => {
  // ...mock repo...
  const { id, created } = await svc.addByName(1, '产品研发群', 'at_mention');
  expect(created).toBe(true);
  // ...
});

it('addByName returns existing without overwrite', async () => {
  // existing mock 返回非 null → created=false
});

it('addByName rejects empty roomName', async () => {
  await expect(svc.addByName(1, '   ', 'at_mention')).rejects.toThrow();
});

it('toggle rejects status=-1', async () => {
  await expect(svc.toggle(1, -1 as any)).rejects.toThrow();
});
  • Step 5: 跑测试 + Commit
pnpm --filter @neta/backend test -- service/agent_channel_group 2>&1 | tail -10
git add -A
git commit -m "refactor(netaclaw): group service 改用户主动添加, 删 upsertOnInbound + 删 status=-1"

Task 20: agent_channel_group controller 加 /add 端点

Files:

  • Modify: packages/backend/src/modules/netaclaw/controller/admin/agent_channel_group.ts

  • Step 1: 加 /add endpoint

@Post('/add')
async add(@Body() body: {
  channelId: number;
  roomName: string;
  triggerMode?: 'at_mention' | 'all';
}) {
  if (!body.channelId || !body.roomName) {
    return { code: 1003, message: 'channelId + roomName required' };
  }
  const mode = body.triggerMode === 'all' ? 'all' : 'at_mention';
  const { id, created } = await this.groupService.addByName(body.channelId, body.roomName, mode);
  return { code: 1000, data: { id, created } };
}
  • Step 2: 删除 upsertOnInbound 路径(若 controller 暴露过此 endpoint)

  • Step 3: Commit

git add -A
git commit -m "feat(netaclaw): agent_channel_group controller 加 /add 端点"

Phase C-6 · 前端 UX

Task 21: 类型 + composable 改名

Files:

  • Modify: packages/frontend/src/modules/agent/types/index.d.ts

  • Rename: useUiaChannelValidation.tsuseDbChannelValidation.ts

  • Step 1: 改类型

export interface AgentChannelInfo {
  type: 'weixin' | 'weixin-db';
  // ...
}
export interface AgentGroupItem {
  status: 0 | 1;  // 删 -1
  triggerMode: 'at_mention' | 'all' | 'prefix';
  // ...
}
  • Step 2: 改 composable
git mv packages/frontend/src/modules/agent/composables/useUiaChannelValidation.ts \
       packages/frontend/src/modules/agent/composables/useDbChannelValidation.ts
  • 内部:useUiaChannelValidationuseDbChannelValidation,findOtherUiaChannelWithWxidfindOtherDbChannelWithWxid,type: 'weixin-uia''weixin-db'

  • Step 3: 跑 type-check + Commit

cd packages/frontend && pnpm type-check 2>&1 | tail -10
git add -A
git commit -m "refactor(agent-fe): types + composable 改 weixin-db"

Task 22: channel-management.vue 改 type 下拉 + 文案

Files:

  • Modify: packages/frontend/src/modules/agent/views/channel-management.vue

  • Step 1: 替换 type 选项与判断

'weixin-uia' 全部改 'weixin-db',下拉 label 改"微信本地代理(群聊 · 需 Windows + PC 微信)"。

  • Step 2: 文案改

所有 "UIA"、"UIA 控件树"、"bridge 自动识别" 改为 "本地代理"、"从本机微信数据库读取"。

  • Step 3: Commit
git add -A
git commit -m "feat(agent-fe): channel-management 切 weixin-db, 文案改本地代理"

Task 23: channel-group-panel.vue 重构为"用户主动添加"UX

Files:

  • Modify: packages/frontend/src/modules/agent/components/channel-group-panel.vue

这是本 plan 最大的前端改动。原 panel 有"待审批"逻辑,全部删除,改为"+ 添加群"按钮 + 添加弹窗。

  • Step 1: 删除"待审批"相关 UI

搜全文:

  • <el-alert ... title="新发现 N 个群"> 横幅

  • 删 "忽略" 按钮(status=-1)

  • pendingCount / ignoredCount / statusFilter 相关 computed

  • 删 "查看已忽略" 切换按钮

  • Step 2: 在头部加 "+ 添加群" 按钮 + 弹窗

<div class="group-panel__header">
  <div>已添加监管 {{ list.length }} 个群 · 已启用 {{ enabledCount }}</div>
  <el-button type="primary" @click="addDialog.visible = true">+ 添加群</el-button>
</div>

<el-dialog v-model="addDialog.visible" title="添加群聊" width="420px">
  <el-form :model="addDialog.form" label-width="80px">
    <el-form-item label="群名" required>
      <el-input v-model="addDialog.form.roomName" placeholder="必须与 PC 微信中显示的群名完全一致" />
    </el-form-item>
    <el-form-item label="触发策略">
      <el-radio-group v-model="addDialog.form.triggerMode">
        <el-radio value="at_mention">@机器人</el-radio>
        <el-radio value="all">所有消息</el-radio>
      </el-radio-group>
    </el-form-item>
  </el-form>
  <template #footer>
    <el-button @click="addDialog.visible = false">取消</el-button>
    <el-button type="primary" :loading="addDialog.saving" @click="handleAdd">添加</el-button>
  </template>
</el-dialog>
  • Step 3: 加 handleAdd
const addDialog = reactive({
  visible: false,
  saving: false,
  form: { roomName: '', triggerMode: 'at_mention' as 'at_mention' | 'all' },
});

async function handleAdd() {
  const name = addDialog.form.roomName.trim();
  if (!name) { ElMessage.warning('请输入群名'); return; }
  addDialog.saving = true;
  try {
    const resp = await apiPost('/admin/netaclaw/channel/group/add', {
      channelId: props.channelId,
      roomName: name,
      triggerMode: addDialog.form.triggerMode,
    });
    if (resp.code !== 1000) { ElMessage.error(resp.message || '添加失败'); return; }
    if (!resp.data.created) {
      ElMessage.info('该群已添加');
    } else {
      ElMessage.success('已添加');
    }
    addDialog.visible = false;
    addDialog.form.roomName = '';
    await loadList();
  } finally {
    addDialog.saving = false;
  }
}
  • Step 4: 简化 visibleList
// 不再有 -1 ignored;不再有 statusFilter
const visibleList = computed(() => list.value);  // 全部展示
const enabledCount = computed(() => list.value.filter(g => g.status === 1).length);
  • Step 5: 群卡片操作区简化
<div class="group-card__actions">
  <el-switch v-model="group.status" :active-value="1" :inactive-value="0"
    @change="(v) => handleToggle(group, v as 0 | 1)" />
  <el-radio-group v-model="group._pendingTrigger.mode">
    <el-radio value="at_mention">@机器人</el-radio>
    <el-radio value="all">所有消息</el-radio>
  </el-radio-group>
  <el-button size="small" @click="handleSavePolicy(group)">保存策略</el-button>
  <el-button size="small" type="danger" link @click="handleDelete(group)">删除</el-button>
</div>
  • Step 6: 加 handleDelete
async function handleDelete(group: GroupItem) {
  await ElMessageBox.confirm(`确定移除群 "${group.roomName}" 的监管?`, '确认', { type: 'warning' });
  await apiPost('/admin/netaclaw/channel/group/delete', { ids: [group.id] });
  ElMessage.success('已移除');
  await loadList();
}
  • Step 7: 手工冒烟 + Commit
cd packages/frontend && pnpm dev
# 浏览器测: 添加群 + 删除 + toggle 触发策略
git add -A
git commit -m "feat(agent-fe): channel-group-panel 重构为 用户主动添加 UX (删被动发现+待审批)"

Phase C-7 · 安装包 + Tray 集成验证

Task 24: build-windows-installer.js 删除 bridge 打包 + 加 tools/win32 拷贝

Files:

  • Modify: packages/backend/scripts/build-windows-installer.js

  • Step 1: 删除 bridge 相关代码块

bridge 关键字,删除:

  • bridgeProject 变量

  • bridgeOutputDir 变量

  • 所有 dotnet publish bridge 的 execFileSync 调用

  • 检查 publishedBridgeExePath 的 throw

  • Step 2: 加 tools/win32 拷贝

const toolsDir = path.join(backendDir, 'tools', 'win32');
const toolsOutputDir = path.join(installerStageDir, 'tools', 'win32');
fs.mkdirSync(toolsOutputDir, { recursive: true });
fs.cpSync(toolsDir, toolsOutputDir, { recursive: true });
console.log(`[installer] copied tools/win32 → ${toolsOutputDir}`);
  • Step 3: Commit
git add -A
git commit -m "build(installer): 删 bridge.exe 打包, 加 tools/win32 ps1 拷贝"

Task 25: installer/setup.iss 删 bridge + 加 ps1

Files:

  • Modify: packages/backend/installer/setup.iss

  • Step 1: 找并删 bridge.exe 行

[Files]
Source: "...\bridge\bridge.exe"; DestDir: "{app}\bin\bridge"; ...   ← 删
  • Step 2: 加 ps1
Source: "..\tools\win32\*.ps1"; DestDir: "{app}\tools\win32"; Flags: ignoreversion
  • Step 3: Commit
git add -A
git commit -m "build(installer): setup.iss 删 bridge.exe 加 tools\\win32\\*.ps1"

Task 26: Tray 单测确认不再依赖 bridge

Files:

  • Verify: packages/windows-tray/Neta.Tray.Tests/

  • Step 1: 跑测试

Run: dotnet test packages/windows-tray/Neta.Tray.Tests 2>&1 | tail -10 Expected: 所有测试 pass(BridgeProcessManagerTests 已删除,其他保留)

  • Step 2: 修剩余编译错误(若有)

若 TrayApplicationContext.cs 残留 bridge import,清理。

  • Step 3: Commit(若有改动)
git add -A
git commit -m "chore(tray): 验证编译通过, 无残留 bridge 引用"

Phase C-8 · 端到端手工验证

Task 27: E2E checklist(架构 C 版,占位回复)

比 2026-05-09 spec 14 条精简,因为 5.7 暂不实现。

前置:

  • Windows 测试机装 Weixin 4.1.x 并登录测试号
  • 测试号在 1-2 个测试群里
  • MySQL 测试库已有最新 schema
  • backend + frontend 跑着, NETA_TRAY_SECRET 一致
  • tools/win32/extract-weixin-key.ps1 拷到 <backend cwd>/tools/win32/

Checklist:

  • E2E-1: 前端新建 channel type=微信本地代理(群聊),填 wxid → 创建成功

  • E2E-2: backend 启动 → 日志含 [weixin-db] channel X bound, wxid=...,无 error

  • E2E-3: 前端"群聊管理"打开,显示"已添加监管 0 个群" → 点 "+ 添加群" → 输入测试群完整名 → 提交

  • E2E-4: backend 日志 addByName created=true;DB netaclaw_agent_channel_group 多一行 status=1

  • E2E-5: 在测试群里发"hello"(触发策略 at_mention) → backend 日志看到 [weixin-db] incremental read,但 agent 不回(at_mention 未命中)

  • E2E-6: 群里发 @机器人 你好 → backend 看到 routeInboundMessage 走群路径 → agentExecutor 执行 → 占位 sendText 抛 NotImplementedError → 日志 weixin-db reply 暂未实现, 跳过发送

  • E2E-7: 切到"所有消息"策略 → 在另一个未添加白名单的群发消息 → backend 日志完全没反应(白名单过滤生效)

  • E2E-8: 在白名单群发消息 → backend 日志看到处理

  • E2E-9: 前端删除该群白名单 → DB row 消失 → 再发消息 backend 无反应

  • E2E-10: 关 Weixin → bridge 不再收新消息(无 error,只是 wal 不变);重开 Weixin → backend 重新启动 channel 后恢复

  • Step 1: 逐条手工跑

  • Step 2: 写验证报告

mkdir -p docs/superpowers/followups
echo "# weixin-db E2E 验证 (2026-05-12, 架构 C)" > docs/superpowers/followups/2026-05-12-weixin-db-e2e-report.md
echo "..." >> docs/superpowers/followups/2026-05-12-weixin-db-e2e-report.md
  • Step 3: Commit
git add -A
git commit -m "docs(weixin-db): 架构 C E2E 验证报告"

Phase C-9(占位) · 回复路径 5.7 实施

不在本 plan 范围。需要单独 spec 描述路径选择(剪贴板 / 搜索切群 / WeChatFerry 等),完成后开新 plan。

  • 接口已就绪: WeixinDbService.replyToGroup(channelId, roomName, text) 当前抛 NotImplementedError
  • 入口已就绪: agent_channel.handleDbInbound 已 try/catch 占位错误,改实现时只需让 throw 消失即可走通

自检 (Self-Review)

1. Spec 覆盖:

Spec 章节 覆盖 Task
5.0 架构演进 Task 1-5 + Task 2(删 bridge .NET)
5.2 总体架构(C) Task 16(WeixinDbService)
5.3 解密公式 (reserve=80) Task 7
5.4 WCDB 表结构 Task 11 + Task 12
5.6 入站事件流 + 白名单过滤 Task 14 + Task 12 filterWhitelistedGroupTables
5.7 回复路径(占位) Task 16 replyToGroup throw NotImplemented + Task 17 try/catch + Phase C-9
5.8 白名单数据模型 Task 19 + 20
5.9 routeInboundMessage 分流 Task 17
5.9.5 前端用户主动添加 UX Task 23
5.10 代码改动清单 Phase C-1 + Task 17
5.11 实施里程碑 本 plan README 顶部 M1/M2/M3 划分
5.12 风险与兜底(重连 / 跨平台 / 脚本路径) Task 10(robust 路径)+ Task 16(健康探针 + 跨平台 guard)
5.14 DEV 工作流 + 路径解析 + 跨平台 本 plan "DEV 工作流" 节 + Task 10 + Task 16
session.db schema PoC(前置) Phase C-0(新增,先于其他模块)

2. Placeholder 扫描:

  • Task 12 loadFromSessionDb 的 SQL 用 Phase C-0 PoC 结果回填 → 不再是猜
  • Task 8 zstd sync/async 不匹配 → Task 8.1 兜底
  • Task 16 replyToGroup 抛 NotImplemented → 是 spec 明确占位,不是 placeholder

3. 类型一致性:

  • WeixinDbInboundRow / WeixinDbPseudoMessage(types.ts) 在 Task 15 / 16 / 17 / 18 一致
  • MessageRow / RoomInfo 字段在 Task 11 / 12 / 14 / 15 一致
  • channel.type 'weixin-db' 在 Task 17 / 18 / 21 / 22 / 23 一致
  • loginStatus 值集: 'connected' | 'disconnected' | 'unsupported_platform' 在 Task 16 写入 / Task 22 前端展示

4. 跨 Phase 衔接:

  • Phase C-0 验证 session.db schema → Phase C-2 Task 12 直接用结果(不再 RE 推测)
  • Phase C-1 删除 → Phase C-2 起干净工作区
  • Phase C-2 子模块独立 TDD → Phase C-3 装配
  • Phase C-5 addByName → Phase C-6 前端 /add 调用
  • Phase C-7 安装包改 → Phase C-8 E2E 验证

5. 里程碑对齐:

  • M1 完成点: Task 1-20(到 controller /add)+ Task 23 (前端添加群)。此时用户在前端加群名,backend 实时读群消息到 chat 页,bot 因 5.7 占位不回,但能看到消息流
  • M2 完成点: Task 24-27(installer + E2E)
  • M3 占位: 独立 spec 实施 replyToGroup

6. DEV 可行性:

  • 所有 runtime/weixin_db/ 子模块均可在 pnpm dev 下开发 + jest 单测
  • 真实 Weixin 集成只需要 Windows 开发机;Linux/Mac 单测 mock ps1 spawn,照跑
  • 改 ps1 不需要重启 backend(下次 spawn 用最新版)

Execution Handoff

Plan 完整保存在 docs/superpowers/plans/2026-05-12-weixin-db-channel.md

优先级:先完成 M1 · 读 + 展现(Phase C-0 → C-4,加部分 C-5 的 addByName + C-6 的添加群 UI)。这是最小可演示版本,用户能加群 + 看到消息流到 chat 页面。 M2 补 UX / installer / E2E。M3(自动回复)独立 spec。

两种执行方式选一:

1. 子 agent 驱动(推荐) —— 每 Task 派 fresh subagent,期间 review,迭代快。 2. 当前会话内执行 —— executing-plans,checkpoint 评审。

哪一种?