GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-05-12-weixin-db-channel.md

2657 lines
96 KiB
Markdown
Raw Normal View History

2026-05-20 21:39:12 +08:00
# 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.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>` 表名的 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_<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 + 观察 + 记录**
```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_<sha> 表名后缀
- [ ] **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 `<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**
```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<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**
```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 <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`:
```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 跑着的情况下):
```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<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)
```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<string>)` 判断
> **研究待办**: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_<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)**
```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<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**
```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<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**
```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<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)**
```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<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)` 改为:
```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<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` 测试删除;改为:
```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**
搜全文:
-`<el-alert ... title="新发现 N 个群">` 横幅
- 删 "忽略" 按钮(status=-1)
-`pendingCount` / `ignoredCount` / `statusFilter` 相关 computed
- 删 "查看已忽略" 切换按钮
- [ ] **Step 2: 在头部加 "+ 添加群" 按钮 + 弹窗**
```vue
<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**
```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
<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**
```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` 拷到 `<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: 写验证报告**
```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 评审。
哪一种?