1264 lines
76 KiB
Markdown
1264 lines
76 KiB
Markdown
|
|
---
|
|||
|
|
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 → decideGroupAcceptance(status + triggerMode 判定)
|
|||
|
|
③ 拒绝 → 丢弃并日志(reject 走 debug 级别,避免群活跃刷屏)
|
|||
|
|
④ 通过 → stripLeadingMention/stripLeadingPrefix 清洗文本
|
|||
|
|
⑤ handleInboundMessage(channel, state, scope, cleanedText)
|
|||
|
|
├─ agentExecutor.execute({ chatScope, message, onClarifyRequest, ... })
|
|||
|
|
│ ├─ beforeToolCall:chatScope==='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。
|
|||
|
|
|
|||
|
|
**并发 upsert(P1-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`:无条件 accept,cleanedText = 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 在群内允许,但有三条约束
|
|||
|
|
|
|||
|
|
Clarify(agent 主动问选择题)本身不危险,群里允许使用,但要解决"谁来回答"和"会不会卡住"。
|
|||
|
|
|
|||
|
|
**约束 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() 入参加 chatScope;beforeToolCall 命中风险时群路径抛 `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 icon;mounted 里处理 `?sessionId=` |
|
|||
|
|
|
|||
|
|
## 测试与验证
|
|||
|
|
|
|||
|
|
### 单元测试
|
|||
|
|
|
|||
|
|
| 文件 | 覆盖 |
|
|||
|
|
|---|---|
|
|||
|
|
| `test/modules/netaclaw/runtime/chat_scope.test.ts` | decideChatScope 四形态;sessionId/dispatchKey 构造;detectAtMention 协议命中 / 文本命中 / 误伤("@小神同学"、"@小神聊天群");stripLeadingMention 处理 U+2005、U+3000;stripLeadingPrefix;中段 @ 不被剥除 |
|
|||
|
|
| `test/modules/netaclaw/service/agent_channel_group.test.ts` | upsertOnInbound 首次 insert 默认 disabled;existing 仅更新 lastSeenAt;并发 upsert 不抛 duplicate key;toggle/updatePolicy 非法入参(prefix 模式缺 prefix、at_mention 模式缺 botAlias)拒绝;cascadeDeleteByChannel;touchActive |
|
|||
|
|
| `test/modules/netaclaw/service/agent_channel.group.test.ts` | routeInboundMessage 端到端:群被动发现默认不 dispatch;enable+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 为 finalContent;onRiskConfirmRequest 未被调用;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 走 info,accept=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 / wxhelper(PC 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 3(agent_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` 表到生产环境。测试库里已建可保留供后续路径复用。
|
|||
|
|
|
|||
|
|
**中长期方向**:见下两节"方案 3:WeChatFerry 路径"和"方案 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 页面。回复占位即可。
|
|||
|
|
|
|||
|
|
|
|||
|
|
---
|