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

38 KiB
Raw Blame History

title created status related
微信 UIA 本地代理渠道设计weixin-uia 2026-05-09 draft
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>.jpgbackend 通过现有 /files 静态服务暴露
  • 触发判定:硬规则 at_mention / allall 模式让 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 里带 bridgeBaseUrlhttp://127.0.0.1:<port>)告知 backend 自己的监听地址backend 据此构造 HTTP 客户端用于 /send /enable-room 等主动调用
  • Room 启用同步:前端点"启用监听" → backend 更新 group.status=1backend 主动调 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 的含义:
    { wxid: 'wxid_xxx', nickname: '张三', wechatVersion: '3.9.11.17' }
    
    不再有 iLink token / accountId——UIA 渠道认的是"本机 PC 微信登录的这个号"
  • config.group JSON 扩展:
    group: {
      botAlias?: string;       // @ 匹配用(已有)
      replyIdentity?: 'silent' | 'ai_prefix';  // 新:默认 silent
    }
    

netaclaw_agent_channel_group(今天 task 新建)

  • 字段基本不变
  • triggerMode 允许值从 3 档收敛为 2 档at_mention / allprefix弃用——updatePolicy service 层拒绝写入 prefixdecideGroupAcceptance 纯函数仍容忍 DB 里残留的 prefix 记录(不 break 存量 iLink 数据);前端 UI 只给 2 个选项
  • 新增字段 replyIdentityOverride?: 'silent' | 'ai_prefix' | null(每群可覆盖 channel 级默认)
  • 新增字段 boundAgentId: number | null(每群可独立绑定一个 agent覆盖 channel 级默认 agent
  • status 字段引入第三态 -1 = 已否决ignored:用户显式"忽略"过的群不再在"待审批"横幅提醒

新增表 netaclaw_wechat_archiveSQLite

独立 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 / allprefix 在 service 写入层(updatePolicy)抛 400 拒绝decideGroupAcceptance 纯函数仍处理 prefix(兼容存量数据);前端 UI 只显示 2 档
  • all 模式下 acceptance 无条件通过,但传给 agent 的 userMessageMetadata 多带 shouldEvaluate: true 标志,提示 agent "你要自己判断相关性"
  • agent 返回 finalContent === '' 或以 [SKIP] 开头 → handleInboundMessage 不调 sendTextagent 沉默)

每群绑定 agentboundAgentId

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

发送路径

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 收到 DMchat_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.exebridge.exe

WeChatBridge.exe \
    --tray-secret <secret> \
    --backend-url http://127.0.0.1:<port> \
    --data-dir <dir> \
    --bridge-port <port>

与现有 BackendProcessManager 模式一致。

BridgeProcessManager(新增,参照 BackendProcessManager

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. 启 bridgeGET 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 形如:

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.exeFileVersion,匹配 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.TestsxUnit
    • 纯逻辑单测:消息 hash 去重、队列合并、版本 profile 匹配、YAML 解析
    • UIA 集成测试CI 环境没 PC 微信 → 整体 skip只在本地开发者机器跑
  • 端到端手工验证清单(对齐 spec 最后节)

与今天 2026-05-08 实施代码的关系

完全复用(零修改)

模块 复用内容
runtime/chat_scope.ts decideChatScope / sessionId 构造器 / dispatchKey / clarify key / decideGroupAcceptanceprefix 分支保留不删,仅前端不暴露)/ detectAtMention / strippers
runtime/errors.ts GroupInteractionDeclineError + withClarifyTimeout
runtime/tool_execution_metadata.ts 纯函数完全无关
service/agent_executor.ts chatScope + GroupInteractionDecline short-circuit 全部不动
service/agent_channel.tsrouteInboundMessage / handleInboundMessage 主体 / senderQueue / pendingClarify 逻辑 框架完全不动,仅在 routeInboundMessage 入口处加 channel.type 分流 switch
service/agent_channel_group.tsupsertOnInbound / toggle / updatePolicy / list / cascadeDeleteByChannel / statsByChannels 不动
controller/admin/agent_channel_group.tslist/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 / allprefix 调用返回 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-uiadrawer 字段按 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 协议(已被微信清理):不可行