---
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 Automation（UIA）代理。本 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`（仅 DM）；UIA = `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 | 发送者 wxid（UIA 大多数情况读不到） |
| `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 机制落 MySQL，sessionId 规则沿用 `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=1（enabled）
           │    未启用/未审批 → SQLite "发现但未审批" + 通知 backend（前端红点）
           │    已否决(-1) → 直接丢弃
           │    已启用(1) → 继续
           ├─ UIA: 点击左侧会话列表对应条目 → 右侧聊天框出现该群
           ├─ 采集新消息（与"上次该群已知 msgId"去重）
           ├─ 写 SQLite
           ├─ 调 backend POST /inbound
           └─ 完成后不切回原窗口，等下一个事件触发
```

**不切回原窗口**：用户可能原本在看某个群，来回切会让体验崩坏。只在需要时切到目标群，结束停在那里。用户自己想看别的群会手动切走——bridge 下次事件也是切到其它群，对用户无额外干扰。

### 队列合并与背压

- **去重合并**：同一 roomName 连续来多条事件，只处理一次切窗——切到该群后一次性读完所有未读
- **超限丢弃**：队列 >50 待处理 room → warn 日志 + 丢 oldest 保 newest
- **单次超时**：单次切窗 3 秒超时 → 跳过该群、保留队列
- **并发上限**：单 bridge = 单 PC 微信实例 = 单点串行。不存在真并发

### Bridge ↔ Backend 通信方向

**Bridge 主动推 inbound，Backend 主动调 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 拒收 DM；ClawBot 拒收群） |
| 前端 `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_entry（SQLite 归档推迟到 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 拒 DM；ClawBot 拒 group；replyIdentity 包装；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 channel：DM 走 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 协议（已被微信清理）**：不可行
