38 KiB
| title | created | status | related | ||
|---|---|---|---|---|---|
| 微信 UIA 本地代理渠道设计(weixin-uia) | 2026-05-09 | draft |
|
微信 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.mdv4(通用桌面 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-secretIPC 模式 - 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纯函数仍能读取旧配置,但updatePolicyservice 层拒绝新写入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并存)credentialJSON 对weixin-uia的含义:
不再有 iLink token / accountId——UIA 渠道认的是"本机 PC 微信登录的这个号"{ wxid: 'wxid_xxx', nickname: '张三', wechatVersion: '3.9.11.17' }config.groupJSON 扩展:group: { botAlias?: string; // @ 匹配用(已有) replyIdentity?: 'silent' | 'ai_prefix'; // 新:默认 silent }
netaclaw_agent_channel_group(今天 task 新建)
- 字段基本不变
triggerMode允许值从 3 档收敛为 2 档:at_mention/all。prefix已弃用——updatePolicyservice 层拒绝写入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 的做法:
- UIA 检测到图片消息到达
- 读气泡里的文件名/时间戳定位目录
- 轮询匹配最新的
.dat(图片)或文件名(文件) - 图片:如果
.jpg缓存已存在用.jpg;否则用公开的 DAT-XOR 算法解密.dat(每字节异或一个常量,识别文件头决定异或 key) - 拷贝到
dataDir/wechat-uploads/<channelId>/YYYY-MM/<msgIdHash>.ext - 本地路径落到 SQLite
attachmentPath
fallback:解密失败或找不到 → 记 .dat 原路径,agent 对话页渲染"⚠️ 图片无法显示",不丢整条消息。
发送回复
- Backend POST
/send给 bridge:{ roomName, text, atList? } - Bridge 调 UIA:切到该群窗口 →
SetFocus输入框 →SendKeys文本 →SendKeys(Enter) - 读最后一条"自己发送的消息"气泡确认时间戳 > 发送前,回 200
MVP 不支持发送图片/文件到群——agent 回复限定为文本。
身份控制(replyIdentity)
silent(默认):直接发${text}ai_prefix:发【AI 助手】${text}
发送者 wxid 的获取
UIA 大多数情况只能读到"群内昵称"而非 wxid。存储策略:
- SQLite 存
senderName(必有) senderWxid绝大多数为 NULL- 启动时主动拉群成员列表,建立 wxid ↔ 当前昵称 映射表(v2 补)
- agent 看到的 message 以
[senderName]: content前缀呈现,让 LLM 基于上下文自行区分
去重 / 首次发现
msgIdUNIQUE 约束避免切窗重复读- 首次发现群不 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 冷启动序列
- 读 PC 微信进程版本 → 校验白名单 → 不匹配则 exit(1) + tray 通知
- 定位微信主窗口 → 拿
wxid/nickname POST /handshake→ backend 认领 channel,返回该 channel 已启用的群列表- 订阅会话列表的 StructureChanged + Name 事件
- 主动扫一次当前会话列表,把未在启用列表里的新群上报为"待审批"
- 进入 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)
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 次失败 → 标记 channelloginStatus='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)
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 启动顺序:
- 启 backend,等
/status.ready - 启 bridge,等
GET bridge/health返回 200 - Bridge
POST /handshake→ backend 标记 channelloginStatus='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.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 测试机)
- 装 PC 微信 3.9.11.17 + 登录测试号
- 装 Neta Windows 包,Tray 启动 → backend + bridge 都拉起 → frontend 频道页可见
- 创建 weixin-uia channel → bridge handshake → 看到 wxid/nickname 自动填充
- 在测试群里说一句"hello"(任何成员)→ bridge 检测到事件 → 切窗采集 → 上报 backend → 前端"待审批"横幅出现该群
- 启用监听 + 选
at_mention+ 填 botAlias=小神 - 群里发
@小神 你好→ agent 回复 → bot 在群里发"已收到,请问需要什么帮助" → 用户看到的发送方就是测试号自己 - 切到
all模式 → 群里随便说 → agent 通过 system prompt 判定相关性 → 不相关时返回 [SKIP] 不发 - 群里发图 → SQLite 落归档 → 前端归档抽屉能预览到图片
- 关掉 PC 微信 → bridge
/health失败 → channel 状态变 disconnected - 重开 PC 微信 → bridge 自愈 → channel 重连
- 故意装 PC 微信 4.x(不在白名单)→ bridge 启动失败 → tray 气泡通知"版本不兼容"
- 一个微信号同时绑 ClawBot channel + UIA channel:DM 走 ClawBot、群走 UIA,不重复响应
- 删 channel → group 表级联清;SQLite 归档保留(按设计是否随删可在 spec 决策——当前不级联删 archive,作为审计记录)
- 群被改名 → 视为新群,又出现在"待审批"
可观测性
新增日志:
[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 协议(已被微信清理):不可行