# 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 评审。

哪一种?






