2657 lines
96 KiB
Markdown
2657 lines
96 KiB
Markdown
|
|
# 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 评审。
|
||
|
|
|
||
|
|
哪一种?
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
|