# 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_` 表;非白名单群的消息**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//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_` 表名映射到群名;从 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.status` 改 `0 \| 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.ts` 的 `pWechatUploadsPath` 函数 | 不再需要 | | `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 是 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.db` 到 `session_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`: ```js 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_ 表, // 从 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 + 观察 + 记录** ```bash 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** 把发现记录下来: ```markdown # 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** ```bash 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: 批量删除** ```bash 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** ```bash 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 项目目录** ```bash 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** ```bash 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** ```bash 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** ```bash 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` 对象删除: ```ts wechatUploads: { prefix: '/wechat-uploads', dir: pWechatUploadsPath(), }, ``` 顶部 import 改回: ```ts import { pCachePath, pDataPath, pUploadPath, pWorkspacePath } from '../comm/path'; ``` - [ ] **Step 2: 删除 path.ts 中 pWechatUploadsPath 函数** 打开 `path.ts`,删除整段: ```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: 空 ```bash 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: 删除文件** ```bash 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** ```bash 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: 安装依赖** ```bash 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** ```bash 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`: ```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`: ```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** ```bash 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`: ```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`: ```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 ``; } } 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`,同时调整调用方。**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** ```bash git add -A git commit -m "feat(weixin-db): zstd_decode.ts 解 message_content zstd 压缩" ``` --- ### Task 8.1(若需):把 tryDecompressToString 改为 async 只在 Task 8 测试失败时执行。 - [ ] **Step 1: 改签名** ```ts export async function tryDecompressToString(buf: Buffer | null | undefined): Promise { if (!buf || buf.length === 0) return ''; if (hasZstdMagic(buf)) { try { return (await decompress(buf)).toString('utf8'); } catch { return ``; } } return buf.toString('utf8'); } ``` - [ ] **Step 2: 测试也改 await,确认通过** - [ ] **Step 3: Commit** ```bash 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: 写测试** ```ts 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: 写实现** ```ts 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** ```bash 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`: ```powershell # extract-weixin-key.ps1 # 输入参数: -SeedDir (可选, 默认自动找 Documents/xwechat_files/ 最近目录) # 输出 (stdout): JSON { wxid: ..., dbKeys: { "message_0.db": "", ... } } # 退出码: 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`: ```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; // 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 部署路径 `/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 { 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((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 跑着的情况下): ```bash # 应能自动找到脚本(模块相对路径命中) 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 运行,无法单测) ```bash # 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** ```bash 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`: ```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 { 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) ```ts 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** ```bash 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)` 判断 > **研究待办**:Msg 表名是 md5(username) 还是 md5(chatroom_id)?session.db 中实际存的关系需要 Phase 实施时 RE(反向工程)确认。**本 Task 先实现接口骨架**,具体 SQL 在集成测试阶段补全。 - [ ] **Step 1: 写实现(骨架 + 待确认的 SQL)** 写入 `src/.../room_resolver.ts`: ```ts import Database from 'better-sqlite3'; /** * 从 session.db 解析 Msg_ 表名到群名的映射。 * 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(); 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[] { 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)** ```ts 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** ```bash 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: 写实现** ```ts 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, 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 { const m = this.readMtime(); if (m === 0 || m === this.lastMtime) return; this.lastMtime = m; await this.onChange(); } } ``` - [ ] **Step 2: Commit** ```bash 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: 写实现** ```ts 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; // 每次读调用,支持用户实时改白名单 } export class IncrementalReader { private lastTsByTable = new Map(); private roomResolver = new RoomResolver(); constructor(private readonly cfg: IncrementalReaderConfig) {} /** 一次性:解密 session.db 加载 room 映射 + 解密 message_0.db + 初始化每表 baseline。 */ async initialize(): Promise { // 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 { 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** ```bash 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** ```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** ```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: 写测试** ```ts 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** ```bash 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`: ```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; } 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; @InjectEntityModel(NetaClawAgentChannelEntity) channelRepo: Repository; private readonly runtimes = new Map(); private healthTimer: NodeJS.Timeout | null = null; @Init() async onInit(): Promise { 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, ): Promise { 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 { 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 { 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> { 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)** ```ts 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** ```bash 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** 把: ```ts import { WeixinUiaService } from './weixin_uia.js'; import { WechatArchiveService } from './wechat_archive.js'; import { buildPseudoMessageFromUia, resolveReplyIdentity, type UiaInboundPayload, } from '../runtime/wechat_uia_routing.js'; ``` 改为: ```ts import { WeixinDbService } from './weixin_db.js'; ``` (不需要 import 旧 `routing` 文件,新 PseudoMessage 直接由 weixin_db 内部投影) - [ ] **Step 2: 改字段** ```ts @Inject() weixinDbService: WeixinDbService; ``` (删 `weixinUiaService` 和 `wechatArchiveService`) - [ ] **Step 3: 全文 sed 替换字面量** ```bash 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` 方法(同上) - `runLoop` 中 `case 'weixin-uia':` 整个分支 - [ ] **Step 5: runLoop 加 weixin-db 启动逻辑** 定位 runLoop 方法,在 type='weixin' 分支后加: ```ts 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) 中加: ```ts 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 处**: ```ts private async handleDbInbound(channel, state, message): Promise { // 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)` 改为: ```ts this.weixinDbService.unbindChannel(id); // (不再有 wechatArchiveService.closeForChannel) ``` - [ ] **Step 9: 跑 type-check** ```bash 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 成果未破坏** ```bash pnpm --filter @neta/backend test -- runtime/chat_scope service/agent_channel_group 2>&1 | tail -10 ``` Expected: 全 pass - [ ] **Step 11: Commit** ```bash 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: 写测试** ```ts 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** ```bash 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 方法** ```ts 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: ```ts async toggle(id: number, status: 0 | 1): Promise { 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` 测试删除;改为: ```ts 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** ```bash 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** ```ts @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** ```bash 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.ts` → `useDbChannelValidation.ts` - [ ] **Step 1: 改类型** ```ts export interface AgentChannelInfo { type: 'weixin' | 'weixin-db'; // ... } export interface AgentGroupItem { status: 0 | 1; // 删 -1 triggerMode: 'at_mention' | 'all' | 'prefix'; // ... } ``` - [ ] **Step 2: 改 composable** ```bash git mv packages/frontend/src/modules/agent/composables/useUiaChannelValidation.ts \ packages/frontend/src/modules/agent/composables/useDbChannelValidation.ts ``` - 内部:`useUiaChannelValidation` → `useDbChannelValidation`,`findOtherUiaChannelWithWxid` → `findOtherDbChannelWithWxid`,`type: 'weixin-uia'` → `'weixin-db'` - [ ] **Step 3: 跑 type-check + Commit** ```bash 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** ```bash 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** 搜全文: - 删 `` 横幅 - 删 "忽略" 按钮(status=-1) - 删 `pendingCount` / `ignoredCount` / `statusFilter` 相关 computed - 删 "查看已忽略" 切换按钮 - [ ] **Step 2: 在头部加 "+ 添加群" 按钮 + 弹窗** ```vue
已添加监管 {{ list.length }} 个群 · 已启用 {{ enabledCount }}
+ 添加群
@机器人 所有消息 ``` - [ ] **Step 3: 加 handleAdd** ```ts 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** ```ts // 不再有 -1 ignored;不再有 statusFilter const visibleList = computed(() => list.value); // 全部展示 const enabledCount = computed(() => list.value.filter(g => g.status === 1).length); ``` - [ ] **Step 5: 群卡片操作区简化** ```vue
@机器人 所有消息 保存策略 删除
``` - [ ] **Step 6: 加 handleDelete** ```ts 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** ```bash 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 拷贝** ```js 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** ```bash 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** ```bash 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(若有改动)** ```bash 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` 拷到 `/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: 写验证报告** ```bash 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** ```bash 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 评审。 哪一种?