GPU_GUARD_MONOREPO/docs/superpowers/specs/2026-05-08-weixin-group-channel-design.md
2026-05-20 21:39:12 +08:00

1264 lines
76 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: Weixin 群聊渠道接入设计
created: 2026-05-08
updated: 2026-05-12
status: **方案 5 (WCDB DB 读+SendInput) 已被选为最终方案**(2026-05-12 更新);原 ClawBot/WCF/WeCom 讨论保留作为决策记录
---
> **📌 2026-05-12 最终结论**:
> 1. **原 2026-05-08 Phase 1-6 实施的所有与传输层无关的代码(chat_scope / errors / group entity / agent_channel_group service+controller / agent_executor chatScope / 前端 UI)全部保留**,复用率 ~90%。
> 2. **传输层从 "iLink ClawBot HTTP" 切换到 "WCDB SQLCipher DB 读取 + Win32 SendInput 回复"**(新渠道类型 `weixin-db`)。
> 3. 2026-05-09 的 UIA 方案(spec `2026-05-09-wechat-uia-channel-design.md`)因 Weixin 4.x 用 Qt 自绘 UI(UIA 树空)**作废**。
> 4. **已验证**:Weixin 4.1.8.107 的 `message_0.db` 可完整解密(见 `docs/superpowers/specs/2026-05-11-weixin-4x-db-decrypt-progress.md`)。
>
> 本文档的**历史方案分析(方案 1-4)保留**作为决策证据;**方案 5 的实施规范写在本文档末尾**。请优先阅读文档末尾 "方案 5 · 落地规范" 章节。
# Weixin 群聊渠道接入设计
## Context
> ⚠️ **下文 "Context" 到 "方案 4 分析" 为 2026-05-08 原始设计讨论 + 2026-05-08 "后记"的历史记录**。
> 请优先阅读文档顶部的 2026-05-12 更新摘要 + 文档末尾的 "方案 5 · 落地规范"。
> 原文保留为决策证据;下文任何与方案 5 冲突的细节以方案 5 为准。
当前 Neta 的微信渠道(`packages/backend/src/modules/netaclaw/service/agent_channel.ts`)在 `routeInboundMessage` 里遇到带 `room_id` 的消息直接 `return`,彻底屏蔽了群聊。业务场景需要把同一个绑定 bot 的 agent 同时服务于私聊与群聊,典型场景例如客服群、项目研发群、家人群。
参考实现hermes-agent 的 weixin 适配器已经支持群聊,核心是"三档策略 + chat_type 区分 + sessionId 多维隔离",但 hermes 的群准入靠人类去另一个地方粘贴 room_id 白名单UX 较差。本设计采用"被动发现 + 默认禁用 + 前端 toggle 启用"的方式,兼顾安全与便利。
接入群之后必须解决:
1. **如何加入群**:没有 iLink 侧的 join API全靠人类在手机微信里把 bot 拉进群;代码侧做"被动发现 + 可见清单"。
2. **何时回话**:群里每条消息都回答会造成风控+噪音,必须支持 "@机器人 / 前缀 / 所有消息" 三档策略,按群独立配置。
3. **日志在哪看**:复用现有 agent 对话页的会话列表 + session tree不新建页面。
## 总体架构
```
微信群消息
└─ weixin.ts.getUpdates
└─ agent_channel.ts.routeInboundMessage同步、零 DB I/O
├─ decideChatScope(message, accountId) → dm / group
├─ dm 路径:先查 pendingClarify[dm key],命中则同步 resolve否则入 DM senderQueue
└─ group 路径:
① 同步:先查 pendingClarify[group key=(cid,roomId,senderId)],命中则 consumePendingClarifyReply 同步 resolve不入队
② 否则构造 chatScope按 chat scope 选 senderQueue key
· DM key = channel:<cid>:weixin:<senderId>
· 群 key = channel:<cid>:weixin:group:<roomId> ← 群级共享,所有人串行
③ fire-and-forget 入 senderQueue
└─ senderQueue 异步链(首位是群路径专属预处理):
① ChannelGroupService.upsertOnInbound被动发现默认 disabled
② 读 group entity → decideGroupAcceptancestatus + triggerMode 判定)
③ 拒绝 → 丢弃并日志reject 走 debug 级别,避免群活跃刷屏)
④ 通过 → stripLeadingMention/stripLeadingPrefix 清洗文本
⑤ handleInboundMessage(channel, state, scope, cleanedText)
├─ agentExecutor.execute({ chatScope, message, onClarifyRequest, ... })
│ ├─ beforeToolCallchatScope==='group' && detectToolRisk 命中 → throw GroupInteractionDeclineError
│ ├─ onClarifyRequest群路径发文本选项到群 + pendingClarify[group key] + 300s timeout
│ ├─ runner 主体 catch (isInteractionDecline) → finalContent=declineMessage short-circuit
│ └─ 写入 assistant entry + metadata
├─ ChannelGroupService.touchActive仅群路径
└─ weixinService.sendText(to=replyTarget, result.content)
· DM replyTarget = senderId
· 群 replyTarget = roomId拒绝文字 / clarify 选项 / 正常回复走同一路径)
```
- **关键不变量**`routeInboundMessage` 严格同步、零 DB I/O与上一轮死锁修复保持一致。所有 DB 写(`upsertOnInbound` / `touchActive`)都发生在 senderQueue 异步链路里。
- **群级串行P0-1 决策)**:群 sessionId 全 sender 共享同一个 senderQueue保证 session tree 写入有序,**LLM 看到的群对话是连贯的多人线程**而非 fork 出的孤岛分支。代价是 A 在等 agent 跑期间 B 的群消息要排队,群机器人可接受。
- **群内风险确定性拒绝**:命中 `detectToolRisk` 的工具在群里**不走确认流程**,直接抛 `GroupInteractionDeclineError`runner short-circuit 把拒绝文字当作 `finalContent` 返回,`agent_channel` 把它发到群里。不依赖 LLM 自由发挥。
- **群内 Clarify 受约束地支持**Clarify 无害群里允许pendingClarify key 含 senderId 确保只收发起者回复Promise 加 300 秒超时避免 senderQueue 被卡住;超时用同一个 `GroupInteractionDeclineError` short-circuit。
- sessionId 规则:
- DM`channel:<cid>:weixin:<senderId>`(保持兼容)
- 群:`channel:<cid>:weixin:group:<roomId>` —— **一群一会话**(真共享)
- 前端不新增对话页面;`channel-management.vue``群聊管理` 抽屉agent 对话页列表自动显示群会话。
## 数据模型
### 新增表 `netaclaw_agent_channel_group`
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | bigint PK auto | 自增主键 |
| `channelId` | bigint, FK logical | 指向 `netaclaw_agent_channel.id`(项目规范不用 FK 约束service 层显式级联) |
| `roomId` | varchar(255) | iLink room_id`12345@chatroom`255 兜底未知上限 |
| `roomName` | varchar(256) nullable | 群名,首次可空,可刷新 |
| `status` | tinyint default 0 | `0 = disabled默认` / `1 = enabled` |
| `triggerMode` | varchar(32) default `at_mention` | `at_mention` / `prefix` / `all` |
| `triggerPrefix` | varchar(64) nullable | triggerMode=prefix 时必填 |
| `firstSeenAt` | datetime | 首次发现时间 |
| `lastSeenAt` | datetime | 最近一次群消息触达 |
| `lastActiveAt` | datetime nullable | 最近一次 bot 真正回复的时间 |
| `createTime / updateTime` | datetime | 标准字段 |
索引:`UNIQUE(channelId, roomId)`
**级联清理P1-4**channel 删除路径必须显式 `groupRepo.delete({ channelId })`;不依赖 FK ON DELETE。
**并发 upsertP1-3**`upsertOnInbound``findOne` + `update`(已存在)/ `repo.upsert`(不存在 + race 时 catch + 重查 + update lastSeenAt的组合实现既保留 `firstSeenAt` 不被覆盖TypeORM `upsert` 不支持条件更新),又规避并发竞态。返回 `{ created: boolean }` 让上层决定是否打"群被发现"日志。
### `netaclaw_agent_channel.config` 扩展Json
```ts
interface NetaClawAgentChannelConfig {
// …existing…
group?: {
/** bot 在群里显示的昵称,用于 @ 匹配的文本回落P1-1 强校验项) */
botAlias?: string;
};
}
```
**不加全局 groupPolicy 白名单字段**——准入已经落到 `netaclaw_agent_channel_group.status` 表里,实现等价 allowlist 语义但列表由后端被动发现填充,无需人工粘贴 roomId。
### 会话 / dispatch key 规则
```ts
export function buildDmSessionId(channelId: number, senderId: string): string {
return `channel:${channelId}:weixin:${senderId}`;
}
export function buildGroupSessionId(channelId: number, roomId: string): string {
return `channel:${channelId}:weixin:group:${roomId}`;
}
export function buildDmDispatchKey(channelId: number, senderId: string): string {
return `channel:${channelId}:weixin:${senderId}`;
}
export function buildGroupDispatchKey(channelId: number, roomId: string): string {
return `channel:${channelId}:weixin:group:${roomId}`; // 群级共享,不含 senderId
}
export function buildDmClarifyKey(channelId: number, senderId: string): string {
return `channel:${channelId}:weixin:${senderId}`;
}
export function buildGroupClarifyKey(channelId: number, roomId: string, senderId: string): string {
return `channel:${channelId}:weixin:group:${roomId}:${senderId}`; // 群里 clarify 仍按发起者收答
}
```
`senderQueues` 使用 dispatch key 作为队列 key
| 场景 | dispatch key | 并发行为 |
|---|---|---|
| 私聊 A | `channel:1:weixin:A` | A 自己消息按到达顺序串行;与 B 并发 |
| 私聊 B | `channel:1:weixin:B` | 与 A 并发 |
| 群 R 内 A | `channel:1:weixin:group:R` | A、B、C 在群 R 的消息**全部串行**,按到达顺序排队 |
| 群 R 内 B | `channel:1:weixin:group:R` | 同上(共享队列) |
| 群 R2 内任何人 | `channel:1:weixin:group:R2` | 与群 R 并发 |
| 私聊 A | `channel:1:weixin:A` | 与群路径并发,私聊不被群里堵塞 |
**pendingClarify 在群里仍然支持**(详见"风险拒绝与 Clarify"节)。两套 key 系统并存:
- `senderQueues` 用 dispatch key群级共享保证群内多人消息严格按到达顺序串行进 agent。
- `pendingClarify` 用 clarify key含 senderId保证 clarify 只接受**发起者**本人的回复,群里其他人乱回的"1"会被忽略走 trigger 判定。
`routeInboundMessage` 在群路径里**先做 pending reply 短路**
```
if pendingClarify.has(buildGroupClarifyKey(cid, roomId, senderId)):
consumePendingClarifyReply(...) # 同步 resolve不入 senderQueue
return
```
这样发起者的回复永远走"答复" 路径,不会被解析为新消息再次触发 trigger 判定。
## 触发策略与风险处理
### 准入判定
```ts
function decideGroupAcceptance(
group: NetaClawAgentChannelGroupEntity,
message: WeixinInboundMessage,
text: string, // routeInboundMessage 已用 weixinService.extractText 提取过的文本
channelConfig: NetaClawAgentChannelConfig,
credential: WeixinCredential,
): { accept: boolean; cleanedText?: string; reason?: string }
```
**关键text 由调用方一次性提取并传入**,避免 chat_scope.ts 的纯函数版与 weixinService.extractText 的业务版双源不一致P0-1 修订)。`detectAtMention` 同样接收 `text` 入参。
流程:
1. `group.status !== 1` → reject `group_disabled`
2.`triggerMode` 分流text 由调用方提供):
- `all`:无条件 acceptcleanedText = text
- `prefix`text 起始匹配 `group.triggerPrefix` → accept 并 `stripLeadingPrefix`;否则 reject
- `at_mention`(默认):协议字段优先 → 文本回落 → accept 并 `stripLeadingMention`;否则 reject
### `detectAtMention` 双层检测
签名:`detectAtMention(message, text, aliases, accountIds): boolean``text` 由调用方提供,与 `decideGroupAcceptance` 同步。
1. **协议层**:先对 `message` / `item_list[*]` 扫描常见字段名 `at_user_list` / `mention_list` / `at_info`(开发首日先打 raw payload 日志,确认 iLink 真实字段名后再固化代码——见 P1-7。命中条件list 中含 `credential.accountId``credential.userId`
2. **文本回落**:构造别名集合 `aliases = [config.group.botAlias, credential.nickname].filter(Boolean)`。如果两者都为空,文本回落直接判 false前端在保存触发策略时会强校验避免出现这种状态——见 P1-1
匹配正则要求 alias 是**完整 token**,且 `@` 前必须是空白或字符串开头(防止 `email@小神.com` 这类邮箱地址误命中——P2-6 修订):
```ts
// @ 前必须是 字符串开头 / 空白 / 中文标点 / U+2005 / U+3000
// alias 后必须紧跟 空白 / U+2005 / U+3000 / 标点 / 字符串结束
const trailing = `(?=$|[\\s\\u2005\\u3000\\p{P}])`;
const leading = `(?<=^|[\\s\\u2005\\u3000])`;
const atRegex = new RegExp(`${leading}@(?:${aliases.map(escape).join('|')})${trailing}`, 'u');
```
`\p{P}` 覆盖中英标点(需 ES2018+ Unicode property escapes
误伤测试用例(见测试节):
- `@小神 你好` → 命中alias=小神)
- `@小神同学 你好` → 不命中alias 后紧跟汉字)
- `今天 @小神 帮看下` → 命中(中段也算 @ 命中——agent 拿完整文本trigger 只是开关)
- `@小神聊天群 大家好` → 不命中("群"字接在 alias 后无分隔符)
- `我的邮箱 email@小神.com` → 不命中(@ 前是字母,不是空白——新增 leading 约束)
- `foo@小神 帮我查` → 不命中(同上)
- `@小神,你好` → 命中alias 后紧跟中文逗号,\p{P} 覆盖)
### 清洗文本
- `stripLeadingMention(text, aliases)`**仅移除文本起首处**的 `@<alias>` 与随后空白;中段的 `@` 保留(可能是 @ 群里其他人)。
```ts
const leading = new RegExp(`^@(?:${aliases.map(escape).join('|')})[\\s\\u2005\\u3000]*`, 'u');
return text.replace(leading, '');
```
- `stripLeadingPrefix(text, prefix)`:去除起首处前缀与随后空白。
清洗后的文本作为 `agentExecutor.execute({ message })`,避免污染 agent context。
### 风险工具:群内必须显式回复"已拒绝"
群里风险工具不能"静默 block"——必须确定性把拒绝消息发到群里,告知用户为什么没动作。**不能依赖 LLM 自由发挥是否说明**。
实现机制:自定义错误 + runner short-circuit。
```ts
// runtime/errors.ts新增
export class GroupInteractionDeclineError extends Error {
readonly isInteractionDecline = true;
constructor(public readonly declineMessage: string) {
super(declineMessage);
this.name = 'GroupInteractionDeclineError';
}
}
```
`agent_executor` runner 的 `beforeToolCall`
```ts
const risk = detectToolRisk(name, runtimeArgs);
if (risk) {
if (params.chatScope === 'group') {
throw new GroupInteractionDeclineError(
`⚠️ 群内禁止高风险操作:${toolLabel || name}\n原因${risk}\n👉 请点我头像私聊 bot 继续`
);
}
// dm 路径继续走 onRiskConfirmRequest
}
```
runner 主体外层 try/catch 识别此错误并 short-circuit
```ts
try {
runResult = await this.agentRunner({ ... });
} catch (err: any) {
if (err?.isInteractionDecline) {
return {
finalContent: err.declineMessage,
thinking: thinkingText || undefined,
usage: undefined,
toolCallCount: toolExecutions.length,
metadata: buildToolExecutionMetadata(toolExecutions),
};
}
// 原有错误处理(标记 running tool 为 error、加 runtimeMetadata 等)
throw err;
}
```
效果:
- `chat_orchestrator.finalizeAssistantEntry` 拿到 `finalContent` 写入 session。
- `agent_channel.handleInboundMessage` 现有路径 `weixinService.sendText(replyTarget, result.content)` **直接把这条拒绝文字发到群里**,无需额外分支。
- assistant entry 的 metadata.skillExecutions 仍包含被 block 的 tool 条目(带 status='error'、reason前端对话页能看到完整决策轨迹。
### Clarify 在群内允许,但有三条约束
Clarifyagent 主动问选择题)本身不危险,群里允许使用,但要解决"谁来回答"和"会不会卡住"。
**约束 1 · 答复者必须是发起者**pendingClarify key 含 senderId见 key 规则节)。其他人发的"1/2"不会被识别为答复,继续走 trigger 判定。
**约束 2 · routeInboundMessage 先做 pending 短路**:群消息先查 `pendingClarify[group clarify key]`,命中即 `consumePendingClarifyReply` 同步 resolve不入 senderQueue、不再走 trigger 判定。避免发起者的"1"被当成新消息触发新对话。
**约束 3 · 强制超时**:发起者长期不回复会让群 senderQueue 被该 await 占住,后续群消息全部排队。群路径的 clarify Promise 加默认 300 秒超时:
```ts
function withClarifyTimeout<T>(promise: Promise<T>, timeoutMs = 300_000): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(
() => reject(new GroupInteractionDeclineError('⏱️ 等待 5 分钟未收到回复,已自动取消本次询问')),
timeoutMs,
),
),
]);
}
```
超时复用同一个 `GroupInteractionDeclineError`runner short-circuit 后 finalContent 自动发到群pendingClarify 在 finally 里清理senderQueue 释放。
`agent_channel.ts` 群路径下 `onClarifyRequest` 实现:
```ts
onClarifyRequest: async (question, choices) => {
let msg = `❓ ${question}`;
if (choices?.length) {
msg += '\n' + choices.map((c, i) => `${i + 1}. ${c}`).join('\n');
msg += `\n\n仅 @${botAlias || '机器人'} 的发起者本人可回复5 分钟超时)`;
}
await this.weixinService.sendText(credential, roomId, msg, contextToken);
const clarifyKey = buildGroupClarifyKey(channelId, roomId, senderId);
const promise = new Promise<string>(resolve => {
this.pendingClarify.set(clarifyKey, { resolve, choices });
});
return withClarifyTimeout(promise, 300_000)
.finally(() => this.pendingClarify.delete(clarifyKey));
}
```
DM 路径 onClarifyRequest 不加超时(私聊场景没有"卡住整个群"的问题),保持现状。
### 风险确认仅在 DM 提供
群路径**不传** `onRiskConfirmRequest`。即使误传,前置 `chatScope==='group'` 分支已 throw `GroupInteractionDeclineError`,不会走到回调。
### 持久化钩子
- `upsertOnInbound(channelId, roomId, roomName?) -> { created: boolean }`:先 `findOne` 查询;命中只 update `lastSeenAt`(与可选 `roomName`),返回 `{ created: false }`;未命中走 `repo.upsert` 落 `firstSeenAt + lastSeenAt + status=0 + triggerMode=at_mention`,返回 `{ created: true }`。upsert 抛 race 异常时 catch + 重查 + update lastSeenAt 兜底,仍返回 `{ created: false }`。**`created: true` 触发上层打 `group discovered` info 日志。**
- `touchActive(channelId, roomId)`:在 `handleInboundMessage` 的 `sendText` 成功后调用,更新 `lastActiveAt`。
## 前端 UX
### 频道卡片(`channel-management.vue`
- 元数据行加 `群聊 X/Y` 徽标(`X=enabled`, `Y=discovered`)。已连接的微信频道才显示。
- 操作栏加按钮:`群聊管理`。
- 频道编辑 drawer 增加字段:`微信机器人昵称`(绑到 `config.group.botAlias`)。
### 群聊管理抽屉(新组件 `channel-group-panel.vue`
```
┌─ 群聊管理 · 老板微信 ─────────────────────[x]─┐
│ │
│ 共发现 5 个群 · 已启用 2 个 [刷新] │
│ │
│ 💡 在手机微信把本账号拉进群即可自动发现。 │
│ 新群默认禁用,请在下方逐个启用。 │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ 产品研发群 [●启用] │ │
│ │ roomId: 12345@chatroom │ │
│ │ 首次发现 4 天前 · 最近消息 12 分钟前 │ │
│ │ │ │
│ │ 触发策略 (●) @机器人 │ │
│ │ ( ) 前缀 │ │
│ │ ( ) 所有消息 │ │
│ │ 前缀 [___________] (仅前缀模式) │ │
│ │ │ │
│ │ [查看对话记录] [保存] │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ 家人群 [○禁用] ... │ │
│ └──────────────────────────────────────┘ │
│ │
│ 📌 禁用后 bot 不再回复该群消息,但仍留在群内。│
│ 如需让 bot 离开群,请在手机微信中移除。 │
└──────────────────────────────────────────────┘
```
- 状态 switch即点即切toggle API
- 触发策略 / 前缀:必须点 `保存` 才提交,避免误触。
- **前端强校验P1-1**:保存触发策略时,若选 `at_mention` 但 channel.config.group.botAlias 为空,弹警告"请先回到频道编辑页填写 bot 昵称,否则群里永远不会被识别为 @机器人"。
- `查看对话记录` 按钮 → 跳 `/agent?sessionId=channel:<cid>:weixin:group:<roomId>&agentId=<bound>`。
### agent 对话页群会话识别
- `store/chat.ts` 的 `buildFallbackTitle` 扩展:
- sessionId 以 `channel:<cid>:weixin:group:` 起头 → 前缀 `微信群 · `(取 roomName 或 roomId 后 8 位)
- sessionId 以 `channel:<cid>:weixin:` 起头但非 group → 前缀 `微信 · `
- **依赖 sessionId 字符串模式判别 chat kind**,不写专门的 `metadata.chat` 字段——sessionId 模板已经携带足够信息channelId / kind / roomId-or-senderId增加一份冗余 metadata 没有额外收益。前端渲染 icon 时用同一份正则判定。
- `chat.vue` mounted 检测 `route.query.sessionId / agentId`,自动切到对应 session。**鲁棒性P2-f**agentId 必须是数字字符串,否则忽略 query 走默认逻辑。
## 代码模块与接口
### 后端新增
| 文件 | 职责 |
|---|---|
| `entity/agent_channel_group.ts` | TypeORM Entity |
| `service/agent_channel_group.ts` | upsertOnInbound / toggle / updatePolicy / rename / touchActive / list / cascadeDeleteByChannel |
| `controller/admin/agent_channel_group.ts` | `@CoolController` + 自定义 list/toggle/updatePolicy/rename/delete |
| `runtime/chat_scope.ts` | 全部纯函数decideChatScope / buildXxxSessionId / buildXxxDispatchKey / detectAtMention / stripLeadingMention / stripLeadingPrefix / decideGroupAcceptance |
### 后端修改
| 文件 | 改动 |
|---|---|
| `service/agent_channel.ts` | routeInboundMessage 按 chat_scope 分流;群路径先查 pendingClarify 短路senderQueues key 改为 dispatch key群级共享upsert/decideGroupAcceptance 移到 senderQueue 异步链;群路径 onClarifyRequest 发群消息 + 300s 超时 |
| `service/agent_executor.ts` | execute() 入参加 chatScopebeforeToolCall 命中风险时群路径抛 `GroupInteractionDeclineError`runner 外层识别该错误 short-circuit 为 finalContent |
| `runtime/errors.ts`(新增) | `GroupInteractionDeclineError` 自定义错误 + `withClarifyTimeout` helper |
| `service/agent_channel.ts` 的 `delete()` 路径 | 调 `agentChannelGroupService.cascadeDeleteByChannel(id)` |
| `configuration.ts` / 入口 | 注册 NetaClawAgentChannelGroupEntity |
| (可选)`service/weixin.ts` | 增加 `sendGroupText` 语义别名,实质复用 sendText |
### 后端 REST 新增
```
POST /admin/netaclaw/channel/group/list { channelId } → list
POST /admin/netaclaw/channel/group/toggle { id, status }
POST /admin/netaclaw/channel/group/updatePolicy { id, triggerMode, triggerPrefix? }
POST /admin/netaclaw/channel/group/rename { id, roomName }
POST /admin/netaclaw/channel/group/delete { ids }
```
服务端校验:
- `updatePolicy` 在 `triggerMode='prefix'` 时必须携带非空 `triggerPrefix`。
- `updatePolicy` 在 `triggerMode='at_mention'` 时校验所属 channel.config.group.botAlias 非空,否则返回 4xx 提示前端补 botAlias。
### 前端新增
| 文件 | 职责 |
|---|---|
| `components/channel-group-panel.vue` | 群列表抽屉 |
| `api/channel_group.ts` | list/toggle/updatePolicy/rename/delete 封装 |
### 前端修改
| 文件 | 改动 |
|---|---|
| `views/channel-management.vue` | 卡片徽标 + "群聊管理" 按钮 + botAlias 表单字段 |
| `store/chat.ts` | buildFallbackTitle 扩展route.query 自动切 session鲁棒性 |
| `views/chat.vue` | 渲染群/DM iconmounted 里处理 `?sessionId=` |
## 测试与验证
### 单元测试
| 文件 | 覆盖 |
|---|---|
| `test/modules/netaclaw/runtime/chat_scope.test.ts` | decideChatScope 四形态sessionId/dispatchKey 构造detectAtMention 协议命中 / 文本命中 / 误伤("@小神同学"、"@小神聊天群"stripLeadingMention 处理 U+2005、U+3000stripLeadingPrefix中段 @ 不被剥除 |
| `test/modules/netaclaw/service/agent_channel_group.test.ts` | upsertOnInbound 首次 insert 默认 disabledexisting 仅更新 lastSeenAt并发 upsert 不抛 duplicate keytoggle/updatePolicy 非法入参prefix 模式缺 prefix、at_mention 模式缺 botAlias拒绝cascadeDeleteByChanneltouchActive |
| `test/modules/netaclaw/service/agent_channel.group.test.ts` | routeInboundMessage 端到端:群被动发现默认不 dispatchenable+at_mention 下 @bot 通过、普通消息拒triggerMode=all 全部通过;**同群多人消息按到达顺序串行**A 发起后 B 紧跟也排队,不并发开 session不同群间并发**群路径 pendingClarify 命中短路**(发起者回"1"直接 resolve 不入 senderQueue**非发起者回"1"不被当作答复**,走常规 trigger 判定 |
| `test/modules/netaclaw/service/agent_executor.test.ts`(扩展) | chatScope='group' + 风险命中 → runner 抛 `GroupInteractionDeclineError`,外层 short-circuit 为 finalContentonRiskConfirmRequest 未被调用finalContent 含拒绝说明文字且 metadata.skillExecutions 含被 block 的条目;**chatScope='group' + Clarify 正常**onClarifyRequest 调用后 resolve 正常返回;**Clarify 超时 path**pendingClarify 长期不 resolve → withClarifyTimeout 抛 `GroupInteractionDeclineError` → runner short-circuit 为超时文案dm 保持现状 |
### 手工验证
1. 后端 `pnpm --filter @neta/backend dev`、前端 `pnpm --filter @neta/frontend dev`
2. 在频道编辑页填写 bot 昵称botAlias
3. 手机微信把 bot 拉进测试群
4. 群里发一条普通消息 → 后端日志 `group discovered`bot 不回,前端卡片徽标 `群聊 0/1`
5. 点 `群聊管理` → 看到该群,禁用状态
6. 启用 + 选 `@机器人`
7. 群里发 `你好` → bot 不回;发 `@<alias> 你好` → bot 回复
8. 测误伤:群里发 `@<alias>同学 在吗` → bot 不回(保护 token 不被前缀匹配误触)
9. 点 `查看对话记录` → 跳 agent 对话页session 标题 `微信群 · ...`assistant 气泡含完整工具卡片/thinking
10. 群里发 `@<alias> rm -rf /tmp/foo` → bot 在群里**明确回复** `⚠️ 群内禁止高风险操作bash\n原因包含文件删除命令\n👉 请点我头像私聊 bot 继续`**不弹确认**
11. 私聊 bot 发同样指令 → bot 发 `1. 确认执行 / 2. 不执行`;回 `2` → 拒绝
12. 群切 `所有消息` → 群里任意消息 bot 都回
13. 群 `禁用` → bot 不回;卡片徽标计数更新
14. 并发回归:两个群同时 @bot + 一个 DM 并发 → 三条任务并行进 agent群内消息顺序串行不乱
15. 删除 channel → DB 检查 group 记录已被级联清除
16. **群内 Clarify 正常流**:拉 bot 进群,配置一个会触发 clarify 的 skill或 agent 自然需要澄清问题的对话。A @bot 问句 → bot 群里发"❓ 问题\n1. ...\n2. ...\n仅 @<alias> 的发起者本人可回复5 分钟超时)"。
- A 回 `1` → agent 继续执行
- 同时 B 回 `1` → 被忽略B 的消息走 trigger 判定(若未 @bot 就丢弃)
17. **群内 Clarify 超时**:发起 clarify 后不回复等 5 分钟 → bot 在群里发 `⏱️ 等待 5 分钟未收到回复,已自动取消本次询问`pendingClarify 被清理,群 senderQueue 恢复接受新消息
### 可观测性
本次新增日志:
- `[AgentChannel] group discovered channelId=%s roomId=%s`info 级,仅首次发现)
- `[AgentChannel] group dispatch channelId=%s roomId=%s sender=%s trigger=%s accept=%s reason=%s`accept=true 走 infoaccept=false 走 debug避免群活跃时刷屏
- `[AgentChannel] group risk declined channelId=%s roomId=%s tool=%s reason=%s`warn 级,表示群里因 detectToolRisk 命中而回复拒绝消息)
- `[AgentChannel] group clarify timeout channelId=%s roomId=%s senderId=%s`info 级Clarify 300s 超时)
## 迁移路径
- DB schema本地 dev 走 TypeORM `synchronize: true` 自动建表。**测试 / 生产环境的 DDL 通过 MCP MySQL 工具直接执行**`mcp__mysql__execute` / `mcp__mysql__describe_table` / `mcp__mysql__list_tables`),不再产出 `.sql` 文件随 release 包发布——按 CLAUDE.md 项目规约统一处理。具体执行步骤见 plan Task 20。
- 老 channel 升级:现存 channel 的 `config.group` 为 undefined → 任何群消息都会进入 disabled 状态bot 不会乱回,平滑过渡。
- 老 sessionId 兼容DM sessionId 模板未变,已有私聊会话不受影响。
## 非目标 / 不在本 spec 范围
- **主动加入/退出群的 API**iLink 未提供,且违反微信平台条款,永远不做。
- **群内风险确认降级**:安全考虑,命中 `detectToolRisk` 的工具在群里一律拒绝(带回执文字),不做"仅发起者能确认"的复杂流程。Clarify 不属于此范畴——无害,群里允许使用。
- **每群 botAlias override**:当前 botAlias 落在 channel 级别,假定一个 bot 在所有群里同名。如果未来需要群级覆盖,加 `netaclaw_agent_channel_group.botAliasOverride` 字段即可,不破坏现有结构。
- **企业微信 / 飞书群**hermes 的 wecom/feishu 适配器是另一体系;本 spec 仅覆盖个人微信iLink Bot API。sessionId 模板里 `weixin` 段绑 channel.type未来加新平台用各自段名`channel:<cid>:wecom:group:...`)不冲突。
- **agent 切换语义**channel.agentId 改后已存在 group session 的历史保留,新消息按当前 channel.agentId 处理(沿用 DM 行为)。
- **跨群 / 跨 channel 的会话合并**:每群一会话,不做跨群的全局人物志合并。
- **独立的 `metadata.chat` 字段**session 实体**刻意不**写 `{ kind, channelId, roomId?, senderId? }` 这类冗余 metadata。sessionId 模板 `channel:<cid>:weixin:[group:]<chatId>` 已经携带所有分类信息,前端做一次 regex 判别即可渲染 icon / fallback title。多一份 metadata 增加双源一致性负担无显著收益。P1-5 修订决策)
- **群内消息主动推送**:只实现被触发后的回复,不做 bot 主动在群内发起话题。
---
## 后记 · iLink ClawBot 群消息接入的事实更正2026-05-08
> 本节是在前述设计与代码20 个 task / 117 单测)实施完成后追加的现状报告。
> **结论先行:当前实现的群聊路径在生产环境无法被触发,因为 iLink ClawBot 协议本身不允许 bot 接收群消息**。下文记录事实、当前代码的处置建议、以及两条候选迁移路径WeChatFerry 逆向 / 企业微信 WeCom的改造难度。
### 1. 事实勘误iLink ClawBot 不是用户的个人微信号
之前的设计基于"hermes weixin 适配器能收到群消息"这个假设,由此推导出"被动发现群 + 三档触发策略"的整套架构。这个假设错了。
通过查阅 iLink Bot 协议拆解资料([openclaw-weixin/weixin-bot-api.md](https://github.com/hao-ji-xing/openclaw-weixin/blob/main/weixin-bot-api.md)、[x1ah/wechat-ilink-demo](https://github.com/x1ah/wechat-ilink-demo)、[微信开放社区 Q&A](https://developers.weixin.qq.com/community/develop/doc/000e0aeba90160ea81f45a2046b400))确认的事实:
- **iLink ClawBot 是腾讯官方"机器人账号"**:扫码后通过 `bot_type=3` 在 iLink 平台生成一个独立 Bot ID每次扫码 Bot ID 都会变化)。它**不是**用户自己的微信号,只是与之"关联"。
- **ClawBot 不能被人拉进任何微信群**:因为它不是普通微信号,不在好友/群成员选择列表里。
- **群消息推送当前未开放**:协议层面 `room_id` / `group_id` 字段存在,但官方明确"群聊可能需要额外权限",目前默认状态下 iLink **不会**向 ClawBot 推送群消息。
- **腾讯官方对"个人微信号 + 群聊多人共享 Bot"的定位**建议改用微信对话开放平台chatbot.weixin.qq.com或企业微信而非 iLink Bot API。
hermes 文档里那句 "personal WeChat accounts may be in many groups" 是基于错误前提的描述,他们的群代码在 iLink 路径下也不会被触发。
### 2. 企业微信 (WeCom) 也无法接入"已有的个人微信群"
有人会自然想到"那转 WeCom 总行了吧"——也不行。腾讯封死了这条路:
- **个人微信群(已存在的)→ 企业微信账号**:❌ 协议禁止加入。
> "原有微信群,不支持邀请企业微信联系人加入。如果要让企业微信账号进入个人微信群,**通常需要在创建群聊时就同时拉入企业微信账号**。"——[企客宝 SCRM](https://www.qikebao.com/article/qywx-qywxjzsyhtgn.html)
- **企业微信新建群 → 同时拉入个人微信用户**:✅ 但是这是企业侧主动建群("客户群"/"互通群"),不是接入用户**已有**的个人微信群。
- 企业微信的 SCRM/客户群路径适合"客服/销售"等场景,不能解决"机器人加入老板已有的家人群、运营群、技术群"这类需求。
### 3. 三条路径的可行性矩阵
| 路径 | 加入"已有个人微信群" | 加入"新建群(同账号建)" | 协议风险 | 已有代码复用度 |
|---|---|---|---|---|
| **iLink ClawBot**(当前) | ❌ Bot 是独立账号 | ❌ 同上 | 合规 | 100%DM 私聊有效) |
| **企业微信 WeCom** | ❌ 协议禁止 | ⚠️ 仅"企业建群拉个人微信好友"的客户群 | 合规 | 60%(架构复用,渠道层重写) |
| **WeChatFerry / wxhelperPC Hook** | ✅ 代理用户**真实**个人微信号 | ✅ | ❌ **违反《微信个人账号使用规范》、有封号风险** | 70%(架构复用,传输层重写) |
### 4. 当前代码的处置建议
实施完成的 20 个 task / 117 单测在以下层面**仍然有价值**
- `chat_scope.ts`decideChatScope / 三档准入 / detectAtMention / strippers / decideGroupAcceptance—— 与传输层无关的纯函数,**任何"个人微信群消息接入"方案都能直接复用**。
- `runtime/errors.ts`GroupInteractionDeclineError + withClarifyTimeout—— 通用错误模型,无需修改。
- `agent_channel_group.{entity,service,controller}.ts` —— 群发现、状态、策略、级联删除都是渠道无关的元数据层。
- `agent_executor.ts` 的 `chatScope` 参数 + 群内风险 short-circuit —— 这是 agent 层语义,与微信底层协议无关,可直接保留。
- `routeInboundMessage` 群分流 + senderQueue 群级共享 + clarify 短路 —— 入站消息处理的通用模式。
- 前端"群聊管理"抽屉、卡片徽标、深链接跳转、`buildFallbackTitle` —— UX 层完全可复用。
**仅在以下层面是空跑**
- `service/weixin.ts`iLink getUpdates / sendText当前**永远收不到 `room_id` 不为空的消息**。所以 routeInboundMessage 的 group 分支理论上不会被触发;只有 DM 路径在生产中工作。
- 群消息回复 `weixinService.sendText(credential, roomId, ...)` 同样发不出去——iLink 服务端不接受 `to_user_id = @chatroom`。
### 5. 处置建议(按时间维度)
**短期建议(本次 PR**
- ✅ **保留** Phase 1纯函数 chat_scope + errors+ Phase 2数据层+ Phase 3agent_executor chatScope+ Phase 4 的 routeInboundMessage 分流框架——这些是平台无关的能力,未来重构成本低且对当前 DM 路径有保护性(明确隔离 DM 与 group 处理路径)。
- ⚠️ **暂时禁用** Phase 5/6 的群聊管理 UI频道页"群聊管理"按钮、`群聊 X/Y` 徽标、群聊面板)——避免误导用户以为已经能用。可以保留组件代码,但前端入口加 `v-if="false"` 或 `feature flag` 隐藏。
- ✅ **保留**前端的 ClawBot 文案修订("扫码登录" → "ClawBot 扫码登录(个人微信助手)",弹窗里明确"无法被拉进微信群,仅支持私聊")。
- ❌ **不要**部署 Task 20 的 `netaclaw_agent_channel_group` 表到生产环境。测试库里已建可保留供后续路径复用。
**中长期方向**:见下两节"方案 3WeChatFerry 路径"和"方案 4企业微信路径"的迁移成本评估。
---
## 方案 3 分析 · 改造为 WeChatFerry / wxhelper 逆向方案
### 协议特征
- WeChatFerry 是 Windows PC Hook 协议DLL 注入到微信客户端进程),代理**用户真实个人微信号**[wcferry 官网](https://wcferry.netlify.app/)、[GitHub lich0821/WeChatFerry](https://github.com/lich0821/WeChatFerry))。
- 群消息识别:`msg.roomid` 包含 `@chatroom` 即为群消息。
- bot 入群方式:用户**手动**在手机/PC 微信里把自己的微信号拉进群(=就是把自己加进去bot 通过 hook 自动收到群消息。
- 提供完整 API发消息 / 收消息 / 群成员管理 / 数据库直读 / 文件解密。
### 风险
- **违反《微信个人账号使用规范》**,封号风险无法消除——只能通过控制使用方式延缓。
- 作者本人 lich0821 已**停止维护**项目2024 年起),但社区仍在用。
- 实测:约 2-3 天后可能掉线警告;高频消息 + @ 操作 + 大群行为容易触发风控。
- 必须严格匹配微信客户端版本wcf 与微信版本绑定)。
### 改造工作量评估
| 子项 | 现有代码可复用度 | 工作量 | 说明 |
|---|---|---|---|
| `service/weixin.ts`iLink HTTP/JSON | ❌ 0% | **大** | 完全重写:从 long-poll HTTP 改为 wcf 的 gRPC/Socket 客户端协议 |
| `getUpdates` 长轮询 | ❌ 0% | 大 | 改为 wcf 的事件订阅/回调 |
| `sendText(credential, chatId, ...)` | ⚠️ 30% | 中 | API shape 类似to / text但传输层换成 wcf SDK |
| 凭证模型 (`WeixinCredential` token / accountId / baseUrl) | ⚠️ 50% | 中 | 改为 wcf 的连接信息gRPC 端点、wxid |
| QR 登录流程 | ❌ 0% | 中 | wcf 不需要 QR改为"启动 wcf 服务、用户在 PC 微信内手动登录" 的引导 |
| `routeInboundMessage` 分流 | ✅ 100% | 几乎为零 | `decideChatScope(message, accountId)` 协议字段 `room_id` 与 wcf 的 `roomid` 形态一致,最多加个字段适配层 |
| `decideGroupAcceptance` / `detectAtMention` / strippers | ✅ 100% | 0 | 纯函数完全无关 |
| Group entity + service + controller | ✅ 100% | 0 | DB 层完全复用 |
| `agent_executor.chatScope` + Decline short-circuit | ✅ 100% | 0 | agent 层无关 |
| 前端 UI群聊管理 / 徽标 / 跳转) | ✅ 95% | 小 | 仅"扫码登录"按钮替换为"启动 wcf 服务"引导 |
| 测试单测 | ✅ 100% | 0 | 都是 mock 入站 message 的protocol-agnostic |
| 部署 / 运维 | ❌ N/A | **大** | 必须有 Windows 节点 + PC 微信客户端长期登录Linux/Mac 服务器跑不了 |
**整体难度评估:中等**(如果只算代码改造)。**实际难度:高**(因为部署环境从 Linux 服务器变成 Windows + PC 微信客户端,运维模型完全变了)。
合规风险**高**——任何用户主号绑定 wcf 都可能封号。建议只用**小号**或**专用号**。
### 落地步骤建议(粗略)
1. 新增渠道类型 `netaclaw_agent_channel.type = 'weixin-pchook'`(与现有 `weixin` 并行)。
2. 实现 `service/weixin_pchook.ts`:封装 wcf gRPC 客户端,提供与 `weixin.ts` 同 shape 的 `getUpdates` / `sendText` 接口。
3. `agent_channel.ts.routeInboundMessage` 按 `channel.type` 分流到不同 weixin service下游处理完全不变。
4. 前端频道编辑页加 type 选项:`微信 ClawBot私聊` / `微信 PC Hook含群聊封号风险`。后者明确警告标语。
5. 部署文档新增 Windows 节点搭建指南。
---
## 方案 4 分析 · 改造为企业微信 (WeCom) 客户群方案
### 协议特征
- 企业微信开放平台提供官方 API`qyapi.weixin.qq.com`[企微 API 文档](https://developer.work.weixin.qq.com/document/path/91039)。
- 群类型:
- **内部群**仅企业内成员bot 可在企业内主动建群、加入。
- **客户群(外部群)**:企业建群后拉个人微信好友进群,**bot 在企业侧、客户在个人微信侧**。
- 消息接收:企业微信使用回调 webhook你提供 URL企微 push 消息),不是 long-poll。
- bot 入群:企业建群时把"应用机器人"加为成员;客户群同理。
- **不能加入"已有的个人微信群"**——协议禁止。
### 改造工作量评估
| 子项 | 现有代码可复用度 | 工作量 | 说明 |
|---|---|---|---|
| `service/weixin.ts` 整体 | ❌ 0% | **大** | 完全重写HTTP+签名认证、回调 webhook 接收消息、access_token 续期、不同消息类型解码(企微的消息体 schema 与 iLink 完全不同) |
| QR 登录 | ❌ 0% | 大 | 改为"安装企微应用 + OAuth 授权",部署一次后长期有效 |
| 凭证模型 | ❌ 0% | 中 | 从 `token + accountId` 改为 `corp_id + corp_secret + agent_id` |
| 入站消息接入 | ❌ 30% | 大 | 从主动 long-poll 改为被动 webhook需要在 Neta 后端开 HTTP endpoint做企微签名校验 |
| `decideChatScope` | ⚠️ 60% | 小 | 企微消息里 `chat_id` / `from_user_id` 字段名不同,加适配层 |
| 三档触发策略 + at_mention 检测 | ✅ 90% | 极小 | 企微消息也有 `at_user_list`,文本里 `@xxx` 格式与微信稍有不同 |
| 群发现机制 | ⚠️ 50% | 中 | 企微提供"获取群列表"主动 API与 iLink 的"被动发现"不同),需要重写 `upsertOnInbound` 时机——可改为"管理员触发刷新群列表"按钮 |
| Group entity + service | ✅ 100% | 0 | 完全复用 |
| `agent_executor.chatScope` + Decline | ✅ 100% | 0 | 完全复用 |
| 前端 UI | ✅ 80% | 中 | 频道管理改为"企业微信应用配置",群聊管理抽屉小改(移除"被动发现"提示,加"刷新群列表"按钮) |
| Risk Confirm / Clarify | ✅ 100% | 0 | DM 路径完全可用 |
| 部署 | ✅ 90% | 小 | 现有后端服务多开一个 webhook endpoint 即可,不需要新机器 |
**整体难度评估:中等偏高**(代码改造)。**实际难度:中**(部署模型友好,但要求企业资质)。
合规风险**低**——这是腾讯官方推荐的合规路径。
### 业务可行性评估
- 必须有**企业微信注册资质**(公司主体 + 营业执照),不能用个人身份。
- 适合"客服群 / 销售对接群 / 售后群"等需要企业身份背书的场景。
- **不适合**"接入老板已有的家人群、技术研讨群"这种纯私域社群。
- 客户群人数上限 500比个人微信群200更宽松。
### 落地步骤建议(粗略)
1. 新增渠道类型 `netaclaw_agent_channel.type = 'wecom'`。
2. 实现 `service/wecom.ts`:封装企微 access_token 管理 + 主动 API消息发送、群列表获取 + 被动回调入口。
3. 新增 controller `controller/admin/wecom_callback.ts` 处理企微 webhook 推送,做签名校验后转化为统一的 `WeixinInboundMessageLike` shape喂给 `routeInboundMessage`。
4. 频道编辑页加 type 选项 `企业微信(客户群)`,配置项变为 `corp_id` / `corp_secret` / `agent_id` / 回调 URL。
5. 群聊管理抽屉改为"主动拉取"模式,加"刷新群列表"按钮。
---
## 决策对比一句话总结
| 需求 | 推荐方案 |
|---|---|
| 只要私聊(一对一 AI 助手) | **保持 iLink ClawBot**(当前实现已可用) |
| 加入用户已有的个人微信群 | **方案 5 · WCDB DB 读+SendInput**(2026-05-12 新增,已验证可行) |
| 客户运营 / 企业 SCRM 场景 | **企业微信 WeCom**(合规、稳定、有企业资质前提) |
| 多场景兼顾 | 加渠道类型字段,多种 type 并存(架构已为此预留) |
---
# 方案 5 · WCDB DB 读 + Win32 SendInput(2026-05-12 追加)
> **状态**: ✅ 技术可行性已验证(见 `2026-05-11-weixin-4x-db-decrypt-progress.md`)。
> **架构 C 修订**(2026-05-12 架构师交叉评审):**不再使用独立 .NET bridge 进程**,改为 backend 内集成 + PowerShell 小脚本处理 Win32 调用。
> **前置**: 用户本机装 Weixin 4.1.x 并已登录;backend 进程以同用户身份运行(不需要管理员)。
> **合规定位**: 读用户本机 SQLite 文件,不触碰 Weixin 服务器,不做协议模拟、不做 DLL 注入。等同于用户自行备份/查看本机聊天记录。封号风险接近 0;唯一的合规议题是在产品侧给用户明示授权。
## 5.0 · 架构演进(决策记录)
| 版本 | 架构 | 状态 | 理由 |
|---|---|---|---|
| v1 (2026-05-09) | 独立 .NET UIA bridge | ❌ 死 | Weixin 4.x Qt 自绘,UIA 树空 |
| v2 (2026-05-12 早) | 独立 .NET bridge + WCDB | ❌ superseded | 33 Task 大头是 Node PoC 翻 C#;没有进程隔离收益 |
| v3 (2026-05-12 晚) | **backend 集成 + PS 脚本** | ✅ 选定 | 复用 better-sqlite3 已有依赖;PoC 直接搬;调试一体 |
**决定性证据**:
- backend 已依赖 `better-sqlite3@^12.8.0` 并被 `pkg` 打包成 `backend.exe` 跑在 Windows 上
- WCDB 解密 PoC `poc-7f-reserve80.mjs` 用纯 Node `crypto` 完成,0.2 秒解 41MB DB
- Win32 调用(OpenProcess / SendInput)用 PowerShell 一次性脚本即可,实测无需管理员
- Tray 是 .NET 是因为 WinForms NotifyIcon 必需;**bridge 没有非 .NET 不可的理由**
## 5.1 · 为什么选方案 5 而不是之前的四条路
| 路径 | 作废/保留 | 原因 |
|---|---|---|
| 方案 1 · iLink ClawBot | **保留用于 DM** | ClawBot 是独立账号,永远收不到群消息 |
| 方案 2 · 企业微信 WeCom | 保留作为 v2 备选 | 只能入"新建客户群",**不能入用户已有个人群** |
| 方案 3 · WeChatFerry PC Hook | ❌ 放弃 | DLL 注入,封号风险高;且作者 2024 停更,对 Weixin 4.x 无官方支持 |
| 方案 4 · UIA(2026-05-09 spec) | ❌ 作废 | Weixin 4.x 用 Qt 5.15 自绘,UIA 树只有 3 个空壳节点 |
| **方案 5 · DB 读 + SendInput** | ✅ **选定** | 无注入无协议模拟;本地文件读;HMAC 验证能捕捉任何非正常页;已实测可解密并读到最新群消息 |
## 5.2 · 总体架构(架构 C · 2026-05-12 修订)
```
Windows 用户桌面
├─ Weixin.exe (4.1.x, 已登录用户真实个人微信号)
│ └─ 写 message_0.db / session.db / contact.db (WCDB 加密)
│ └─ 写 message_0.db-wal (WAL, 实时增量)
│ └─ 主窗口 (Qt 自绘, 仅 Ctrl+F 搜索 + 剪贴板 Ctrl+V 可控)
├─ backend.exe (Neta backend, Node 22)
│ └─ modules/netaclaw/runtime/weixin_db/
│ ├─ wcdb_codec.ts WCDB 解密 (纯 crypto, 直接搬 PoC)
│ ├─ db_paths.ts xwechat_files 目录结构
│ ├─ key_extractor.ts spawn ps1 → JSON 输出 → 缓存
│ ├─ message_repo.ts better-sqlite3 readonly 查询
│ ├─ wal_watcher.ts setInterval 轮询 .db-wal mtime
│ ├─ incremental_reader.ts 解密 + 查 + 解 zstd
│ └─ message_sender.ts spawn ps1 → 切群 + 发文本
│ └─ modules/netaclaw/service/weixin_db.ts ← 主服务,装配上述子模块
│ └─ modules/netaclaw/service/agent_channel.ts
│ routeInboundMessage 按 channel.type 分流
│ ├─ type='weixin' → iLink 路径 (DM)
│ └─ type='weixin-db' → ★直接调 weixinDbService.ingestRow(...)
│ (无 HTTP IPC, 内部函数调用)
│ └─ tools/win32/ PowerShell 脚本 (装包时拷到 installDir/tools/)
│ ├─ extract-weixin-key.ps1 一次性: dump key map JSON
│ └─ send-weixin-text.ps1 每次回复: Ctrl+F 切群 + Ctrl+V 发
└─ Neta.Tray.exe (.NET, 仅 NotifyIcon, 无变化)
拉起 backend.exe;不再拉 bridge.exe (已删除)
```
**关键差别 vs v2 .NET bridge**:
- ❌ 删除 `Neta.WeChatBridge` / `Neta.WeChatBridge.Tests` 整个 .NET 项目
- ❌ 删除 `/open/netaclaw/channel/weixin-db/handshake|inbound` HTTP 入口(无 bridge 进程要推)
- ❌ 删除 `service/weixin_db.ts` 中的 axios 客户端(无 HTTP 出站)
- ✅ `weixin_db.ts` 改为完整服务: 启动时抽 key + 持有 wal watcher;入站直接调 `agent_channel.routeInboundMessage`
- ✅ 安装时只多 2 个 .ps1 脚本(无编译产物);bridge 文件夹里没有 .exe 要打入
## 5.3 · 最关键的技术细节(不能再猜)
已通过 `poc-7f-reserve80.mjs` + `poc-10-decrypt-and-read.mjs` 完整验证:
```
WCDB cipher_compatibility = 4 (SQLCipher 4 标准)
page_size = 4096
kdf_algorithm = PBKDF2-HMAC-SHA512
hmac_algorithm = HMAC-SHA512
iv_sz = 16
hmac_sz = 64 ← 关键!HMAC-SHA512 是 64B(我之前按 SQLCipher 3 的 SHA1=20B 算成 48,全部失败)
reserve_sz = iv_sz + hmac_sz = 80
Raw key 模式(WCDB 内存中以 x'<64hex raw><32hex salt>' 形式,99 字符串):
encKey = 内存中提取的 32 字节 raw key (不派生)
salt = DB 文件 page 1 前 16 字节(每个 DB 不同,是随机的)
hmacKey = PBKDF2-HMAC-SHA512(encKey, salt XOR 0x3a, 2 rounds, 32B)
每 page(4096B)解密:
encStart = 16 if pageNum == 1 else 0 # page 1 前 16B 是 salt, 不加密
ciphertext = page[encStart : 4016]
iv = page[4016 : 4032] # 16B
storedHmac = page[4032 : 4096] # 64B HMAC-SHA512
# HMAC 验证(LE page number)
computed = HMAC-SHA512(hmacKey, ciphertext || iv || pageNum_LE_u32)
assert computed[0..64] == storedHmac
# AES 解密
plaintext = AES-256-CBC(encKey, iv).decrypt(ciphertext)
输出明文 SQLite:
out[0..16] = "SQLite format 3\0" # 替换 salt 位
out[16..encEnd] = plaintext
out[encEnd..4096] = 原 reserve 区(含 IV + HMAC,不影响 SQLite 解析)
```
## 5.4 · 群消息数据模型(WCDB 实际结构)
```
message_0.db (主消息库)
├─ DeleteInfo / DeleteResInfo / HistoryAddMsgInfo / HistorySysMsgInfo
└─ Msg_<sha256_of_room_or_wxid> × N ← 每个会话一张表(DM + 群都在这)
├─ local_id INTEGER PK
├─ server_id INTEGER — 服务端消息 id
├─ local_type INTEGER — 1=文本, 3=图片, 其他高位值看 bit flag(81604378673 等)
├─ sort_seq INTEGER
├─ real_sender_id INTEGER — 指向 contact.db 的 sender 映射
├─ create_time INTEGER — Unix 秒
├─ status INTEGER
├─ upload_status / download_status INTEGER
├─ server_seq INTEGER
├─ source TEXT — 来源元数据(可能含 wxid)
├─ message_content TEXT/BLOB — 明文或 zstd 压缩(前 4B=28 b5 2f fd)
├─ compress_content TEXT
├─ packed_info_data BLOB
└─ WCDB_CT_message_content/source — WCDB 自带的加密类型列,可忽略
contact.db + session.db — 通过 real_sender_id 反查 wxid / 昵称 / 群名
room 判断:message_content 里若出现 "wxid_xxx:\n..." 形式的前缀 → 群消息(发送者 wxid 自带)
反之是 DM(发送者就是这张表对应的联系人)
```
## 5.5 · Bridge 项目结构(替换老 Neta.WeChatBridge)
**老 bridge 作废**(UIA 实现),**新 bridge 在同目录重建**。目录名复用。
```
packages/windows-tray/Neta.WeChatBridge/
├── Neta.WeChatBridge.csproj (net8.0-windows, AssemblyName=bridge)
├── Program.cs 启动 + 版本白名单 + handshake + 启 watcher
├── Config/
│ ├── WeixinProfile.cs Weixin 4.x 版本 profile
│ └── WeixinProfiles.yaml 版本白名单 (4.1.x, 4.2.x ...)
├── Crypto/
│ ├── WeixinProcessLocator.cs 定位 Weixin.exe(不是 WeChat.exe)
│ ├── MemoryKeyExtractor.cs OpenProcess + VirtualQueryEx + 找 x'...' 96-char literal + 按 salt 反向匹配各 DB
│ ├── WcdbCodec.cs 解密一个 page(HMAC 验证 + AES 解密) - 见 5.3
│ ├── ZstdDecompressor.cs 解 message_content 的 zstd
│ └── DbDecryptor.cs 全量解密 DB 到临时文件
├── Db/
│ ├── DbPaths.cs 定位 ~/Documents/xwechat_files/<seed>/db_storage/
│ ├── MessageRepo.cs node:sqlite 风格 API,解密后用 Microsoft.Data.Sqlite 读
│ ├── ContactRepo.cs 查 real_sender_id → wxid 映射
│ └── RoomRepo.cs 查群名/成员
├── Watcher/
│ ├── WalWatcher.cs 轮询 .db-wal mtime(500ms)
│ ├── IncrementalReader.cs 只查 create_time > lastKnownSeq 的行
│ └── MessageDispatcher.cs 去重 + 推 backend
├── Input/
│ ├── WeixinWindowLocator.cs FindWindowEx 找指定群/联系人的聊天子窗口
│ └── SendInputSender.cs Win32 SendInput UTF-16 字符模拟输入 + Enter
├── Http/
│ ├── BridgeHttpServer.cs Kestrel 托管
│ ├── Endpoints/ /health /send /enable-room /disable-room /rooms /diag
│ └── TraySecretAuth.cs
├── Backend/
│ └── BackendClient.cs POST /handshake /inbound
└── Runtime/
├── BridgeRuntimeInfo.cs
└── GracefulShutdown.cs
```
**作废的老 UIA 代码**: `Uia/WeChatWindow.cs`、`SessionListWatcher.cs`、`ChatBoxReader.cs`、`UiaRoomMessageReader.cs`、`MessageSender.cs`(UIA 版)、`AttachmentExtractor.cs`。**全部删除**,不保留占位。
## 5.6 · 入站事件流(架构 C 修订版)
```
backend.exe 启动时 weixin-db channel 初始化:
1. WeixinProcessLocator: 找 Weixin.exe 4.x 主进程 + 版本号
2. 定位 seedDir = ~/Documents/xwechat_files/<wxid>_<suffix>/
3. 抽 key: spawn `extract-weixin-key.ps1` (一次性, 输出 JSON):
- PowerShell 用 OpenProcess(VM_READ | QUERY_INFO, pid) ← 同用户, 无需管理员
- 枚举 private RW 区域 + ReadProcessMemory
- 正则找 x'<96hex>' literal
- 根据各 DB 的前 16B salt 一一匹配 → 输出 {dbFile: rawKey32B} JSON
- backend 读 stdout, 缓存到内存 (channel 单例 service)
4. ★ 从 DB 读取 channel 的"用户已添加群名"白名单 (netaclaw_agent_channel_group WHERE channelId=X AND status=1)
5. 初次全量解密 message_0.db → 临时文件 (dataDir/<cid>/decrypted/message_0.db)
6. ★ 枚举 Msg_<sha> 表, 用 contact.db / session.db 反查每张表对应的群名
→ 构造 { tableName → roomName } 映射
→ ★ 只保留 roomName 在白名单内的表; 其他表整体跳过 (隐私 + 性能)
7. 记录每张"白名单表"的 MAX(create_time) 为 lastKnownTs
8. 启 WalWatcher (setInterval 500ms 防抖, 轮询 message_0.db-wal mtime):
mtime 变 → 把 DB 主文件 + WAL + -shm 拷到 work 目录 → 全量解密
→ 对每个"白名单表" SELECT * WHERE create_time > lastKnownTs ORDER BY create_time ASC
→ 每条新行: 解 zstd → 构造 WeixinDbInboundPayload
→ ★ 直接调用 agentChannelService.routeInboundMessage(channel, state, pseudo)
(无 HTTP IPC, 同进程内函数调用)
→ 更新 lastKnownTs
```
**白名单语义**:
- 用户在前端"群聊管理"里**手动输入群名**才会建条 `netaclaw_agent_channel_group` 记录,默认 status=1 启用
- DB 解密阶段 ★ 第 6 步就过滤掉非白名单的表 → **bot 永远不读未授权群的消息**(无论加密 DB 里是什么)
- 这是隐私保护 + 性能优化双重收益:用户家庭群、好友群不会被 agent "顺便看到"
**运行时目录约定**:
- 解密临时文件路径: `<pDataPath()>/weixin-db-work/cid-<channelId>/{src,decrypted}/<dbname>`
- backend 进程退出时不清理(下次启动覆盖即可,且方便排错)
- DEV 时 `pDataPath()` 解析为 `packages/backend/dist/`,与现有 uploads/workspace 同一根
**关键优化**:
- 全量解密 10644 pages 仅 0.2s (Node),性能不是问题
- 解密后的临时文件只在 work 目录存在,backend 停止时清理
- 白名单过滤在 SQL 层 (`SELECT FROM "Msg_<sha>"`),只读用户授权的表
## 5.7 · 回复路径(TODO · 占位)
> **状态**: 占位,后续单独 spec 描述。
>
> **目标**: 当 agent 决定回复某个白名单群时,把回复文本投递到 Weixin UI 让微信真实发出去。
>
> **已知约束**(用于后续设计):
> - Weixin 4.x 主窗口是 Qt 自绘,EnumChildWindows 只能看到 2 个 top-level 子窗口(整个聊天 UI 在 `MMUIRenderSubWindowHW` 一块画布里,Qt widget 对 Win32 完全不可见)
> - UIA 树空,不能通过控件自动化切群/定位输入框
> - 候选路径(待评估,**本 spec 不下定论**):
> - 路径 A: 剪贴板 + Ctrl+V + Enter,假设用户当前已聚焦目标群
> - 路径 B: Ctrl+F 搜索群名 → Enter 切群 → Ctrl+V + Enter
> - 路径 C: WeChatFerry 4.x 注入 hook(灰色,封号风险)
> - 任一路径都要解决"用户在用微信时怎么避免打断"问题
>
> **接口设计(占位)**: `weixin_db_service.ts` 暴露:
> ```ts
> /** 向白名单群发送一条文本回复。v1 占位,先抛 NotImplementedError。 */
> async replyToGroup(channelId: number, roomName: string, text: string): Promise<void>;
> ```
> 在 `routeInboundMessage` 决策完 finalContent 后调,失败时 agent 视为发送失败(不重试)。
>
> **未决问题**(下一次 spec 解决):
> 1. 如何在用户操作微信时安全发送 / 何时拒发
> 2. 切群后如何验证焦点在目标群的输入框(避免发错群)
> 3. 同名群处理策略
> 4. 长文本 / 多行 / emoji 的 SendInput 编码细节
## 5.8 · 数据模型变更(架构 C · 群白名单语义)
**保留不变**:
- `netaclaw_agent_channel_group` 表(Phase 2 已上线,schema 主体不动)
- `agent_channel.config.group.botAlias`
- 所有 sessionId / dispatch key / clarify key 规则
**语义变更**(2026-05-12 关键):
| 字段 | 原 ClawBot/UIA 语义 | weixin-db 新语义 |
|---|---|---|
| `roomId` | iLink 平台 ID 或 sha1(roomName) | **就是用户输入的群名(string)**,作为白名单 key |
| `roomName` | 显示名 | 同 roomId(冗余,前端可编辑) |
| `status` | `0=disabled (待审批)` / `1=enabled` / `-1=ignored` | 简化为 `0=disabled` / `1=enabled`,**默认 1**(用户手动添加即启用) |
| `firstSeenAt` | bridge 被动发现时间 | 用户添加时间 |
| `lastSeenAt` | bridge 看到该群消息时间 | DB 解密时确认群存在的时间 |
| 创建路径 | bridge 被动发现 → `upsertOnInbound` | **前端用户主动 POST /list/add { channelId, roomName }** |
**作废**:
- `upsertOnInbound`(被动发现写库)→ 删除
- `status=-1 (ignored)` 状态 → 删除(没有"忽略"概念,用户没添加就不监管)
- 前端"待审批横幅" + "新发现 N 个群" → 删除
**新增**:
```sql
ALTER TABLE netaclaw_agent_channel
-- type 字段新增合法值 'weixin-db'(原 'weixin' 仍用于 iLink DM)
;
-- credential JSON 对 weixin-db 的含义:
-- {
-- "wxid": "wxid_xxx", -- 必填(用户从 PC 微信"设置→关于"抄;backend 启动时校验进程匹配)
-- "nickname": "张三", -- 启动后自动回填
-- "wechatVersion": "4.1.8.107", -- 同上
-- }
```
**作废**:
- 2026-05-09 spec 中的 `netaclaw_wechat_archive` SQLite 归档表 → 不再需要(用户白名单内的群消息直接落 session_entry,非白名单的根本不读)
## 5.9 · routeInboundMessage 的最终分流逻辑
```ts
async routeInboundMessage(channel, state, message) {
// 按 channel.type 分流
switch (channel.type) {
case 'weixin': // iLink ClawBot(DM only)
if (message.room_id) return; // 丢弃任何群消息(ClawBot 实际也收不到,双保险)
return this.handleIlinkInbound(channel, state, message);
case 'weixin-db': // ★新增:本地 DB 读
if (!message.room_id) {
// DM 走 ClawBot,丢弃 DB 读出的 DM 避免双渠道重复响应(见 2026-05-09 spec 决策)
this.logger.debug('[weixin-db] drop DM, use ClawBot for DM');
return;
}
return this.handleDbInbound(channel, state, message);
default:
return;
}
}
```
`handleDbInbound` 的代码 **99% 复用** `handleIlinkInbound`:
- pendingClarify 短路 → 同
- decideChatScope → 同
- decideGroupAcceptance → 同(trigger 策略 at_mention/all 仍生效;白名单已经过滤掉非授权群,本层只判 @ 命中等)
- senderQueue 按 group dispatch key 串行 → 同
- agentExecutor.execute({ chatScope: 'group', ... }) → 同
- finalContent → **唯一差别在 sendText**:调 `weixinDbService.replyToGroup(channelId, roomName, text)`(占位,见 5.7)
## 5.9.5 · 前端"用户主动添加群"UX(替换被动发现)
**原 channel-group-panel(被动发现版)作废**,改为简单的"白名单管理"形态:
```
┌─ 群聊白名单 · 老板微信 ──────────────────────[x]─┐
│ │
│ 已添加监管 3 个群 [+ 添加群] │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ 产品研发群 [启用 ●] [删除] │ │
│ │ 添加于 2026-05-12 · 最近消息 12 分钟前 │ │
│ │ 触发策略 (●) @机器人 ( ) 所有消息 │ │
│ │ [保存策略] │ │
│ └──────────────────────────────────────┘ │
│ │
│ 💡 群名必须与 PC 微信里完全一致(包括空格、表情)│
│ 💡 未添加的群,bot 不会读取消息内容(隐私保护) │
└──────────────────────────────────────────────────┘
[+ 添加群] 弹窗:
┌─ 添加群聊 ─┐
│ 群名: [_________________________] │
│ 触发策略: (●) @机器人 ( ) 所有消息 │
│ [取消] [添加] │
└─────────┘
```
**前端约束**:
- 群名作为白名单 key,**用户必须输入与 Weixin UI 完全一致的群名**(后端校验 trim 但不做 fuzzy)
- 同一 channel 下同名群只能加一次(UNIQUE(channelId, roomName) 校验)
- 删除群 = 立即停止监管(不影响 Weixin 自身,只是 backend 不再读)
- 不再有"待审批"状态;不再有"忽略"按钮
**REST 接口**:
```
POST /admin/netaclaw/channel/group/add { channelId, roomName, triggerMode } → { id, status: 1 }
POST /admin/netaclaw/channel/group/list { channelId } → list
POST /admin/netaclaw/channel/group/toggle { id, status }
POST /admin/netaclaw/channel/group/updatePolicy { id, triggerMode }
POST /admin/netaclaw/channel/group/delete { ids }
```
**作废接口**: 旧 `/upsertOnInbound`(已被 add 替代)
## 5.10 · 现有代码需要修改的地方(架构 C 修订)
### 后端 modifications
| 文件 | 改动 |
|---|---|
| `packages/backend/src/modules/netaclaw/entity/agent_channel.ts` | type 字段 enum 加 `weixin-db` |
| `packages/backend/src/modules/netaclaw/service/agent_channel.ts` | `routeInboundMessage` 加 `case 'weixin-db'` 分支;`handleDbInbound` 新增(99% 复用 handleIlinkInbound,仅 sendText 改调 weixinDbService.replyToGroup) |
| `packages/backend/src/modules/netaclaw/service/weixin_db.ts` (新增) | **完整服务**(架构 C):启动时抽 key、维持 wal watcher、解密 + 增量读 + 投递 routeInboundMessage、replyToGroup 占位接口 |
| `packages/backend/src/modules/netaclaw/runtime/weixin_db/wcdb_codec.ts` (新增) | WCDB 解密(直接搬 PoC `poc-7f-reserve80.mjs` + `poc-10`) |
| `packages/backend/src/modules/netaclaw/runtime/weixin_db/db_paths.ts` (新增) | xwechat_files 目录解析 |
| `packages/backend/src/modules/netaclaw/runtime/weixin_db/key_extractor.ts` (新增) | spawn ps1 抽 key + 缓存 |
| `packages/backend/src/modules/netaclaw/runtime/weixin_db/message_repo.ts` (新增) | better-sqlite3 readonly 查询 + zstd 解压 |
| `packages/backend/src/modules/netaclaw/runtime/weixin_db/wal_watcher.ts` (新增) | setInterval 500ms 轮询 wal mtime |
| `packages/backend/src/modules/netaclaw/runtime/weixin_db/incremental_reader.ts` (新增) | 解密 → 白名单过滤 → 增量读 → 解 zstd |
| `packages/backend/src/modules/netaclaw/runtime/weixin_db/room_resolver.ts` (新增) | 解析 Msg_<sha> 表名 → 群名 → 白名单匹配 |
| `packages/backend/src/modules/netaclaw/service/agent_channel_group.ts` | 删除 `upsertOnInbound`(被动发现);新增 `addByName(channelId, roomName, triggerMode)` 显式添加 |
| `packages/backend/src/modules/netaclaw/controller/admin/agent_channel_group.ts` | endpoint `/upsertOnInbound` 改 `/add`;入参变 `{ channelId, roomName, triggerMode }` |
| `packages/backend/tools/win32/extract-weixin-key.ps1` (新增) | OpenProcess + 抽 key,JSON stdout 输出 |
| `packages/backend/tools/win32/send-weixin-text.ps1` (新增,占位) | 5.7 实现时再写;v1 先写 stub 抛 NotImplemented |
### 后端 删除(作废)
| 文件 | 理由 |
|---|---|
| `packages/backend/src/modules/netaclaw/service/weixin_uia.ts` | UIA 路径作废 |
| `packages/backend/src/modules/netaclaw/controller/open/weixin_uia.ts` | UIA bridge HTTP 入口作废(架构 C 不需要) |
| `packages/backend/src/modules/netaclaw/runtime/wechat_uia_routing.ts` | 同上 |
| `packages/backend/src/modules/netaclaw/service/wechat_archive.ts` | 不再需要 SQLite 归档 |
| `packages/backend/src/modules/netaclaw/runtime/wechat_archive_schema.ts` | 同上 |
| `packages/backend/src/modules/netaclaw/controller/admin/wechat_archive.ts` | 同上 |
| `packages/backend/src/config/config.default.ts` 的 `staticFile.wechatUploads` | 不再需要图片挂载 |
| `packages/backend/src/comm/path.ts` 的 `pWechatUploadsPath` | 同上 |
### 前端 modifications
| 文件 | 改动 |
|---|---|
| `packages/frontend/src/modules/agent/types/index.d.ts` | `AgentChannelInfo.type` 改为 `'weixin' \| 'weixin-db'`;`AgentGroupItem.status` 仅 `0/1`,删 `-1` |
| `packages/frontend/src/modules/agent/views/channel-management.vue` | drawer 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` | 改名;wxid 唯一性校验逻辑保留 |
### 前端 删除
| 文件 | 理由 |
|---|---|
| `packages/frontend/src/modules/agent/components/wechat-archive-panel.vue` | 归档面板作废 |
### .NET Bridge — **整个项目删除**
| 文件 | 理由 |
|---|---|
| `packages/windows-tray/Neta.WeChatBridge/` 整个项目 | 架构 C 不需要独立 bridge;backend 直接处理 |
| `packages/windows-tray/Neta.WeChatBridge.Tests/` | 同上 |
| `packages/windows-tray/Neta.Tray/BridgeProcessManager.cs` | 不再拉 bridge.exe |
| `packages/windows-tray/Neta.Tray/BridgeHealthPoller.cs` | 同上 |
| `packages/windows-tray/Neta.Tray/TrayApplicationContext.cs` 中的 bridge 拉起逻辑 | 删除相关代码段;Tray 只负责 backend.exe |
### 安装包脚本
| 文件 | 改动 |
|---|---|
| `packages/backend/scripts/build-windows-installer.js` | 删除 `dotnet publish bridge` 步骤;新增"拷贝 backend/tools/win32/*.ps1 到 installDir/tools/" 步骤 |
| `packages/backend/installer/setup.iss` | 删除 bridge.exe 文件项;新增 ps1 文件项 |
## 5.11 · 实施优先级(架构 C · 简化版)
| Phase | 内容 | 估时 | 前置 |
|---|---|---|---|
| **P1** | 清理 UIA + bridge .NET 项目 + archive 后端代码 | 0.5d | 本 PR |
| **P2** | Backend runtime/weixin_db/ 各子模块(wcdb_codec / db_paths / message_repo / room_resolver) | 2d | P1 |
| **P3** | Backend `weixin_db.ts` service 装配 + 启动钩子 + key extractor (含 ps1) | 1.5d | P2 |
| **P4** | Backend `agent_channel.ts` 加 weixin-db 分支 + `handleDbInbound` | 0.5d | P3 |
| **P5** | Backend group service `addByName` + controller `/add` 端点 | 0.5d | P1 |
| **P6** | 前端 channel-management 改文案 + `channel-group-panel` 重写 UX(添加群表单) | 1d | P5 |
| **P7** | 安装包脚本去 .NET bridge + 加 ps1 拷贝 | 0.5d | P3 |
| **P8** | Tray 删除 bridge 拉起逻辑 | 0.25d | P7 |
| **P9** | 端到端手工验证 | 0.5d | 全部 |
| **P10**(占位) | 5.7 自动回复实现 | TBD | P9 |
**合计 ~7 工作日(不含 P10)**。对比架构 A 的 10 天 + .NET 维护负担,显著简化。
## 5.12 · 风险与兜底
| 风险 | 可能性 | 兜底 |
|---|---|---|
| Weixin 升级后 raw key 在内存中的位置/格式变 | 中 | KeyExtractor 在 ps1 里做版本兜底,失败提示用户重启 backend |
| Weixin 改 cipher_compatibility 或换 KDF | 低(WCDB 标准) | 真改了升级 wcdb_codec.ts 即可,无需重打包 |
| DB 解密频率过高被 antivirus 检测 | 低 | 解密在内存做,只读 DB 文件不写;500ms-1s 防抖 |
| PC 微信未登录 / 未启动 | 必然 | weixinDbService 启动时探测,未就绪 channel.loginStatus='disconnected',前端卡片显示离线;**60s 健康探针自动重连** |
| Weixin 进程重启(PID 变) | 中 | 健康探针检测到 PID 变化或解密失败 → `unbindChannel` + `bindChannel` 重跑抽 key 流程 |
| 同时登录多个微信号 | 不存在 | PC 微信本身限制一机一号 |
| 用户输入群名与 Weixin 不一致 | 中 | 前端校验:群名长度 1-128;后端启动后 RoomResolver 扫描所有 session,若白名单某项匹配不上 → 前端卡片提示"未找到群: <name>" |
| **backend 在非 Windows 平台运行**(Linux/Mac dev 机) | 必然 | `weixinDbService.bindChannel` 检测 `process.platform !== 'win32'` 直接 return + 标 `loginStatus='unsupported_platform'`;不 spawn powershell,不让 backend crash |
| PowerShell 脚本路径解析错误 | 中 | `key_extractor.ts` 按 `NETA_WEIXIN_KEY_SCRIPT` env → `<installDir>/tools/win32/*.ps1` → `<cwd>/tools/win32/*.ps1` → `<module>/../../tools/win32/*.ps1` 顺序 fallback |
| **5.7 回复部分(未实现)** | 必然 | v1 暴露 `replyToGroup` 接口但抛 `NotImplementedError`;`handleDbInbound` 捕获后写入 sessionEntry "[占位:回复未实现]";不阻塞读链路 |
## 5.13 · 与 2026-05-08 原 Phase 成果的关系
| 原 spec 产出 | 方案 5 状态 |
|---|---|
| Phase 1 · `runtime/chat_scope.ts` + `runtime/errors.ts` | ✅ 完全保留 |
| Phase 2 · `entity/agent_channel_group.ts` + service + controller | ✅ 完全保留 |
| Phase 3 · `agent_executor.chatScope` + GroupInteractionDeclineError short-circuit | ✅ 完全保留 |
| Phase 4 · `routeInboundMessage` 分流 + senderQueue 群级共享 + clarify 短路 | ✅ 保留,只是按 channel.type 加一个 case |
| Phase 5 · 前端 channel-management / channel-group-panel | ✅ 保留,仅改文案 + group panel 重构为"用户主动添加" |
| Phase 6 · "UIA 采集" 相关代码(2026-05-09 的 A/B/C/D) | ❌ 作废,全删 |
| Phase 7 · iLink ClawBot DM 路径 | ✅ 保留(作为 DM 渠道) |
**结论:原 2026-05-08 的 20 个 task / 117 单测,95% 资产有效**。只替换传输层。
---
## 5.14 · 开发与部署形态(架构 C 关键澄清)
### DEV 模式工作流
backend 在 DEV 是普通 Node 进程,**无需打包成 backend.exe** 即可完整开发与调试 weixin-db 渠道:
```
开发者机器 (Windows + 已登录 Weixin 4.x):
├─ packages/backend/ → pnpm dev (Midway tsx HMR)
│ └─ 改 wcdb_codec.ts / message_repo.ts / weixin_db.ts → 热更新, 无需重启
├─ packages/backend/tools/win32/extract-weixin-key.ps1
│ └─ 改 ps1 → 下次 spawn 直接读最新版, 无需重启 backend
├─ packages/frontend/ → pnpm dev (Vite HMR)
└─ Weixin.exe → 用户保持登录
```
**只在以下三个时机需要打包成 exe**:
1. 端到端验证 installer 把 ps1 拷到正确路径
2. 给非开发者(测试用户)分发安装包
3. CI 烟囱测试
**绝大多数日常开发改动(代码、UI、PoC、测试)都用 `pnpm dev` 完成**。
### PowerShell 脚本路径解析(robust 兜底)
PS 脚本是纯文本资源,需在 DEV / pkg 打包 / installer 安装三种环境定位到。`key_extractor.ts` 按以下顺序 fallback:
```ts
function resolveExtractorScript(): string {
// 1. 环境变量(覆盖一切)
if (process.env.NETA_WEIXIN_KEY_SCRIPT) return process.env.NETA_WEIXIN_KEY_SCRIPT;
// 2. 模块自身相对路径(DEV / pkg 都行)
// runtime/weixin_db/key_extractor.ts → ../../../../tools/win32/extract-weixin-key.ps1
const moduleRel = path.resolve(__dirname, '..', '..', '..', '..', 'tools', 'win32', 'extract-weixin-key.ps1');
if (fs.existsSync(moduleRel)) return moduleRel;
// 3. installer 部署路径: <installDir>/tools/win32/*.ps1
const installDir = process.env.NETA_INSTALL_DIR || path.dirname(process.execPath);
const installRel = path.join(installDir, 'tools', 'win32', 'extract-weixin-key.ps1');
if (fs.existsSync(installRel)) return installRel;
// 4. 当前工作目录 fallback
const cwdRel = path.join(process.cwd(), 'tools', 'win32', 'extract-weixin-key.ps1');
if (fs.existsSync(cwdRel)) return cwdRel;
throw new Error('extract-weixin-key.ps1 not found in any candidate path');
}
```
### 跨平台兜底
backend 跨平台编译(Linux/Mac/Windows),但 weixin-db **只在 Windows 有意义**:
```ts
// service/weixin_db.ts
async bindChannel(channel, onInbound) {
if (process.platform !== 'win32') {
this.logger.warn('[weixin-db] non-Windows platform (%s), channel %s skipped', process.platform, channel.id);
// 标记 channel.loginStatus = 'unsupported_platform' (前端卡片显示)
await this.channelRepo.update({ id: channel.id }, { loginStatus: 'unsupported_platform' });
return;
}
// ...正常流程
}
```
效果:
- **Linux/Mac dev 机能完整跑 backend / 前端 / 单测**(只是 weixin-db channel 不工作,其他功能正常)
- 单测里所有 weixin_db service 测试用 jest mock,不依赖真实 powershell.exe → 跨平台跑通
### 健康探针 / 自动重连
```ts
// service/weixin_db.ts 新增
@Init()
async onInit() {
setInterval(() => this.healthCheck().catch(() => void 0), 60_000);
}
private async healthCheck() {
for (const [cid, runtime] of this.runtimes) {
try {
// 探测 Weixin 进程仍在 + DB 文件可读
const ok = await this.probeAlive(runtime);
if (!ok) {
this.logger.warn('[weixin-db] cid=%s lost connection, rebinding', cid);
this.unbindChannel(cid);
const channel = await this.channelRepo.findOne({ where: { id: cid } });
if (channel) await this.bindChannel(channel, this.cachedOnInbound);
}
} catch (err: any) {
this.logger.error('[weixin-db] healthCheck cid=%s: %s', cid, err.message);
}
}
}
```
### 实施里程碑(明确优先级)
| 里程碑 | 范围 | 价值 |
|---|---|---|
| **M1 · 读 + 展现** | Phase C-0 / C-1 / C-2 / C-3 / C-4 + Phase C-5 的 addByName | 用户能添加群名,backend 实时读到群消息,落 sessionEntry,前端 chat 页能看到对话流 |
| **M2 · 完整 UX** | Phase C-6 / C-7 / C-8 | 前端"+ 添加群"完整体验,安装包能装,E2E 跑通 |
| **M3 · 自动回复(留 v2)** | Phase C-9(独立 spec) | replyToGroup 真实实现 |
**M1 是最小可演示版本**:加群 → 看到群消息流到 chat 页面。回复占位即可。
---