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

783 lines
38 KiB
Markdown
Raw 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: 微信 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 协议(已被微信清理)**:不可行