GPU_GUARD_MONOREPO/docs/superpowers/specs/2026-05-09-wechat-uia-channel-design.md

783 lines
38 KiB
Markdown
Raw Normal View History

2026-05-20 21:39:12 +08:00
---
title: 微信 UIA 本地代理渠道设计weixin-uia
created: 2026-05-09
status: draft
related:
- docs/superpowers/specs/2026-05-08-weixin-group-channel-design.md
- docs/superpowers/plans/2026-05-08-weixin-group-channel.md
---
# 微信 UIA 本地代理渠道设计weixin-uia
> **⚠️ OBSOLETE 2026-05-14**: UIA 路线在微信 4.1.9.54 经 PoC(`tools/uia_probe/probe.ps1`)验证彻底失效(Qt 自绘 + `MMUIRenderSubWindowHW` 硬件加速渲染层 → UIA 树只有 3 节点 0 交互控件;讲述人 / 注册表 AccessibilityTemp / `QT_ACCESSIBILITY=1` 环境变量 / `StructureChangedEventHandler` 伪客户端全部无效)。
> 新方案见 `2026-05-14-neta-desktop-op-design.md` v4(通用桌面 GUI Agent,WeixinAdapter 是第一个 application adapter)。
> 本文件保留作历史参考。
## Context
Neta 当前仅接入 iLink ClawBot渠道类型 `weixin`),只能做一对一私聊助手,**不能接收微信群消息**——详见前 spec `2026-05-08-weixin-group-channel-design.md` 的"后记 · iLink ClawBot 群消息接入的事实更正"节。
经过多轮技术评估,**进入个人微信群收集客户问题并让 agent 介入处理**的唯一可行路径是:在用户本机 PC 微信上做 UI AutomationUIA代理。本 spec 基于以下决策:
- 走 Microsoft 官方 .NET 8 `System.Windows.Automation`(不走 Python wxauto、不走 WeChatFerry DLL 注入)
- 新增独立桥接进程 `Neta.WeChatBridge`,与 `Neta.Tray` 平级,复用现有 `tray-secret` IPC 模式
- iLink ClawBot 渠道(`weixin`)保留为**个人助手**UIA 渠道(`weixin-uia`)专门做**群聊助手**
- 今天 2026-05-08 已实施的 20 个 task 绝大部分抽象可直接复用chat_scope / agent_executor chatScope / session 构造 / 群管理 UI 等)
本 spec 负责描述 UIA 渠道从 0 到 MVP 的完整架构、消息采集、触发策略、前端扩展、与现有代码的增量关系。
## 总体架构
```
┌──────────────── Windows 用户桌面会话 ────────────────┐
│ │
│ PC 微信(用户的个人微信号,已登录) │
│ ↑ (System.Windows.Automation COM API) │
│ │ │
│ ▼ │
│ Neta.WeChatBridge.exe新增 .NET 8 进程) │
│ ├─ UIA 监听器StructureChanged 事件 + 切窗) │
│ ├─ 消息采集(文本 / 图片 / @ / 引用) │
│ ├─ 图片从 WeChat Files 目录拷到 dataDir │
│ ├─ 版本白名单校验 │
│ └─ 暴露 localhost HTTP + x-neta-tray-secret │
│ ↓ │
│ Neta backend (Node.js) │
│ ├─ service/weixin_uia.ts ← 客户端 │
│ │ ├─ POST /send 发回复 │
│ │ └─ GET /rooms 拉群列表快照 │
│ ├─ controller/open/weixin_uia.ts ← 入站入口 │
│ │ 接 bridge 推送的群消息 │
│ ├─ service/agent_channel.ts 按 channel.type │
│ │ 分流:'weixin' → iLink / 'weixin-uia' │
│ │ → bridge同一套 routeInboundMessage │
│ ├─ 群聊 archive → SQLite (dataDir/wechat- │
│ │ archive-<cid>.db) │
│ └─ 被 trigger 命中的消息 → session_entry │
MySQL 主库) │
│ │
│ Neta.Tray已存在负责拉起 backend + bridge
└──────────────────────────────────────────────────────┘
```
### 关键决策
- **channel.type 多态**iLink ClawBot = `weixin`(仅 DMUIA = `weixin-uia`(仅群聊)。两种 channel 可同时存在
- **wxid ↔ weixin-uia channel 一对一**(硬约束):同一 wxid 最多绑一个 UIA channel。handshake 时发现多个会返回 409 要求手工清理;前端新建 UIA channel 页同 wxid 已存在时拒绝
- **严格职责划分**ClawBot 即使收到群消息也丢弃UIA 即使收到 DM 也丢弃(防止双渠道重复响应)
- **进程模型**Tray 启动时拉起 `backend.exe` + `WeChatBridge.exe`,共享 tray-secret
- **双库存储**SQLite 存全量原始群消息含被拒绝的MySQL session_entry 只存被 agent 接纳的
- **图片策略**:从微信 `%APPDATA%\Tencent\WeChat\WeChat Files\<wxid>\FileStorage\Image\` 拷到 `dataDir/wechat-uploads/<channelId>/YYYY-MM/<hash>.jpg`backend 通过现有 `/files` 静态服务暴露
- **触发判定**:硬规则 `at_mention` / `all`all 模式让 agent 自行判断是否相关并通过返回空串/`[SKIP]` 决定不回),不做 LLM 意图过滤的独立一层。**`prefix` 模式在新渠道中弃用**——数据库字段保留以兼容存量 iLink 数据,`decideGroupAcceptance` 纯函数仍能读取旧配置,但 `updatePolicy` service 层拒绝新写入 `prefix`,前端不暴露该选项
- **IPC 复用**Bridge ↔ Backend 使用 `localhost HTTP + x-neta-tray-secret`,与 Tray ↔ Backend 现有模式完全一致
- **Bridge URL 注册**bridge handshake 时必须在 body 里带 `bridgeBaseUrl``http://127.0.0.1:<port>`)告知 backend 自己的监听地址backend 据此构造 HTTP 客户端用于 `/send` `/enable-room` 等主动调用
- **Room 启用同步**:前端点"启用监听" → backend 更新 `group.status=1`**backend 主动调 `bridge /enable-room`** 让 bridge 实时订阅disable 同理。bridge 重启后通过 handshake 响应的 `enabledRooms` 批量恢复订阅
### MVP 与 v2 边界
MVP 覆盖:
- 文本消息完整往返
- 图片消息采集 + 本地归档 + agent vision 可见
- @ 检测、引用消息的结构化 prompt 投影
- 多群切窗监听、每群独立 agent 绑定
- 群内回复身份silent / `【AI 助手】` 前缀)
- Bridge 崩溃自愈、版本白名单
v2不在本 spec 范围):
- 语音转文字
- 视频、文件下载
- 跨群全局人物志
- bridge 主动发图片/文件到群
---
## 数据模型
### 复用 / 扩展已有表
**`netaclaw_agent_channel`**(今天 task 已用)
- `type` 字段新增合法值 `weixin-uia`(与现有 `weixin` 并存)
- `credential` JSON 对 `weixin-uia` 的含义:
```ts
{ wxid: 'wxid_xxx', nickname: '张三', wechatVersion: '3.9.11.17' }
```
不再有 iLink token / accountId——UIA 渠道认的是"本机 PC 微信登录的这个号"
- `config.group` JSON 扩展:
```ts
group: {
botAlias?: string; // @ 匹配用(已有)
replyIdentity?: 'silent' | 'ai_prefix'; // 新:默认 silent
}
```
**`netaclaw_agent_channel_group`**(今天 task 新建)
- 字段基本不变
- `triggerMode` 允许值从 3 档收敛为 **2 档**`at_mention` / `all``prefix` 已**弃用**——`updatePolicy` service 层拒绝写入 `prefix``decideGroupAcceptance` 纯函数仍容忍 DB 里残留的 `prefix` 记录(不 break 存量 iLink 数据);前端 UI 只给 2 个选项
- 新增字段 `replyIdentityOverride?: 'silent' | 'ai_prefix' | null`(每群可覆盖 channel 级默认)
- 新增字段 `boundAgentId: number | null`(每群可独立绑定一个 agent覆盖 channel 级默认 agent
- `status` 字段引入第三态 `-1 = 已否决ignored`:用户显式"忽略"过的群不再在"待审批"横幅提醒
### 新增表 `netaclaw_wechat_archive`SQLite
独立 SQLite 文件存放于 `dataDir/wechat-archive-<channelId>.db`,每个 UIA 渠道独立一个文件(便于备份/清理)。
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK | 自增 |
| `roomId` | TEXT | 群稳定 id见下 |
| `msgId` | TEXT | UIA 合成的消息唯一键见下UNIQUE |
| `senderWxid` | TEXT nullable | 发送者 wxidUIA 大多数情况读不到) |
| `senderName` | TEXT | 发送者群内昵称 |
| `msgType` | TEXT | `text` / `image` / `file` / `voice` / `video` / `system` / `quote` |
| `content` | TEXT | 文本内容(图片类型下为 OCR 文本或空) |
| `attachmentPath` | TEXT nullable | 本地文件路径(`dataDir/wechat-uploads/...` |
| `quotedRef` | TEXT nullable | 引用消息 JSON被引用者 + 原文本预览) |
| `atList` | TEXT nullable | 被 @ 的人JSON 数组) |
| `receivedAt` | TEXT | bridge 观测时间戳ISO8601 |
| `triggerAccepted` | INTEGER 0/1 | 是否通过 trigger gate |
| `triggerReason` | TEXT nullable | 拒绝时的 reason |
| `sessionEntryId` | TEXT nullable | 关联 MySQL session_entry id |
| `createdAt` | TEXT | 入库时间 |
索引:`(roomId, receivedAt)``UNIQUE(msgId)``(triggerAccepted)`
**`msgId` 合成规则**`sha256(roomId + '|' + senderName + '|' + content + '|' + receivedAt.slice(0,16))`,精度到分钟。避免切窗重复读同一消息时重复入库。
**`roomId` 稳定 id 策略**:微信 UI 不暴露群的原生 id。采取 `${channelId}:room:${sha1(roomName)}` 作为稳定 id群被用户改名后视为新群重新发现、重新审批
### 主库 session_entry 不变
被 trigger 接纳的群消息继续按现有 session_entry 机制落 MySQLsessionId 规则沿用 `channel:<cid>:weixin:group:<roomId>`——前端 agent 对话页透明复用。
### 数据流向
```
UIA 事件 → bridge → POST /inbound → Node backend
↓ (SQLite 写入全量原始消息triggerAccepted=0)
routeInboundMessage → decideGroupAcceptance
↓ ↓
↓ (reject: SQLite 已记录,结束)
↓ (accept)
SQLite 回写 triggerAccepted=1 + sessionEntryId
agentExecutor.execute → finalizeAssistantEntry 写 MySQL session_entry
POST /send 回 bridge → UIA 模拟发送(若 finalContent 非空且非 [SKIP]
```
---
## 消息采集与图片处理
### UIA 能读到的群消息字段
从微信主窗口右侧"聊天框"的 `ListControl(Name='消息')` 里,每条消息是一个 `ListItem`
```
ListItem
├── Button / Text发送者昵称群内显示名不是 wxid
├── Pane消息气泡
│ ├── Text纯文本内容
│ │ 或 Image + TextBlock图片消息含文件名
│ │ 或 Button文件消息含文件名和大小
│ │ 或 Button引用消息"XXX: 原文..."+ 本条回复)
│ └── 右键菜单(上下文菜单)
└── Text时间戳
```
对每种消息类型的处理:
| 消息类型 | 采集策略 | 落 SQLite |
|---|---|---|
| **文本** | 直接读 `Text.Name` | `msgType='text'`, `content` |
| **@** | 解析文本中 `@<alias>` + UIA 如果给 at list 则优先取 | `atList` JSON |
| **引用** | 读气泡顶部的 "XXX: 原文..." 子控件 | `quotedRef` JSON |
| **图片** | DAT 解密 / 拷贝,见下 | `msgType='image'`, `attachmentPath` |
| **文件** | 从 WeChat Files 目录拷出,见下 | `msgType='file'`, `attachmentPath` |
| **语音** | MVP 不处理v2 右键"转文字" | `msgType='voice'`, `content` 留空 |
| **视频** | 只记 metadata文件名不下载 | `msgType='video'`, 无 attachmentPath |
| **系统消息**(撤回通知等) | 读文本 | `msgType='system'` |
| **表情/动图** | 商店贴图忽略;自定义 GIF 按图片处理 | `msgType='image'` 或丢弃 |
### 图片与文件的获取路径
**不用 UIA 模拟右键"另存为"**慢、UI 闪烁。PC 微信把图片/文件缓存在固定目录bridge 直接访问文件系统:
```
%APPDATA%\Tencent\WeChat\WeChat Files\<wxid>\FileStorage\
├── Image\YYYY-MM\<hash>.dat ← 加密图片(固定 XOR
├── Image\YYYY-MM\<hash>.jpg ← 解密缓存(微信打开过会生成)
└── File\YYYY-MM\<filename>.xxx ← 未加密文件
```
Bridge 的做法:
1. UIA 检测到图片消息到达
2. 读气泡里的文件名/时间戳定位目录
3. 轮询匹配最新的 `.dat`(图片)或文件名(文件)
4. 图片:如果 `.jpg` 缓存已存在用 `.jpg`;否则用公开的 DAT-XOR 算法解密 `.dat`(每字节异或一个常量,识别文件头决定异或 key
5. 拷贝到 `dataDir/wechat-uploads/<channelId>/YYYY-MM/<msgIdHash>.ext`
6. 本地路径落到 SQLite `attachmentPath`
**fallback**:解密失败或找不到 → 记 `.dat` 原路径agent 对话页渲染"⚠️ 图片无法显示",不丢整条消息。
### 发送回复
1. Backend POST `/send` 给 bridge`{ roomName, text, atList? }`
2. Bridge 调 UIA切到该群窗口 → `SetFocus` 输入框 → `SendKeys` 文本 → `SendKeys(Enter)`
3. 读最后一条"自己发送的消息"气泡确认时间戳 > 发送前,回 200
**MVP 不支持发送图片/文件到群**——agent 回复限定为文本。
### 身份控制(`replyIdentity`
- `silent`(默认):直接发 `${text}`
- `ai_prefix`:发 `【AI 助手】${text}`
### 发送者 wxid 的获取
UIA 大多数情况只能读到"群内昵称"而非 wxid。存储策略
- SQLite 存 `senderName`(必有)
- `senderWxid` 绝大多数为 NULL
- 启动时主动拉群成员列表,建立 wxid ↔ 当前昵称 映射表v2 补)
- agent 看到的 message 以 `[senderName]: content` 前缀呈现,让 LLM 基于上下文自行区分
### 去重 / 首次发现
- `msgId` UNIQUE 约束避免切窗重复读
- 首次发现群**不 backfill 历史消息**,只认"现在能看到的最新一条"为起点,避免一次性喂给 agent 几百条堆积消息
### 端到端延迟预估
- StructureChanged 事件触发 → < 100ms
- 切窗 → 读列表 → 切回:约 500ms
- 图片落地含缓存等待1-3 秒
- 发送回复:约 500ms
**典型延迟**:文本 ~1 秒,图片 ~2-3 秒。
---
## 多群监听策略(切窗调度)
### 事件源
Bridge 在微信主窗口上注册:
- **`StructureChangedEvent`**订阅左侧会话列表Sessions 栏)
- **`AutomationPropertyChangedEvent(NameProperty)`**:订阅会话列表项的文本变化(= 最新消息预览变了 = 有新消息)
**事件源 = 会话列表,而非聊天框**。切窗后读聊天框只是事件触发后的动作。
### 切窗调度队列
所有切窗串行化UIA 对同一窗口不支持并发):
```
事件触发 → enqueue(roomName)
单线程 worker:
├─ 从队列 dequeue 一个 roomName
├─ 校验该群 channel_group.status=1enabled
│ 未启用/未审批 → SQLite "发现但未审批" + 通知 backend前端红点
│ 已否决(-1) → 直接丢弃
│ 已启用(1) → 继续
├─ UIA: 点击左侧会话列表对应条目 → 右侧聊天框出现该群
├─ 采集新消息(与"上次该群已知 msgId"去重)
├─ 写 SQLite
├─ 调 backend POST /inbound
└─ 完成后不切回原窗口,等下一个事件触发
```
**不切回原窗口**用户可能原本在看某个群来回切会让体验崩坏。只在需要时切到目标群结束停在那里。用户自己想看别的群会手动切走——bridge 下次事件也是切到其它群,对用户无额外干扰。
### 队列合并与背压
- **去重合并**:同一 roomName 连续来多条事件,只处理一次切窗——切到该群后一次性读完所有未读
- **超限丢弃**:队列 >50 待处理 room → warn 日志 + 丢 oldest 保 newest
- **单次超时**:单次切窗 3 秒超时 → 跳过该群、保留队列
- **并发上限**:单 bridge = 单 PC 微信实例 = 单点串行。不存在真并发
### Bridge ↔ Backend 通信方向
**Bridge 主动推 inboundBackend 主动调 send/rooms/health**。不用 long-poll。理由
- 与 Tray → Backend 现有 IPC 方向一致
- Backend 定期调 `GET bridge/health` 检测 bridge 存活
HTTP 接口清单:
```
Bridge 暴露(被 backend 调):
GET /health → { ok, wxid, nickname, wechatVersion }
GET /rooms → 能看到的群列表
POST /send { roomName, text, atList? } → 发送回复
POST /enable-room { roomName } → 加入监听
POST /disable-room { roomName } → 取消监听
GET /diag → 诊断信息(队列长度、最近错误)
Backend 暴露(被 bridge 调):
POST /open/netaclaw/channel/uia/handshake
{ wxid, nickname, wechatVersion, bridgeBaseUrl }
→ { channelId, enabledRooms: string[] } 认领 channel + 返回已启用群列表
POST /open/netaclaw/channel/uia/inbound
{ channelId, roomName, senderName, msgType, content, attachmentPath?,
atList?, quotedRef?, receivedAt, rawHash }
→ 入站消息
```
> `handshake` 不再接受 `channelId?` 请求字段——bridge 启动时不知道 channelId只能靠 wxid 查找channelId 是响应字段。`bridgeBaseUrl` 为必填(形如 `http://127.0.0.1:<port>`backend 据此注册 HTTP 客户端用于后续 `/send` `/enable-room` 等主动调用。
两端用 `x-neta-tray-secret` 鉴权,与 Tray ↔ Backend 共用。
### Bridge 冷启动序列
1. 读 PC 微信进程版本 → 校验白名单 → 不匹配则 exit(1) + tray 通知
2. 定位微信主窗口 → 拿 `wxid` / `nickname`
3. `POST /handshake` → backend 认领 channel返回该 channel 已启用的群列表
4. 订阅会话列表的 StructureChanged + Name 事件
5. 主动扫一次当前会话列表,把未在启用列表里的新群上报为"待审批"
6. 进入 worker 循环
### 重启不回灌
Bridge 重启后**只处理重启后新出现的事件**,不补齐 SQLite 已记录消息的空窗期——避免一次性塞几百条历史消息给 agent。
### 运行时约束
- 微信主窗口不能最小化到系统托盘(可以置于屏幕外/移到虚拟桌面但**不能完全最小化**——UIA 对 minimized 窗口读不到控件树
- 同时只能登录一个微信号PC 微信本身限制)
- Bridge 运行期间用户操作微信正常——bridge 只在事件触发时短暂切窗,平时不干扰
---
## Backend 分流与触发策略
### `agent_channel.routeInboundMessage` 按 channel.type 分流
今天 task 里 `routeInboundMessage` 已经是"传输层无关"的同步分发器。本 spec 只加一个入口适配:
```
Bridge POST → controller: /open/netaclaw/channel/uia/inbound
body: { channelId, roomName, senderName, msgType, content, attachmentPath?, atList?, quotedRef?, rawHash, receivedAt }
agentChannelService.ingestUiaInbound(...)
构造"伪 message"shape 与 iLink 入站一致,让下游 routeInboundMessage 无感):
{
from_user_id: senderName, // 群内昵称充当 sender
room_id: roomIdStable, // ${channelId}:room:${sha1(roomName)}
message_id: rawHash, // bridge 合成的去重 key
item_list: [{ type: 1, text_item: { text: content } }],
at_user_list: atList,
attachments: attachmentPath ? [{ kind: msgType, path: attachmentPath }] : undefined,
quoted_ref: quotedRef,
}
routeInboundMessage(channel, state, message) ← 今天 task 已实现的同步分流
↓ (群路径)
├─ decideChatScope → kind='group'
├─ 查 pendingClarify 短路
├─ senderQueue 按 roomId 入队(群级共享串行,已实现)
└─ handleInboundMessage 异步处理
```
### handleInboundMessage 的增量改动
- 触发策略从 3 档 → 2 档:`at_mention` / `all`。**`prefix` 在 service 写入层(`updatePolicy`)抛 400 拒绝**`decideGroupAcceptance` 纯函数仍处理 `prefix`(兼容存量数据);前端 UI 只显示 2 档
- `all` 模式下 acceptance 无条件通过,但传给 agent 的 `userMessageMetadata` 多带 `shouldEvaluate: true` 标志,提示 agent "你要自己判断相关性"
- agent 返回 `finalContent === ''` 或以 `[SKIP]` 开头 → `handleInboundMessage` **不调 sendText**agent 沉默)
### 每群绑定 agent`boundAgentId`
```ts
const group = await this.groupService.findByKey(channel.id, scope.chatId);
const effectiveAgentId = group.boundAgentId ?? channel.agentId;
if (!effectiveAgentId) {
this.logger.warn('[UIA] group_no_agent_bound channelId=%s roomId=%s', channel.id, scope.chatId);
return;
}
// boundAgentId 覆盖时channel.agentName 可能不对(指向 fallback agent传 undefined 让
// agentExecutor 内部按 effectiveAgentId 查正确 agentName避免 prompt 里 agent 身份错乱。
const effectiveAgentName =
effectiveAgentId === channel.agentId ? (channel.agentName || undefined) : undefined;
```
### 图片附件喂给 agent 的通路
伪 message 里带 `attachments: [{ kind: 'image', path: '...' }]`——复用 Neta 现有 `runAgent.userMessageMetadata.attachments` 通路(`gateway/server.ts:564` 已支持agent 看到带图片附件的 user message能调 vision 工具识图。
### 引用 / @ 的 prompt 投影
给 LLM 的 user message 不是裸 content而是**结构化上下文渲染**(在 `handleInboundMessage` 构造 cleanedText 时生成):
```
[群: 产品研发群]
[发送人: 小王]
[被引用: 老板18:32: 这个方案明天就上线]
[被@的人: 张三, @小神bot]
消息正文:
确定可以上线吗?@小神 帮忙 double check 一下
```
### Clarify / Risk 在群路径的处理
- **Clarify**:沿用今天 task 的"发起者绑定 + 5 分钟超时 + 超时 short-circuit 成 Decline"。clarify 回复的身份遵循 `replyIdentity` 配置
- **Risk**:沿用"直接 block + 回群里发拒绝文字"。拒绝文字也遵循 `replyIdentity`
### 发送路径
```ts
if (result.content.trim() === '' || result.content.startsWith('[SKIP]')) {
// agent 选择不回。更新 SQLite.sessionEntryId不调 bridge /send
return;
}
const finalText = resolveReplyIdentity(channel, group) === 'ai_prefix'
? `【AI 助手】${result.content}`
: result.content;
await this.uiaBridgeClient.sendText(roomName, finalText);
await this.groupService.touchActive(channel.id, scope.chatId);
```
### Bridge 离线时 backend 的处理
- Backend 每 30 秒 `GET bridge/health`,连续 2 次失败 → 标记 channel `loginStatus='disconnected'`,前端卡片显示离线
- 离线期间bridge 不推消息、agent 不执行
- 重连channel 自动转回 `connected`,继续运转
### 严格的跨渠道去重
同一 wxid 可能同时存在 iLink ClawBot 渠道 + UIA 渠道。为避免重复响应:
- iLink ClawBot 收到 `room_id` 非空的消息 → 按现有逻辑**丢弃**(今天 task 已有)
- UIA 收到 DM`chat_type='dm'` → 日志 warn + **丢弃**,不调 agent
- 这个约束写进 `routeInboundMessage` 的 channel.type 分流入口
---
## 前端 UX 调整
### 频道管理页(`channel-management.vue`
drawer 新增**频道类型**下拉:
```
○ 微信 ClawBot个人助手 · 仅私聊) ← 现有 type='weixin'
● 微信本地代理(群聊助手 · 需 PC 微信) ← 新增 type='weixin-uia'
```
按 type 动态渲染表单字段:
| 字段 | weixin | weixin-uia |
|---|---|---|
| 扫码登录 | ✅ | ❌ |
| `wxid` 手填 | ❌(由 iLink 返回) | ✅ **必填**(从 PC 微信"设置→关于"复制handshake 时 bridge 按此匹配本频道并回填 nickname/wechatVersion |
| 机器人昵称 `botAlias` | ✅(必填) | ✅(选填,默认用微信昵称) |
| 默认 Agent | ✅(必填) | ✅(可空,群可各自绑) |
| 群内回复身份 `replyIdentity` | ❌ 不适用 | ✅ `silent` / `ai_prefix` 下拉 |
| Bridge 连接状态 | ❌ | ✅ 展示 `已连接 / 未就绪 / 版本不兼容` |
卡片元数据行的徽标(今天 task 已有)仍有效:`群聊 X/Y`。UIA 渠道加 `微信版本不兼容` 红色 tag版本白名单判定失败时
### 群聊管理抽屉(`channel-group-panel.vue`
今天 task 已建好整个组件,本 spec 扩展:
- **触发策略** 从 3 档改 2 档:`@机器人` / `所有消息(由 agent 自行判断是否回复)`
- **每群绑定 agent**新字段默认空fallback 到 channel 默认):
```
绑定 agent[下拉选择] 未选 → 使用频道默认 Agent
```
- **回复身份覆盖**(新字段):
```
回复身份:○ 跟随频道 ○ 隐形 ○ 【AI 助手】前缀
```
- **待审批横幅**(对应"被动发现 + 前端审批"决策):
- 列表顶部 `新发现 N 个群(待审批)` 橙色横幅
- 点击横幅 → 过滤 status=0 的群
- 每个新群卡片加两个按钮:`启用监听` / `忽略`
- "忽略" → status=-1不再在横幅重复提醒
### agent 对话页(`chat.vue`
- sessionId 仍是 `channel:<cid>:weixin:group:<roomId>``buildFallbackTitle` 今天已支持,显示 `微信群 · ...`
- **新增**:消息气泡左上角小标识,区分 DM 和群消息(按 sessionId 模式判断,不引入 metadata.chat
### 归档查看页(`wechat-archive-panel.vue`
SQLite 原始消息归档查看抽屉,入口放在群聊管理面板每个群卡片里 `查看归档` 按钮:
- 按 roomId 过滤
- 按 triggerAccepted 筛选(全部 / 只看已接纳 / 只看被拒绝)
- 时间轴展示,图片/文件可点开预览
- 支持"标记为有价值 → 转存到 MySQL 业务表"v2 兑现UI 先预留按钮)
### Neta.Tray 菜单扩展
```
Neta AI
├─ 状态:运行中
├─ 打开控制台
├─ 微信桥接:
│ ├─ 状态:✅ 已连接(小明,微信 3.9.11.17
│ ├─ 重启桥接
│ └─ 查看日志
└─ 退出
```
Tray 负责 bridge 进程生命周期(新增 `BridgeProcessManager`,模式参照 `BackendProcessManager`)。
---
## Neta.WeChatBridge 项目结构与部署
### .NET 项目骨架
```
packages/windows-tray/
├── Neta.Tray/ ← 已存在
├── Neta.Tray.Tests/ ← 已存在
├── Neta.WeChatBridge/ ← 新增
│ ├── Neta.WeChatBridge.csproj (net8.0-windows, OutputType=Exe)
│ ├── Program.cs 启动入口:读取参数 / 启 HTTP / 初始化 UIA
│ ├── Config/
│ │ ├── VersionProfile.cs 微信版本适配 profile
│ │ └── VersionProfiles.yaml 可热更新的控件名/路径配置
│ ├── Uia/
│ │ ├── WeChatWindow.cs 主窗口定位 + 子控件缓存
│ │ ├── SessionListWatcher.cs 会话列表事件订阅(事件源)
│ │ ├── ChatBoxReader.cs 切窗 + 读消息列表
│ │ ├── MessageSender.cs 发文本 + @
│ │ └── AttachmentExtractor.cs 图片/文件从 WeChat Files 目录拷出
│ ├── Scheduling/
│ │ └── RoomEventQueue.cs 切窗串行队列 + 去重合并
│ ├── Http/
│ │ ├── BridgeHttpServer.cs Kestrel 托管的本地 HTTP
│ │ ├── Endpoints/
│ │ │ ├── HealthEndpoint.cs
│ │ │ ├── RoomsEndpoint.cs
│ │ │ ├── SendEndpoint.cs
│ │ │ └── EnableDisableEndpoint.cs
│ │ └── TraySecretAuth.cs 与 tray 共用 secret
│ ├── Backend/
│ │ └── BackendClient.cs 推送 inbound 到 backend
│ └── Runtime/
│ ├── BridgeRuntimeInfo.cs 读取 tray 传来的 bootstrap 参数
│ └── GracefulShutdown.cs SIGTERM / Ctrl+C 处理
└── Neta.WeChatBridge.Tests/ 新增xUnit
```
### 启动参数与 IPC 凭证
Neta.Tray 启动时生成 `tray-secret`,同时传给 `backend.exe``bridge.exe`
```
WeChatBridge.exe \
--tray-secret <secret> \
--backend-url http://127.0.0.1:<port> \
--data-dir <dir> \
--bridge-port <port>
```
与现有 `BackendProcessManager` 模式一致。
### `BridgeProcessManager`(新增,参照 `BackendProcessManager`
```csharp
public sealed class BridgeProcessManager {
public static ProcessStartInfo BuildBridgeStartInfo(string exe, string traySecret, string backendUrl, string dataDir, int port);
public Process Start(string exe, string traySecret, string backendUrl, string dataDir, int port);
public bool IsBridgeProcessAlive(int pid);
public void KillProcess(int pid);
public void WaitForExit(int pid, TimeSpan timeout);
}
```
**Tray 启动顺序**
1. 启 backend`/status.ready`
2. 启 bridge`GET bridge/health` 返回 200
3. Bridge `POST /handshake` → backend 标记 channel `loginStatus='connected'`
**崩溃自愈**:按 `BackendProcessManager` 同样的 `IsBridgeProcessAlive` 轮询;最多 3 次重启30 秒间隔),超限后 tray 气泡通知用户。
### 安装包分发
Neta 已有 Windows 安装包构建脚本 `packages/backend/scripts/build-windows-installer.js`。改动:
- `dotnet publish -c Release -r win-x64 --self-contained=false` 发布 bridge.exe依赖系统 .NET 8 运行时,与 Tray 一致)
- 安装包把 `bridge.exe` 放到 `{installDir}/bin/bridge/bridge.exe`
- Tray 启动时按固定相对路径找 bridge
- 升级时覆盖 bridge**不动** `%APPDATA%/Neta/` 下的用户数据
### 运行时数据目录
```
%APPDATA%/Neta/
├── runtime/tray-info.json ← 已有
├── wechat-archive-<cid>.db ← 新增 SQLite
├── wechat-uploads/<cid>/<YYYY-MM>/*.{jpg,mp4,...} ← 新增附件缓存
└── logs/bridge.log ← 新增 bridge 日志
```
### 版本白名单 profile
`Config/VersionProfiles.yaml` 形如:
```yaml
profiles:
- version: "3.9.11.17"
mainWindowClass: "WeChatMainWndForPC"
sessionListName: "会话"
messageListName: "消息"
searchBoxName: "搜索"
inputBoxName: "输入"
imageCacheDir: "FileStorage/Image/{YYYY-MM}"
- version: "3.9.12.x"
inherit: "3.9.11.17" # 继承上一个 profile仅覆盖差异
```
Bridge 启动时读 PC 微信 `WeChat.exe``FileVersion`,匹配 profile。未匹配 → exit(1) + tray 气泡通知"该微信版本未经适配"。
### 监控与诊断
- Bridge `GET /diag`(需 tray-secret返回
- PC 微信版本号
- 当前会话列表数量 / 已启用群数
- 最近切窗耗时 p50/p95
- 队列长度
- 最近 5 条 UIA 错误堆栈
- Tray 菜单"查看日志"打开 `%APPDATA%/Neta/logs/bridge.log`
- 前端"频道管理 → weixin-uia 卡片"点击可调 `/diag` 展示运维信息
### 测试策略
- **Neta.WeChatBridge.Tests**xUnit
- 纯逻辑单测:消息 hash 去重、队列合并、版本 profile 匹配、YAML 解析
- UIA 集成测试CI 环境没 PC 微信 → 整体 skip只在本地开发者机器跑
- **端到端手工验证清单**(对齐 spec 最后节)
---
## 与今天 2026-05-08 实施代码的关系
### 完全复用(零修改)
| 模块 | 复用内容 |
|---|---|
| `runtime/chat_scope.ts` | `decideChatScope` / sessionId 构造器 / dispatchKey / clarify key / `decideGroupAcceptance``prefix` 分支保留不删,仅前端不暴露)/ `detectAtMention` / strippers |
| `runtime/errors.ts` | `GroupInteractionDeclineError` + `withClarifyTimeout` |
| `runtime/tool_execution_metadata.ts` | 纯函数完全无关 |
| `service/agent_executor.ts` | `chatScope` + `GroupInteractionDecline` short-circuit 全部不动 |
| `service/agent_channel.ts``routeInboundMessage` / `handleInboundMessage` 主体 / senderQueue / pendingClarify 逻辑 | 框架完全不动,仅在 `routeInboundMessage` 入口处加 channel.type 分流 switch |
| `service/agent_channel_group.ts`upsertOnInbound / toggle / updatePolicy / list / cascadeDeleteByChannel / statsByChannels | 不动 |
| `controller/admin/agent_channel_group.ts`list/toggle/updatePolicy/rename/delete | 不动 |
| 前端 `channel-group-panel.vue` 主体结构 | 不动 |
| 前端 `agent/views/chat.vue` 深链接 + `buildFallbackTitle` | 不动 |
### 小改(增量扩展)
| 模块 | 改动 |
|---|---|
| `entity/agent_channel_group.ts` | 加可选字段 `boundAgentId: number \| null` / `replyIdentityOverride: string \| null` |
| `service/agent_channel_group.ts.updatePolicy` | triggerMode 合法集合改为 `at_mention` / `all``prefix` 调用返回 400保留字段语法以兼容存量数据 |
| `service/agent_channel.ts.runLoop` | 按 channel.type 分流:`weixin` 走现有 iLink 逻辑;`weixin-uia` 不起 runLoop由 bridge 主动 POST 触发) |
| `service/agent_channel.ts.handleInboundMessage` | + `replyIdentity` 包装回复文字;+ `boundAgentId` 覆盖 default agent+ agent 返回 `[SKIP]`/空串时跳过 sendText |
| `service/agent_channel.ts` | DM/group 反向丢弃约束UIA 拒收 DMClawBot 拒收群) |
| 前端 `channel-management.vue` | type 下拉加 `weixin-uia`drawer 字段按 type 动态渲染UIA 渠道隐藏扫码按钮;新增 bridge 状态 tag |
| 前端 `channel-group-panel.vue` | + 回复身份下拉;+ 绑定 agent 下拉;+ "待审批" 横幅 + "忽略" 按钮 |
### 全新增
| 模块 | 用途 |
|---|---|
| `packages/windows-tray/Neta.WeChatBridge/` 整个项目 | .NET 8 UIA 桥接进程 |
| `packages/windows-tray/Neta.WeChatBridge.Tests/` | bridge 单测 |
| `Neta.Tray/BridgeProcessManager.cs` | 拉起 / 监控 bridge |
| `service/weixin_uia.ts` | Node 侧 bridge HTTP 客户端send / rooms / health |
| `service/wechat_archive.ts` | SQLite 归档服务 |
| `entity/wechat_archive_entry.ts`(或 SQL DDL | SQLite archive 表定义 |
| `controller/admin/wechat_archive.ts` | 前端查看归档 / 筛选 / 导出 |
| `controller/open/weixin_uia.ts` | bridge → backend 的 inbound / handshake / room-list-snapshot |
| 前端 `components/wechat-archive-panel.vue` | 归档查看抽屉 |
### 今天 task 已验证资产保留
- 117/117 单测保持有效(增量改动不破坏)
- iLink 渠道部分的 SQL migration 保留
- 已部署到测试库的 `netaclaw_agent_channel_group` 表保留,仅 `ALTER TABLE ADD COLUMN` 加 2 个可选字段
### 改造工作量粗估
| 阶段 | 内容 | 工作量 |
|---|---|---|
| Phase A | Neta.WeChatBridge 项目骨架 + UIA 消息读取 POC | 3-5 天 |
| Phase B | Bridge 完整能力(多群切窗 / 图片 / 发送 / 身份) | 5-7 天 |
| Phase C | Backend 适配controller + weixin_uia.ts + wechat_archive | 3 天 |
| Phase D | 前端扩展type 下拉 / 每群绑 agent / 待审批 / 归档页) | 3 天 |
| Phase E | Tray 拉起 bridge + 生命周期 + 安装包 | 2 天 |
| Phase F | 端到端手工验证清单 + 版本白名单 profile | 2-3 天 |
| **合计** | | **约 3-4 周(一个全职工程师)** |
### MVP 降级方案
如果 Phase B 的"图片 DAT 解密"或 Phase C 的"SQLite 归档"成本超预期,可砍 MVP
- 仅文本消息(图片记 metadata 但 attachmentPath 留空UI 显示"⚠️ 图片不可用"
- 仅 MySQL session_entrySQLite 归档推迟到 v2
MVP 覆盖 80% 核心需求,后续追加。
---
## 验证
### 单元测试
| 文件 | 覆盖 |
|---|---|
| `Neta.WeChatBridge.Tests` | 消息 hash 去重 / 队列合并 / 版本 profile 匹配 / YAML 解析 / DAT-XOR 解密单测(含已知向量) |
| `test/modules/netaclaw/runtime/chat_scope.test.ts`(已有)| triggerMode='all' + agent 返回 [SKIP] 不发送 |
| `test/modules/netaclaw/service/agent_channel.uia.test.ts`(新增) | UIA 渠道 routeInboundMessage 分流UIA 拒 DMClawBot 拒 groupreplyIdentity 包装boundAgentId 覆盖 |
| `test/modules/netaclaw/service/wechat_archive.test.ts`(新增) | SQLite 写入 / 查询 / 状态回写 |
| `test/modules/netaclaw/service/weixin_uia.test.ts`(新增) | bridge HTTP 客户端 mock超时 / 重试 / 鉴权 header |
### 端到端手工验证(部署到 Windows 测试机)
1. 装 PC 微信 3.9.11.17 + 登录测试号
2. 装 Neta Windows 包Tray 启动 → backend + bridge 都拉起 → frontend 频道页可见
3. 创建 weixin-uia channel → bridge handshake → 看到 wxid/nickname 自动填充
4. 在测试群里说一句"hello"(任何成员)→ bridge 检测到事件 → 切窗采集 → 上报 backend → 前端"待审批"横幅出现该群
5. 启用监听 + 选 `at_mention` + 填 botAlias=小神
6. 群里发 `@小神 你好` → agent 回复 → bot 在群里发"已收到,请问需要什么帮助" → 用户看到的发送方就是测试号自己
7. 切到 `all` 模式 → 群里随便说 → agent 通过 system prompt 判定相关性 → 不相关时返回 [SKIP] 不发
8. 群里发图 → SQLite 落归档 → 前端归档抽屉能预览到图片
9. 关掉 PC 微信 → bridge `/health` 失败 → channel 状态变 disconnected
10. 重开 PC 微信 → bridge 自愈 → channel 重连
11. 故意装 PC 微信 4.x不在白名单→ bridge 启动失败 → tray 气泡通知"版本不兼容"
12. 一个微信号同时绑 ClawBot channel + UIA channelDM 走 ClawBot、群走 UIA不重复响应
13. 删 channel → group 表级联清SQLite 归档保留(按设计是否随删可在 spec 决策——当前**不级联删 archive**,作为审计记录)
14. 群被改名 → 视为新群,又出现在"待审批"
### 可观测性
新增日志:
- `[UIA-Bridge] handshake channelId=%s wxid=%s version=%s`
- `[UIA-Bridge] room discovered roomName=%s`
- `[UIA-Bridge] window switch roomName=%s elapsedMs=%d`
- `[UIA-Bridge] attachment extracted msgId=%s path=%s`
- `[AgentChannel] uia inbound channelId=%s roomName=%s sender=%s msgType=%s`
- `[AgentChannel] uia agent skipped reason=empty_or_skip channelId=%s roomId=%s`
- `[AgentChannel] uia bridge offline channelId=%s consecutiveFailures=%d`
---
## 非目标 / 不在本 spec 范围
- **WeChatFerry / DLL 注入**:本 spec 不引入注入式 hook 路径。如未来需要更高性能(毫秒级,不切窗),再单起 spec
- **iPad/Mac 协议模拟PadLocal/Gewechat**:付费服务/灰色项目,不纳入
- **企业微信 WeCom**:协议不允许接入个人微信群,不纳入
- **批量发图/文件到群**MVP 限定 agent 回复纯文本
- **语音消息处理**v2 才做UIA 模拟右键"转文字"或本地 ASR
- **跨群人物志合并**v2 才做
- **同时多账号**MVP 一个 bridge 实例 = 一个 PC 微信 = 一个微信号;多账号需多机器
- **Bridge 主动建群 / 邀请成员**:永远不做(伦理 + 微信风控)
- **Computer Use 视觉路径**LLM 看截图操作鼠标):性能差、成本高,不纳入
- **WebView 协议(已被微信清理)**:不可行