108 KiB
WeChat UIA Phase B · Bridge 完整能力 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在 Plan A 骨架之上补齐 Bridge 真正的业务能力——多群切窗事件订阅、图片 / 文件附件采集 (含 DAT-XOR 解密)、消息发送 (含 @ / 身份前缀)、@ / 引用 / 图片消息的结构化解析、向 backend 主动 POST inbound。本 plan 完成后 Bridge 可独立完成端到端"群消息 → backend"的传输,但仍未接通 backend 真实业务 (留给 Plan C)。
Architecture: 在 BridgeState 上挂载新组件:RoomRegistry(已启用群名 → roomId 缓存)、RoomEventQueue(切窗串行队列 + 去重 + 背压)、SessionListWatcher(订阅 UIA StructureChanged + NameProperty Changed)、AttachmentExtractor(WeChat Files 目录扫描 + DAT-XOR 解密)、MessageSender(SetFocus + SendKeys + 时间戳确认)、InboundDispatcher(把读到的消息推 backend)。事件源是 SessionListWatcher,worker thread 串行 dequeue → 切窗 → 读消息 → 提取附件 → POST backend。所有纯逻辑严格 TDD;UIA 交互部分提供单元覆盖 + 手工验证清单。
Tech Stack: .NET 8 / System.Windows.Automation / WindowsInput.SendKeys (走 User32.SendInput 或 System.Windows.Forms.SendKeys) / xUnit。
Spec: docs/superpowers/specs/2026-05-09-wechat-uia-channel-design.md (主体节"消息采集与图片处理" / "多群监听策略" / "Bridge 暴露 HTTP")
前置依赖: Plan A (2026-05-09-wechat-uia-a-bridge-skeleton.md) 必须已完成并合并。Plan B 不修改 Plan A 已写文件的导出契约,只是扩展。
关键约束:
- 事件源 = SessionListWatcher (会话列表),不是 ChatBoxReader (聊天框)。这是 spec 写明的;切窗只是事件触发后的动作。
- 切窗串行——同一时刻只能有一个 worker 操作 UIA。
RoomEventQueue内部串行,worker thread 单线程跑 (单消费者模型,DequeueAsync 不保证多消费者正确)。 - 不切回原窗口——切到目标群读消息后停在那里,用户再想看别的群手动切。
- handshake 优先于事件订阅——必须先完成 backend handshake 拿到真实
channelId+ 已启用群列表,才能创建RoomRegistry与SessionListWatcher。BridgeState的Rooms/EventQueue字段在 handshake 完成前为 null,等待注入后原子可见 (架构师审查 B5/B6/B7/B10)。 - UIA ControlType 对比用 ProgrammaticName 或静态 ControlType 对象,不要用
LocalizedControlType(中文 Windows 会返回本地化字符串,与 parser 期望的"Text"/"Pane"/"Button"/"Image"不匹配) (架构师审查 B8)。 - 去重双层:
msgId在 backend SQLite 上 UNIQUE (Plan C 实现),Bridge 这一层在RoomRegistry上保留每群"最新已读 msgId 集合 (LRU 200)" 防止切窗时重复读已经处理过的消息。 - 首次发现群不 backfill——只认"现在能看到的最新一条"为起点,避免给 agent 灌历史。
- 重启不回灌——Bridge 重启后只处理重启后新出现的事件。
- DAT-XOR 解密走纯字节运算(不调外部 DLL),公开算法:每字节异或一个常量,key 通过识别已知图片格式头反推。
- 键盘发送优先
SendInput(User32),因为System.Windows.Forms.SendKeys对 PUA / 非 ASCII 字符不稳。但 .NET 8 console 项目调 SendInput 需 P/Invoke 包装。 - UIA 事件订阅的 Dispose 用精确取消 (
Automation.RemoveStructureChangedEventHandler/RemoveAutomationPropertyChangedEventHandler),不用全局RemoveAllEventHandlers()(架构师审查 B2)。 - 不实现:语音转文字 (v2)、视频下载 (v2)、跨群人物志 (v2)、bridge 主动发图片到群 (MVP 不支持)、前端 UI (Plan D)、SQLite 归档表 (Plan C)、跨渠道丢弃 (Plan C)。
- 沿用 Plan A 的 Windows-only 约束、UTF-8 终端、UIA test SkippableFact 模式。
文件结构
新增 — Neta.WeChatBridge/
| 文件 | 责任 |
|---|---|
Uia/RoomRegistry.cs |
已启用群 (roomName → roomId 稳定 id) 内存缓存 + 每群 last-msgId LRU |
Uia/RoomEventQueue.cs |
切窗事件串行队列:enqueue 合并去重、背压丢弃、3 秒超时 |
Uia/SessionListWatcher.cs |
UIA StructureChangedEventHandler + AutomationPropertyChangedEventHandler 订阅会话列表 |
Uia/MessageParser.cs |
把单个 ListItem UIA 控件树解析成结构化 ParsedMessage (text/image/file/quote/at) |
Uia/AttachmentExtractor.cs |
WeChat Files 目录扫描 + DAT-XOR 解密 + 拷贝到 dataDir |
Uia/DatXorDecoder.cs |
纯算法:从 .dat 头字节反推 XOR key,解密整文件 |
Uia/MessageSender.cs |
切群 → SetFocus 输入框 → 输入文本 → Enter → 时间戳确认 |
Uia/SendInput.cs |
User32.SendInput P/Invoke 包装 (Unicode 字符发送) |
Uia/MessageIdHasher.cs |
纯函数:`sha256(roomId |
Uia/RoomIdHasher.cs |
纯函数:${channelId}:room:${sha1(roomName)} 生成 roomId |
Backend/BackendClient.cs (扩展) |
新增 IngestInboundAsync POST /open/netaclaw/channel/uia/inbound |
Workers/InboundDispatcher.cs |
消费 RoomEventQueue → 切窗 → 读消息 → 抽附件 → 调 BackendClient |
Http/Endpoints/SendEndpoint.cs |
POST /send { roomName, text, atList? } |
Http/Endpoints/RoomsEndpoint.cs |
GET /rooms 返回当前可见群列表 |
Http/Endpoints/EnableDisableEndpoint.cs |
POST /enable-room / POST /disable-room 修改 RoomRegistry |
Models/ParsedMessage.cs |
{ Type, SenderName, Content, AtList, QuotedRef, AttachmentPath, ReceivedAt } |
Models/InboundPayload.cs |
POST 给 backend 的 body record |
新增 — Neta.WeChatBridge.Tests/
| 文件 | 覆盖 |
|---|---|
Uia/RoomEventQueueTests.cs |
enqueue 合并 / 同名跨秒去重 / 背压丢 oldest 保 newest / 队列上限 |
Uia/RoomRegistryTests.cs |
enable/disable / list / 已启用群消息 LRU 上限 |
Uia/MessageIdHasherTests.cs |
hash 稳定 / 跨分钟边界变化 / 不同发送者 hash 不同 / 已知向量 |
Uia/RoomIdHasherTests.cs |
同 channelId 同 roomName → 同 roomId / 不同 channelId → 不同 roomId / 已知向量 |
Uia/DatXorDecoderTests.cs |
已知 JPEG header (0xFF 0xD8) 反推 key / PNG header / GIF header / 头部不识别返回 null |
Uia/AttachmentExtractorTests.cs |
模拟 dat 文件 → 解密 → 输出到 dataDir / 无法识别时 fallback 失败处理 |
Uia/MessageParserTests.cs |
用伪造的 AutomationElement 树 (mock) 测试 text/image/quote 解析 |
Backend/BackendClientInboundTests.cs |
inbound 请求结构、retry 策略、超时 |
Workers/InboundDispatcherTests.cs |
用 mock RoomEventQueue + mock BackendClient 验证编排 |
Http/SendEndpointTests.cs |
鉴权 / 参数校验 / 调用 MessageSender mock |
Http/RoomsEndpointTests.cs |
返回当前 RoomRegistry 快照 |
修改
| 文件 | 改动 |
|---|---|
Neta.WeChatBridge/Program.cs |
启动后构造 RoomRegistry / RoomEventQueue / SessionListWatcher / InboundDispatcher worker 并 hook 到 BridgeState;/health 拓展返回队列长度 |
Neta.WeChatBridge/BridgeState.cs |
新增 RoomRegistry Rooms / RoomEventQueue EventQueue 字段 |
Neta.WeChatBridge/Http/BridgeHttpServer.cs |
注册 SendEndpoint / RoomsEndpoint / EnableDisableEndpoint |
Phase 1 · 纯函数层 (hash + queue + registry)
Task 1: RoomIdHasher
Files:
-
Create:
packages/windows-tray/Neta.WeChatBridge/Uia/RoomIdHasher.cs -
Test:
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/RoomIdHasherTests.cs -
Step 1: 写失败测试
using Xunit;
using Neta.WeChatBridge.Uia;
public class RoomIdHasherTests
{
[Fact]
public void Build_returns_stable_id_for_same_inputs()
{
var a = RoomIdHasher.Build(channelId: 7, roomName: "产品研发群");
var b = RoomIdHasher.Build(channelId: 7, roomName: "产品研发群");
Assert.Equal(a, b);
}
[Fact]
public void Build_differs_when_channel_id_differs()
{
var a = RoomIdHasher.Build(7, "产品研发群");
var b = RoomIdHasher.Build(8, "产品研发群");
Assert.NotEqual(a, b);
}
[Fact]
public void Build_differs_when_room_name_differs()
{
var a = RoomIdHasher.Build(7, "群A");
var b = RoomIdHasher.Build(7, "群B");
Assert.NotEqual(a, b);
}
[Fact]
public void Build_format_is_channel_room_sha1()
{
var id = RoomIdHasher.Build(42, "test");
Assert.StartsWith("42:room:", id);
// sha1 hex 长度 40
Assert.Equal("42:room:".Length + 40, id.Length);
}
[Fact]
public void Build_throws_on_empty_room_name()
{
Assert.Throws<ArgumentException>(() => RoomIdHasher.Build(1, ""));
}
}
- Step 2: 运行测试确认失败
cd packages/windows-tray
dotnet test Neta.WeChatBridge.Tests --filter "FullyQualifiedName~RoomIdHasherTests"
- Step 3: 实现
using System.Security.Cryptography;
using System.Text;
namespace Neta.WeChatBridge.Uia;
public static class RoomIdHasher
{
public static string Build(int channelId, string roomName)
{
if (string.IsNullOrWhiteSpace(roomName))
throw new ArgumentException("roomName 不能为空", nameof(roomName));
var bytes = Encoding.UTF8.GetBytes(roomName);
var hash = SHA1.HashData(bytes);
var hex = Convert.ToHexString(hash).ToLowerInvariant();
return $"{channelId}:room:{hex}";
}
}
- Step 4: 测试通过
Expected:Passed: 5。
- Step 5: Commit
git add packages/windows-tray/Neta.WeChatBridge/Uia/RoomIdHasher.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/RoomIdHasherTests.cs
git commit -m "feat(bridge): add RoomIdHasher stable id generator"
Task 2: MessageIdHasher
Files:
-
Create:
packages/windows-tray/Neta.WeChatBridge/Uia/MessageIdHasher.cs -
Test:
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/MessageIdHasherTests.cs -
Step 1: 写失败测试
using Xunit;
using Neta.WeChatBridge.Uia;
public class MessageIdHasherTests
{
private static DateTimeOffset T(int year, int mo, int d, int h, int m, int s) =>
new(year, mo, d, h, m, s, TimeSpan.Zero);
[Fact]
public void Build_is_stable_for_same_inputs()
{
var a = MessageIdHasher.Build("room1", "alice", "hi", T(2026,5,9,12,30,15));
var b = MessageIdHasher.Build("room1", "alice", "hi", T(2026,5,9,12,30,15));
Assert.Equal(a, b);
}
[Fact]
public void Build_collapses_seconds_within_same_minute()
{
// spec 要求精度到分钟
var a = MessageIdHasher.Build("room1", "alice", "hi", T(2026,5,9,12,30,15));
var b = MessageIdHasher.Build("room1", "alice", "hi", T(2026,5,9,12,30,59));
Assert.Equal(a, b);
}
[Fact]
public void Build_changes_across_minute_boundary()
{
var a = MessageIdHasher.Build("room1", "alice", "hi", T(2026,5,9,12,30,59));
var b = MessageIdHasher.Build("room1", "alice", "hi", T(2026,5,9,12,31,0));
Assert.NotEqual(a, b);
}
[Theory]
[InlineData("room2", "alice", "hi")]
[InlineData("room1", "bob", "hi")]
[InlineData("room1", "alice", "hello")]
public void Build_differs_when_any_field_differs(string room, string sender, string content)
{
var baseId = MessageIdHasher.Build("room1", "alice", "hi", T(2026,5,9,12,30,15));
var other = MessageIdHasher.Build(room, sender, content, T(2026,5,9,12,30,15));
Assert.NotEqual(baseId, other);
}
[Fact]
public void Build_returns_64_char_hex()
{
var id = MessageIdHasher.Build("r", "s", "c", T(2026,1,1,0,0,0));
Assert.Equal(64, id.Length);
Assert.Matches("^[0-9a-f]+$", id);
}
}
-
Step 2: 运行测试确认失败
-
Step 3: 实现
using System.Security.Cryptography;
using System.Text;
namespace Neta.WeChatBridge.Uia;
public static class MessageIdHasher
{
public static string Build(
string roomId, string senderName, string content, DateTimeOffset receivedAt)
{
// 精度到分钟,与 spec 一致
var minute = receivedAt.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm");
var raw = $"{roomId}|{senderName}|{content}|{minute}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
- Step 4: 测试通过
Expected:Passed: 7 (1+1+1+3+1)。
- Step 5: Commit
git add packages/windows-tray/Neta.WeChatBridge/Uia/MessageIdHasher.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/MessageIdHasherTests.cs
git commit -m "feat(bridge): add MessageIdHasher with minute-precision dedup"
Task 3: RoomRegistry
Files:
-
Create:
packages/windows-tray/Neta.WeChatBridge/Uia/RoomRegistry.cs -
Test:
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/RoomRegistryTests.cs -
Step 1: 写失败测试
using Xunit;
using Neta.WeChatBridge.Uia;
public class RoomRegistryTests
{
[Fact]
public void Enable_adds_room_and_marks_enabled()
{
var reg = new RoomRegistry(channelId: 1);
reg.EnableRoom("产品群");
Assert.True(reg.IsEnabled("产品群"));
}
[Fact]
public void Disable_removes_enabled_flag()
{
var reg = new RoomRegistry(1);
reg.EnableRoom("产品群");
reg.DisableRoom("产品群");
Assert.False(reg.IsEnabled("产品群"));
}
[Fact]
public void IsEnabled_returns_false_for_unknown_room()
{
var reg = new RoomRegistry(1);
Assert.False(reg.IsEnabled("never-seen"));
}
[Fact]
public void GetRoomId_returns_stable_id()
{
var reg = new RoomRegistry(7);
var a = reg.GetRoomId("产品群");
var b = reg.GetRoomId("产品群");
Assert.Equal(a, b);
Assert.StartsWith("7:room:", a);
}
[Fact]
public void RememberSeen_keeps_lru_within_capacity()
{
var reg = new RoomRegistry(1, seenCapacityPerRoom: 3);
reg.RememberSeen("产品群", "msg1");
reg.RememberSeen("产品群", "msg2");
reg.RememberSeen("产品群", "msg3");
reg.RememberSeen("产品群", "msg4");
Assert.False(reg.HasSeen("产品群", "msg1")); // 被挤出
Assert.True(reg.HasSeen("产品群", "msg2"));
Assert.True(reg.HasSeen("产品群", "msg4"));
}
[Fact]
public void HasSeen_isolates_per_room()
{
var reg = new RoomRegistry(1);
reg.RememberSeen("A", "msg1");
Assert.False(reg.HasSeen("B", "msg1"));
}
[Fact]
public void EnabledList_returns_all_enabled_rooms()
{
var reg = new RoomRegistry(1);
reg.EnableRoom("a");
reg.EnableRoom("b");
reg.DisableRoom("a");
var list = reg.EnabledList();
Assert.Single(list);
Assert.Contains("b", list);
}
[Fact]
public void Operations_are_thread_safe()
{
var reg = new RoomRegistry(1);
Parallel.For(0, 100, i =>
{
reg.EnableRoom($"room{i}");
reg.RememberSeen($"room{i}", $"msg{i}");
});
Assert.Equal(100, reg.EnabledList().Count);
}
}
-
Step 2: 运行测试确认失败
-
Step 3: 实现
namespace Neta.WeChatBridge.Uia;
public sealed class RoomRegistry
{
private readonly int _channelId;
private readonly int _seenCapacityPerRoom;
private readonly object _lock = new();
private readonly HashSet<string> _enabled = new(StringComparer.Ordinal);
// 每群一个 LRU:LinkedList head=最新,tail=最旧;HashSet 加速查询
private readonly Dictionary<string, LinkedList<string>> _seenOrder = new();
private readonly Dictionary<string, HashSet<string>> _seenSet = new();
public RoomRegistry(int channelId, int seenCapacityPerRoom = 200)
{
_channelId = channelId;
_seenCapacityPerRoom = seenCapacityPerRoom;
}
public string GetRoomId(string roomName) => RoomIdHasher.Build(_channelId, roomName);
public void EnableRoom(string roomName)
{
lock (_lock) _enabled.Add(roomName);
}
public void DisableRoom(string roomName)
{
lock (_lock) _enabled.Remove(roomName);
}
public bool IsEnabled(string roomName)
{
lock (_lock) return _enabled.Contains(roomName);
}
public IReadOnlyList<string> EnabledList()
{
lock (_lock) return _enabled.ToList();
}
public void RememberSeen(string roomName, string msgId)
{
lock (_lock)
{
if (!_seenOrder.TryGetValue(roomName, out var list))
{
list = new LinkedList<string>();
_seenOrder[roomName] = list;
_seenSet[roomName] = new HashSet<string>(StringComparer.Ordinal);
}
var set = _seenSet[roomName];
if (set.Contains(msgId))
{
// 把它移到 head
list.Remove(msgId);
list.AddFirst(msgId);
return;
}
list.AddFirst(msgId);
set.Add(msgId);
while (list.Count > _seenCapacityPerRoom)
{
var tail = list.Last!;
list.RemoveLast();
set.Remove(tail.Value);
}
}
}
public bool HasSeen(string roomName, string msgId)
{
lock (_lock)
return _seenSet.TryGetValue(roomName, out var set) && set.Contains(msgId);
}
}
- Step 4: 测试通过
Expected:Passed: 8。
- Step 5: Commit
git add packages/windows-tray/Neta.WeChatBridge/Uia/RoomRegistry.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/RoomRegistryTests.cs
git commit -m "feat(bridge): add RoomRegistry with per-room LRU dedup"
Task 4: RoomEventQueue (合并去重 + 背压 + 单线程串行)
Files:
-
Create:
packages/windows-tray/Neta.WeChatBridge/Uia/RoomEventQueue.cs -
Test:
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/RoomEventQueueTests.cs -
Step 1: 写失败测试
using Xunit;
using Neta.WeChatBridge.Uia;
public class RoomEventQueueTests
{
[Fact]
public async Task Enqueue_then_dequeue_returns_room_name()
{
var q = new RoomEventQueue(capacity: 50);
q.Enqueue("产品群");
var dequeued = await q.DequeueAsync(default);
Assert.Equal("产品群", dequeued);
}
[Fact]
public void Enqueue_same_room_twice_collapses_to_one()
{
var q = new RoomEventQueue(50);
q.Enqueue("a");
q.Enqueue("a");
Assert.Equal(1, q.Count);
}
[Fact]
public async Task Distinct_rooms_preserved_in_fifo_order()
{
var q = new RoomEventQueue(50);
q.Enqueue("a");
q.Enqueue("b");
q.Enqueue("c");
Assert.Equal("a", await q.DequeueAsync(default));
Assert.Equal("b", await q.DequeueAsync(default));
Assert.Equal("c", await q.DequeueAsync(default));
}
[Fact]
public void Enqueue_beyond_capacity_drops_oldest()
{
var q = new RoomEventQueue(capacity: 3);
q.Enqueue("a");
q.Enqueue("b");
q.Enqueue("c");
q.Enqueue("d"); // a 被挤掉
Assert.Equal(3, q.Count);
Assert.False(q.Contains("a"));
Assert.True(q.Contains("d"));
}
[Fact]
public async Task Dequeue_blocks_until_enqueued()
{
var q = new RoomEventQueue(50);
var task = q.DequeueAsync(default);
Assert.False(task.IsCompleted);
q.Enqueue("late");
var result = await task;
Assert.Equal("late", result);
}
[Fact]
public async Task Dequeue_is_cancelled_by_token()
{
var q = new RoomEventQueue(50);
var cts = new CancellationTokenSource();
var task = q.DequeueAsync(cts.Token);
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => task);
}
[Fact]
public void OnDrop_callback_fires_when_oldest_evicted()
{
var dropped = new List<string>();
var q = new RoomEventQueue(2, onDrop: name => dropped.Add(name));
q.Enqueue("a");
q.Enqueue("b");
q.Enqueue("c");
Assert.Equal(new[] { "a" }, dropped);
}
}
-
Step 2: 运行测试确认失败
-
Step 3: 实现
using System.Threading.Channels;
namespace Neta.WeChatBridge.Uia;
public sealed class RoomEventQueue
{
private readonly int _capacity;
private readonly object _lock = new();
private readonly LinkedList<string> _order = new();
private readonly HashSet<string> _set = new(StringComparer.Ordinal);
private readonly Action<string>? _onDrop;
private TaskCompletionSource<bool>? _waiter;
public RoomEventQueue(int capacity, Action<string>? onDrop = null)
{
if (capacity < 1) throw new ArgumentOutOfRangeException(nameof(capacity));
_capacity = capacity;
_onDrop = onDrop;
}
public int Count
{
get { lock (_lock) return _order.Count; }
}
public bool Contains(string roomName)
{
lock (_lock) return _set.Contains(roomName);
}
public void Enqueue(string roomName)
{
TaskCompletionSource<bool>? waiterToSignal = null;
lock (_lock)
{
if (_set.Contains(roomName))
return; // 合并去重
_order.AddLast(roomName);
_set.Add(roomName);
while (_order.Count > _capacity)
{
var oldest = _order.First!.Value;
_order.RemoveFirst();
_set.Remove(oldest);
_onDrop?.Invoke(oldest);
}
waiterToSignal = _waiter;
_waiter = null;
}
waiterToSignal?.TrySetResult(true);
}
public async Task<string> DequeueAsync(CancellationToken ct)
{
while (true)
{
ct.ThrowIfCancellationRequested();
TaskCompletionSource<bool> waitFor;
lock (_lock)
{
if (_order.Count > 0)
{
var head = _order.First!.Value;
_order.RemoveFirst();
_set.Remove(head);
return head;
}
_waiter ??= new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
waitFor = _waiter;
}
using var reg = ct.Register(() => waitFor.TrySetCanceled(ct));
await waitFor.Task;
}
}
}
- Step 4: 测试通过
Expected:Passed: 7。
- Step 5: Commit
git add packages/windows-tray/Neta.WeChatBridge/Uia/RoomEventQueue.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/RoomEventQueueTests.cs
git commit -m "feat(bridge): add RoomEventQueue with merge dedup + backpressure"
Phase 2 · 附件 (DAT-XOR + AttachmentExtractor)
Task 5: DatXorDecoder
Files:
- Create:
packages/windows-tray/Neta.WeChatBridge/Uia/DatXorDecoder.cs - Test:
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/DatXorDecoderTests.cs
算法:WeChat .dat 是把图片整文件每字节 XOR 同一个 8-bit 常量。识别 key 的方法:已知图片格式头第一字节 (JPEG=0xFF, PNG=0x89, GIF=0x47),用 dat 的第一字节 XOR 已知头字节得到 key,再验证第二字节也匹配。
- Step 1: 写失败测试
using Xunit;
using Neta.WeChatBridge.Uia;
public class DatXorDecoderTests
{
[Fact]
public void DetectKey_recovers_jpeg_xor_key()
{
// 真实 JPEG 头:FF D8 FF E0 ...
byte key = 0x77;
var encoded = new byte[] {
(byte)(0xFF ^ key), (byte)(0xD8 ^ key),
(byte)(0xFF ^ key), (byte)(0xE0 ^ key),
};
var detected = DatXorDecoder.DetectKey(encoded);
Assert.NotNull(detected);
Assert.Equal(key, detected!.Value.Key);
Assert.Equal("jpg", detected.Value.Extension);
}
[Fact]
public void DetectKey_recovers_png_xor_key()
{
// PNG: 89 50 4E 47
byte key = 0xA3;
var encoded = new byte[] {
(byte)(0x89 ^ key), (byte)(0x50 ^ key),
(byte)(0x4E ^ key), (byte)(0x47 ^ key),
};
var detected = DatXorDecoder.DetectKey(encoded);
Assert.NotNull(detected);
Assert.Equal(key, detected!.Value.Key);
Assert.Equal("png", detected.Value.Extension);
}
[Fact]
public void DetectKey_recovers_gif_xor_key()
{
// GIF: 47 49 46 38
byte key = 0x10;
var encoded = new byte[] {
(byte)(0x47 ^ key), (byte)(0x49 ^ key),
(byte)(0x46 ^ key), (byte)(0x38 ^ key),
};
var detected = DatXorDecoder.DetectKey(encoded);
Assert.Equal(0x10, detected!.Value.Key);
Assert.Equal("gif", detected.Value.Extension);
}
[Fact]
public void DetectKey_returns_null_when_unknown_format()
{
// 随机字节,无法对应任何已知格式
var encoded = new byte[] { 0x12, 0x34, 0x56, 0x78 };
Assert.Null(DatXorDecoder.DetectKey(encoded));
}
[Fact]
public void DetectKey_returns_null_when_input_too_short()
{
Assert.Null(DatXorDecoder.DetectKey(new byte[] { 0x00 }));
Assert.Null(DatXorDecoder.DetectKey(Array.Empty<byte>()));
}
[Fact]
public void Decrypt_xors_each_byte_with_key()
{
var input = new byte[] { 0x11, 0x22, 0x33, 0x44 };
var output = DatXorDecoder.Decrypt(input, 0x0F);
Assert.Equal(new byte[] { 0x1E, 0x2D, 0x3C, 0x4B }, output);
}
}
-
Step 2: 运行测试确认失败
-
Step 3: 实现
namespace Neta.WeChatBridge.Uia;
public static class DatXorDecoder
{
public sealed record DetectedKey(byte Key, string Extension);
private static readonly (byte[] Magic, string Ext)[] KnownFormats =
{
(new byte[] { 0xFF, 0xD8, 0xFF }, "jpg"),
(new byte[] { 0x89, 0x50, 0x4E, 0x47 }, "png"),
(new byte[] { 0x47, 0x49, 0x46, 0x38 }, "gif"),
(new byte[] { 0x42, 0x4D }, "bmp"),
};
public static DetectedKey? DetectKey(ReadOnlySpan<byte> encoded)
{
if (encoded.Length < 2) return null;
foreach (var (magic, ext) in KnownFormats)
{
if (encoded.Length < magic.Length) continue;
byte key = (byte)(encoded[0] ^ magic[0]);
var ok = true;
for (var i = 1; i < magic.Length; i++)
{
if ((byte)(encoded[i] ^ key) != magic[i]) { ok = false; break; }
}
if (ok) return new DetectedKey(key, ext);
}
return null;
}
public static byte[] Decrypt(ReadOnlySpan<byte> input, byte key)
{
var output = new byte[input.Length];
for (var i = 0; i < input.Length; i++) output[i] = (byte)(input[i] ^ key);
return output;
}
}
- Step 4: 测试通过
Expected:Passed: 6。
- Step 5: Commit
git add packages/windows-tray/Neta.WeChatBridge/Uia/DatXorDecoder.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/DatXorDecoderTests.cs
git commit -m "feat(bridge): add DatXorDecoder for WeChat image .dat decryption"
Task 6: AttachmentExtractor
Files:
- Create:
packages/windows-tray/Neta.WeChatBridge/Uia/AttachmentExtractor.cs - Test:
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/AttachmentExtractorTests.cs
这个组件需要文件系统副作用,测试用临时目录隔离。
- Step 1: 写失败测试
using Xunit;
using Neta.WeChatBridge.Uia;
public class AttachmentExtractorTests : IDisposable
{
private readonly string _tempDataDir;
private readonly string _tempCacheDir;
public AttachmentExtractorTests()
{
_tempDataDir = Path.Combine(Path.GetTempPath(), $"neta-test-data-{Guid.NewGuid():N}");
_tempCacheDir = Path.Combine(Path.GetTempPath(), $"neta-test-cache-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDataDir);
Directory.CreateDirectory(_tempCacheDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDataDir, true); } catch { }
try { Directory.Delete(_tempCacheDir, true); } catch { }
}
[Fact]
public void ExtractImage_decrypts_jpg_dat_and_writes_to_dataDir()
{
// 构造一个加密 dat (实际 jpg 头 FF D8 FF E0 + 假数据)
byte key = 0x33;
var plaintext = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x12, 0x34 };
var encoded = DatXorDecoder.Decrypt(plaintext, key);
var datPath = Path.Combine(_tempCacheDir, "abc.dat");
File.WriteAllBytes(datPath, encoded);
var extractor = new AttachmentExtractor(_tempDataDir);
var result = extractor.ExtractImage(channelId: 1, datPath: datPath, msgIdHash: "msg-1");
Assert.True(result.Ok);
Assert.Equal("jpg", Path.GetExtension(result.OutputPath!).TrimStart('.'));
Assert.True(File.Exists(result.OutputPath));
Assert.Equal(plaintext, File.ReadAllBytes(result.OutputPath!));
}
[Fact]
public void ExtractImage_falls_back_to_jpg_cache_if_present()
{
// 微信打开过的图片会同时有 abc.dat + abc.jpg
var jpgPath = Path.Combine(_tempCacheDir, "abc.jpg");
var datPath = Path.Combine(_tempCacheDir, "abc.dat");
File.WriteAllBytes(jpgPath, new byte[] { 0xFF, 0xD8, 0x99, 0x88 });
File.WriteAllBytes(datPath, new byte[] { 0x00 }); // 不应该被读
var extractor = new AttachmentExtractor(_tempDataDir);
var result = extractor.ExtractImage(channelId: 1, datPath: datPath, msgIdHash: "msg-1");
Assert.True(result.Ok);
Assert.Equal(new byte[] { 0xFF, 0xD8, 0x99, 0x88 }, File.ReadAllBytes(result.OutputPath!));
}
[Fact]
public void ExtractImage_returns_failure_on_unknown_format()
{
var datPath = Path.Combine(_tempCacheDir, "weird.dat");
File.WriteAllBytes(datPath, new byte[] { 0x12, 0x34, 0x56, 0x78 });
var extractor = new AttachmentExtractor(_tempDataDir);
var result = extractor.ExtractImage(1, datPath, "msg-1");
Assert.False(result.Ok);
Assert.Contains("unknown", result.Error!.ToLowerInvariant());
}
[Fact]
public void ExtractImage_returns_failure_when_dat_missing()
{
var extractor = new AttachmentExtractor(_tempDataDir);
var result = extractor.ExtractImage(1, Path.Combine(_tempCacheDir, "missing.dat"), "msg-1");
Assert.False(result.Ok);
}
[Fact]
public void ExtractImage_writes_to_correct_subdir_layout()
{
// dataDir/wechat-uploads/<channelId>/YYYY-MM/<hash>.ext
byte key = 0x10;
var plaintext = new byte[] { 0xFF, 0xD8, 0xFF };
var datPath = Path.Combine(_tempCacheDir, "x.dat");
File.WriteAllBytes(datPath, DatXorDecoder.Decrypt(plaintext, key));
var extractor = new AttachmentExtractor(_tempDataDir);
var result = extractor.ExtractImage(channelId: 42, datPath: datPath, msgIdHash: "abcdef");
Assert.True(result.Ok);
var expectedYm = DateTime.UtcNow.ToString("yyyy-MM");
var expectedDir = Path.Combine(_tempDataDir, "wechat-uploads", "42", expectedYm);
Assert.Equal(expectedDir, Path.GetDirectoryName(result.OutputPath));
Assert.Equal("abcdef.jpg", Path.GetFileName(result.OutputPath));
}
}
-
Step 2: 运行测试确认失败
-
Step 3: 实现
namespace Neta.WeChatBridge.Uia;
public sealed class AttachmentExtractor
{
private readonly string _dataDir;
public AttachmentExtractor(string dataDir)
{
_dataDir = dataDir;
}
public sealed record ExtractResult(bool Ok, string? OutputPath = null, string? Error = null);
public ExtractResult ExtractImage(int channelId, string datPath, string msgIdHash)
{
try
{
// 1. 优先用 .jpg 缓存(微信曾打开过该图)
var jpgCache = Path.ChangeExtension(datPath, ".jpg");
byte[] plaintext;
string ext;
if (File.Exists(jpgCache))
{
plaintext = File.ReadAllBytes(jpgCache);
ext = "jpg";
}
else
{
if (!File.Exists(datPath))
return new ExtractResult(false, Error: $"dat not found: {datPath}");
var encoded = File.ReadAllBytes(datPath);
var detected = DatXorDecoder.DetectKey(encoded);
if (detected is null)
return new ExtractResult(false, Error: "unknown image format");
plaintext = DatXorDecoder.Decrypt(encoded, detected.Key);
ext = detected.Extension;
}
var ym = DateTime.UtcNow.ToString("yyyy-MM");
var outDir = Path.Combine(_dataDir, "wechat-uploads", channelId.ToString(), ym);
Directory.CreateDirectory(outDir);
var outPath = Path.Combine(outDir, $"{msgIdHash}.{ext}");
File.WriteAllBytes(outPath, plaintext);
return new ExtractResult(true, OutputPath: outPath);
}
catch (Exception ex)
{
return new ExtractResult(false, Error: ex.Message);
}
}
}
- Step 4: 测试通过
Expected:Passed: 5。
- Step 5: Commit
git add packages/windows-tray/Neta.WeChatBridge/Uia/AttachmentExtractor.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/AttachmentExtractorTests.cs
git commit -m "feat(bridge): add AttachmentExtractor with jpg-cache fallback"
Phase 3 · 模型 + Backend Inbound
Task 7: ParsedMessage / InboundPayload 数据类
Files:
- Create:
packages/windows-tray/Neta.WeChatBridge/Models/ParsedMessage.cs - Create:
packages/windows-tray/Neta.WeChatBridge/Models/InboundPayload.cs
纯数据类,无逻辑。直接写,无需 TDD。
- Step 1: 实现
Models/ParsedMessage.cs:
namespace Neta.WeChatBridge.Models;
public enum ParsedMessageType
{
Text,
Image,
File,
Voice,
Video,
System,
Quote,
}
public sealed record QuotedRef(string SenderName, string Preview);
public sealed record ParsedMessage(
ParsedMessageType Type,
string SenderName,
string Content,
DateTimeOffset ReceivedAt,
IReadOnlyList<string>? AtList = null,
QuotedRef? QuotedRef = null,
string? AttachmentPath = null
);
Models/InboundPayload.cs:
namespace Neta.WeChatBridge.Models;
public sealed record InboundPayload(
int ChannelId,
string RoomName,
string SenderName,
string MsgType, // "text" / "image" / "file" / "voice" / "video" / "system" / "quote"
string Content,
string RawHash, // = MessageIdHasher.Build(...)
string ReceivedAt, // ISO8601
string? AttachmentPath = null,
IReadOnlyList<string>? AtList = null,
QuotedRefDto? QuotedRef = null
);
public sealed record QuotedRefDto(string SenderName, string Preview);
- Step 2: 编译
dotnet build packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj
Expected: Build succeeded。
- Step 3: Commit
git add packages/windows-tray/Neta.WeChatBridge/Models
git commit -m "feat(bridge): add ParsedMessage and InboundPayload models"
Task 8: BackendClient 扩展 (handshake 响应 + IngestInboundAsync)
Files:
- Modify:
packages/windows-tray/Neta.WeChatBridge/Backend/BackendClient.cs - Test:
packages/windows-tray/Neta.WeChatBridge.Tests/Backend/BackendClientInboundTests.cs - Test (新增):
packages/windows-tray/Neta.WeChatBridge.Tests/Backend/BackendClientHandshakeExtendedTests.cs
本 Task 同时补齐两个缺口:
- Handshake 响应原本只返回
Ok,Phase B 需要拿到 真实 channelId + 已启用群列表,才能真正启动 dispatcher (架构师审查 B5/B6/B7/B10)。- 新增
IngestInboundAsync推送入站消息给 backend。
- Step 1: 写失败测试
文件 1 — BackendClientInboundTests.cs:
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Xunit;
using Neta.WeChatBridge.Backend;
using Neta.WeChatBridge.Models;
public class BackendClientInboundTests
{
private sealed class StubHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest;
public string? LastBody;
public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
LastRequest = request;
if (request.Content is not null)
LastBody = await request.Content.ReadAsStringAsync(ct);
return new HttpResponseMessage(StatusCode);
}
}
private static InboundPayload MakePayload(string content = "hi") => new(
ChannelId: 7, RoomName: "产品群", SenderName: "小王",
MsgType: "text", Content: content, RawHash: "abc123",
ReceivedAt: "2026-05-09T12:30:00Z");
[Fact]
public async Task Ingest_posts_to_correct_url_with_secret_and_body()
{
var handler = new StubHandler();
var client = new BackendClient(new HttpClient(handler), "http://127.0.0.1:7071", "sec");
var ok = await client.IngestInboundAsync(MakePayload(), default);
Assert.True(ok);
Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method);
Assert.Equal(
"http://127.0.0.1:7071/open/netaclaw/channel/uia/inbound",
handler.LastRequest.RequestUri!.ToString());
Assert.Equal("sec", handler.LastRequest.Headers.GetValues("x-neta-tray-secret").First());
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal(7, doc.RootElement.GetProperty("channelId").GetInt32());
Assert.Equal("text", doc.RootElement.GetProperty("msgType").GetString());
Assert.Equal("abc123", doc.RootElement.GetProperty("rawHash").GetString());
}
[Fact]
public async Task Ingest_returns_false_on_non_2xx()
{
var handler = new StubHandler { StatusCode = HttpStatusCode.InternalServerError };
var client = new BackendClient(new HttpClient(handler), "http://127.0.0.1:7071", "sec");
Assert.False(await client.IngestInboundAsync(MakePayload(), default));
}
[Fact]
public async Task Ingest_returns_false_on_network_error()
{
var client = new BackendClient(
new HttpClient(new ThrowingHandler()), "http://127.0.0.1:7071", "sec");
Assert.False(await client.IngestInboundAsync(MakePayload(), default));
}
private sealed class ThrowingHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage r, CancellationToken c)
=> throw new HttpRequestException("connect refused");
}
}
文件 2 — BackendClientHandshakeExtendedTests.cs:
using System.Net;
using System.Net.Http.Json;
using Xunit;
using Neta.WeChatBridge.Backend;
public class BackendClientHandshakeExtendedTests
{
private sealed class JsonHandler : HttpMessageHandler
{
public object ResponseBody { get; set; } = new { };
public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage r, CancellationToken c)
=> Task.FromResult(new HttpResponseMessage(StatusCode)
{
Content = JsonContent.Create(ResponseBody),
});
}
[Fact]
public async Task Handshake_parses_channelId_and_enabledRooms_from_response()
{
var handler = new JsonHandler
{
ResponseBody = new
{
channelId = 42,
enabledRooms = new[] { "产品群", "研发群" }
}
};
var client = new BackendClient(new HttpClient(handler), "http://x", "sec");
var result = await client.HandshakeAsync("wxid_x", "N", "3.9.11.17", "http://127.0.0.1:7702", default);
Assert.True(result.Ok);
Assert.Equal(42, result.ChannelId);
Assert.NotNull(result.EnabledRooms);
Assert.Equal(new[] { "产品群", "研发群" }, result.EnabledRooms);
}
[Fact]
public async Task Handshake_tolerates_missing_fields()
{
var handler = new JsonHandler { ResponseBody = new { } };
var client = new BackendClient(new HttpClient(handler), "http://x", "sec");
var result = await client.HandshakeAsync("w", "n", "3.9.11.17", "http://127.0.0.1:7702", default);
Assert.True(result.Ok);
Assert.Null(result.ChannelId);
Assert.NotNull(result.EnabledRooms);
Assert.Empty(result.EnabledRooms!);
}
}
-
Step 2: 运行测试确认失败
-
Step 3: 修改 BackendClient.cs 扩展 HandshakeResult + 实现 IngestInboundAsync
替换 Plan A 的 HandshakeResult + 追加 IngestInboundAsync:
using System.Net.Http.Json;
using System.Text.Json;
namespace Neta.WeChatBridge.Backend;
public sealed record HandshakeResult(
bool Ok,
string? Error = null,
int? ChannelId = null,
IReadOnlyList<string>? EnabledRooms = null
);
public sealed class BackendClient
{
private readonly HttpClient _http;
private readonly string _baseUrl;
private readonly string _secret;
public BackendClient(HttpClient http, string baseUrl, string secret)
{
_http = http;
_baseUrl = baseUrl.TrimEnd('/');
_secret = secret;
}
public async Task<HandshakeResult> HandshakeAsync(
string wxid, string nickname, string wechatVersion, string bridgeBaseUrl, CancellationToken ct)
{
try
{
using var req = new HttpRequestMessage(
HttpMethod.Post,
$"{_baseUrl}/open/netaclaw/channel/uia/handshake");
req.Headers.Add("x-neta-tray-secret", _secret);
req.Content = JsonContent.Create(new { wxid, nickname, wechatVersion, bridgeBaseUrl });
using var resp = await _http.SendAsync(req, ct);
if (!resp.IsSuccessStatusCode)
return new HandshakeResult(false, $"handshake HTTP {(int)resp.StatusCode}");
int? channelId = null;
var rooms = new List<string>();
try
{
using var doc = await resp.Content.ReadFromJsonAsync<JsonDocument>(ct);
if (doc is not null)
{
if (doc.RootElement.TryGetProperty("channelId", out var cid) &&
cid.ValueKind == JsonValueKind.Number)
channelId = cid.GetInt32();
if (doc.RootElement.TryGetProperty("enabledRooms", out var er) &&
er.ValueKind == JsonValueKind.Array)
{
foreach (var item in er.EnumerateArray())
if (item.ValueKind == JsonValueKind.String)
rooms.Add(item.GetString()!);
}
}
}
catch { /* 响应解析失败视为字段缺失,不把 handshake 判负 */ }
return new HandshakeResult(true, null, channelId, rooms);
}
catch (Exception ex)
{
return new HandshakeResult(false, ex.Message);
}
}
public async Task<bool> IngestInboundAsync(
Models.InboundPayload payload, CancellationToken ct)
{
try
{
using var req = new HttpRequestMessage(
HttpMethod.Post,
$"{_baseUrl}/open/netaclaw/channel/uia/inbound");
req.Headers.Add("x-neta-tray-secret", _secret);
req.Content = JsonContent.Create(payload);
using var resp = await _http.SendAsync(req, ct);
return resp.IsSuccessStatusCode;
}
catch
{
return false;
}
}
}
- Step 4: 测试通过 (Plan A 原有 HandshakeTests 保持 pass)
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~BackendClient"
Expected:Plan A 原 BackendClientTests (3) + BackendClientInboundTests (3) + BackendClientHandshakeExtendedTests (2) = Passed: 8。
- Step 5: Commit
git add packages/windows-tray/Neta.WeChatBridge/Backend/BackendClient.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Backend/BackendClientInboundTests.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Backend/BackendClientHandshakeExtendedTests.cs
git commit -m "feat(bridge): extend HandshakeResult + add IngestInboundAsync"
Phase 4 · UIA 解析 (MessageParser)
这层依赖 UIA tree 结构;真实 UIA 树测试只能用本地手工冒烟。但我们把"控件树 → ParsedMessage"的逻辑抽到一个接口(IUiNode),可以用 fake node 跑单测。
Task 9: IUiNode 抽象 + StructuredItemSnapshot
Files:
- Create:
packages/windows-tray/Neta.WeChatBridge/Uia/IUiNode.cs
抽象 UIA 控件节点最低依赖,便于测试 mock。
- Step 1: 实现
namespace Neta.WeChatBridge.Uia;
/// <summary>
/// 抽象一个可枚举的 UI 节点 (UIA / 测试 mock 共享接口)。
/// 只暴露 MessageParser 需要的最小集合。
/// </summary>
public interface IUiNode
{
string Name { get; }
string ControlTypeName { get; } // "Text" / "Pane" / "Button" / "Image" / "ListItem"
IReadOnlyList<IUiNode> Children { get; }
}
public sealed class StaticUiNode : IUiNode
{
public string Name { get; init; } = string.Empty;
public string ControlTypeName { get; init; } = string.Empty;
public IReadOnlyList<IUiNode> Children { get; init; } = Array.Empty<IUiNode>();
}
- Step 2: Commit
git add packages/windows-tray/Neta.WeChatBridge/Uia/IUiNode.cs
git commit -m "feat(bridge): add IUiNode abstraction for parser tests"
Task 10: MessageParser
Files:
- Create:
packages/windows-tray/Neta.WeChatBridge/Uia/MessageParser.cs - Test:
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/MessageParserTests.cs
Phase B MVP:文本 / 图片 / 引用 / @ 解析。文件 / 语音 / 视频识别为类型,但 attachmentPath 暂留空 (用 spec 表里的 fallback 策略)。
- Step 1: 写失败测试
using Xunit;
using Neta.WeChatBridge.Uia;
using Neta.WeChatBridge.Models;
public class MessageParserTests
{
private static StaticUiNode N(string name, string type, params IUiNode[] children) =>
new() { Name = name, ControlTypeName = type, Children = children };
[Fact]
public void Parse_text_message()
{
// ListItem
// Button: "小王" ← 发送者
// Pane (bubble)
// Text: "你好"
// Text: "12:30" ← 时间戳
var item = N("", "ListItem",
N("小王", "Button"),
N("", "Pane", N("你好", "Text")),
N("12:30", "Text"));
var parser = new MessageParser();
var parsed = parser.Parse(item);
Assert.NotNull(parsed);
Assert.Equal(ParsedMessageType.Text, parsed!.Type);
Assert.Equal("小王", parsed.SenderName);
Assert.Equal("你好", parsed.Content);
}
[Fact]
public void Parse_at_mention_extracts_alias_list()
{
var item = N("", "ListItem",
N("张三", "Button"),
N("", "Pane", N("@小神 @lisa 帮看下", "Text")));
var parsed = new MessageParser().Parse(item);
Assert.NotNull(parsed);
Assert.Equal(ParsedMessageType.Text, parsed!.Type);
Assert.Contains("小神", parsed.AtList);
Assert.Contains("lisa", parsed.AtList);
}
[Fact]
public void Parse_image_message_marks_type_image()
{
// 气泡里出现 Image 控件
var item = N("", "ListItem",
N("Alice", "Button"),
N("", "Pane",
N("[图片]", "Text"),
N("", "Image")));
var parsed = new MessageParser().Parse(item);
Assert.Equal(ParsedMessageType.Image, parsed!.Type);
}
[Fact]
public void Parse_quoted_message_captures_quote_ref()
{
// 引用:气泡顶部子控件 "老板: 这个方案明天就上线"
var item = N("", "ListItem",
N("张三", "Button"),
N("", "Pane",
N("老板: 这个方案明天就上线", "Text"), // 引用块文本
N("确定可以上线吗?", "Text"))); // 当前消息正文
var parsed = new MessageParser().Parse(item);
Assert.NotNull(parsed!.QuotedRef);
Assert.Equal("老板", parsed.QuotedRef!.SenderName);
Assert.Contains("这个方案明天就上线", parsed.QuotedRef.Preview);
Assert.Equal("确定可以上线吗?", parsed.Content);
}
[Fact]
public void Parse_returns_null_when_no_sender()
{
// 系统消息没有 sender Button
var item = N("", "ListItem",
N("", "Pane", N("张三 撤回了一条消息", "Text")));
var parsed = new MessageParser().Parse(item);
Assert.NotNull(parsed);
Assert.Equal(ParsedMessageType.System, parsed!.Type);
}
[Fact]
public void Parse_ignores_empty_item()
{
var item = N("", "ListItem");
Assert.Null(new MessageParser().Parse(item));
}
}
-
Step 2: 运行测试确认失败
-
Step 3: 实现
using System.Text.RegularExpressions;
using Neta.WeChatBridge.Models;
namespace Neta.WeChatBridge.Uia;
public sealed class MessageParser
{
// @<alias> — alias 必须前后有边界(空白/字符串边界/标点)
private static readonly Regex AtPattern = new(
@"(?:^|\s)@([^\s@,;:!?]+)(?=$|[\s,;:!?])",
RegexOptions.Compiled);
// 引用消息块:形如 "<sender>: <preview>" (sender 不含冒号)
private static readonly Regex QuotePattern = new(
@"^([^:]{1,32})[::]\s*(.{1,200})$",
RegexOptions.Compiled);
public ParsedMessage? Parse(IUiNode item)
{
if (item.Children.Count == 0) return null;
// 1. 找发送者 Button
var senderButton = item.Children.FirstOrDefault(c =>
c.ControlTypeName == "Button" && !string.IsNullOrWhiteSpace(c.Name));
// 2. 找气泡 Pane
var bubble = item.Children.FirstOrDefault(c => c.ControlTypeName == "Pane");
if (bubble is null) return null;
// 3. 收集气泡内的文本控件 (有序)
var texts = bubble.Children
.Where(c => c.ControlTypeName == "Text" && !string.IsNullOrEmpty(c.Name))
.Select(c => c.Name)
.ToList();
var hasImage = bubble.Children.Any(c => c.ControlTypeName == "Image");
var receivedAt = DateTimeOffset.UtcNow;
// 4. 系统消息(无 sender)
if (senderButton is null)
{
if (texts.Count == 0) return null;
return new ParsedMessage(
Type: ParsedMessageType.System,
SenderName: "system",
Content: string.Join(" ", texts),
ReceivedAt: receivedAt);
}
var senderName = senderButton.Name.Trim();
// 5. 图片
if (hasImage)
{
return new ParsedMessage(
Type: ParsedMessageType.Image,
SenderName: senderName,
Content: texts.LastOrDefault() ?? "[图片]",
ReceivedAt: receivedAt);
}
// 6. 引用消息:第一段文本符合 "X: ..." 且后面还有文本
QuotedRef? quoted = null;
var contentText = texts.LastOrDefault() ?? string.Empty;
if (texts.Count >= 2)
{
var maybeQuote = QuotePattern.Match(texts[0]);
if (maybeQuote.Success)
{
quoted = new QuotedRef(
SenderName: maybeQuote.Groups[1].Value.Trim(),
Preview: maybeQuote.Groups[2].Value.Trim());
contentText = texts[1];
}
}
// 7. @ 提取
var atList = new List<string>();
foreach (Match m in AtPattern.Matches(contentText))
atList.Add(m.Groups[1].Value);
return new ParsedMessage(
Type: ParsedMessageType.Text,
SenderName: senderName,
Content: contentText,
ReceivedAt: receivedAt,
AtList: atList.Count > 0 ? atList : null,
QuotedRef: quoted);
}
}
- Step 4: 测试通过
Expected:Passed: 6。
- Step 5: Commit
git add packages/windows-tray/Neta.WeChatBridge/Uia/MessageParser.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/MessageParserTests.cs
git commit -m "feat(bridge): add MessageParser for text/image/quote/at"
Phase 5 · 发送 (SendInput + MessageSender)
Task 11: SendInput P/Invoke 包装
Files:
- Create:
packages/windows-tray/Neta.WeChatBridge/Uia/SendInput.cs
用
User32.SendInput发 Unicode 文本,比System.Windows.Forms.SendKeys对中文/emoji 稳定。无 unit test (操作系统 API),仅手工验证。
- Step 1: 实现
using System.Runtime.InteropServices;
namespace Neta.WeChatBridge.Uia;
/// <summary>
/// User32.SendInput 包装。仅支持 KEYBOARD INPUT。
/// 用于把 Unicode 字符送入当前焦点输入框 (绕过 SendKeys 的 ASCII 限制)。
/// </summary>
public static class SendInput
{
private const int INPUT_KEYBOARD = 1;
private const uint KEYEVENTF_KEYUP = 0x0002;
private const uint KEYEVENTF_UNICODE = 0x0004;
private const ushort VK_RETURN = 0x0D;
[StructLayout(LayoutKind.Sequential)]
private struct INPUT
{
public int type;
public InputUnion union;
}
[StructLayout(LayoutKind.Explicit)]
private struct InputUnion
{
[FieldOffset(0)] public KEYBDINPUT ki;
}
[StructLayout(LayoutKind.Sequential)]
private struct KEYBDINPUT
{
public ushort wVk;
public ushort wScan;
public uint dwFlags;
public uint time;
public IntPtr dwExtraInfo;
}
[DllImport("user32.dll", SetLastError = true)]
private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
public static void TypeUnicodeText(string text)
{
if (string.IsNullOrEmpty(text)) return;
var inputs = new List<INPUT>(text.Length * 2);
foreach (var ch in text)
{
inputs.Add(MakeChar(ch, isKeyUp: false));
inputs.Add(MakeChar(ch, isKeyUp: true));
}
var arr = inputs.ToArray();
SendInput((uint)arr.Length, arr, Marshal.SizeOf<INPUT>());
}
public static void PressEnter()
{
var inputs = new[]
{
new INPUT
{
type = INPUT_KEYBOARD,
union = new InputUnion
{
ki = new KEYBDINPUT { wVk = VK_RETURN, dwFlags = 0 }
}
},
new INPUT
{
type = INPUT_KEYBOARD,
union = new InputUnion
{
ki = new KEYBDINPUT { wVk = VK_RETURN, dwFlags = KEYEVENTF_KEYUP }
}
},
};
SendInput(2, inputs, Marshal.SizeOf<INPUT>());
}
private static INPUT MakeChar(char ch, bool isKeyUp)
{
var flags = KEYEVENTF_UNICODE | (isKeyUp ? KEYEVENTF_KEYUP : 0);
return new INPUT
{
type = INPUT_KEYBOARD,
union = new InputUnion
{
ki = new KEYBDINPUT { wVk = 0, wScan = ch, dwFlags = flags }
}
};
}
}
- Step 2: 编译
Expected: Build succeeded。
- Step 3: Commit
git add packages/windows-tray/Neta.WeChatBridge/Uia/SendInput.cs
git commit -m "feat(bridge): add SendInput P/Invoke wrapper for unicode keys"
Task 12: MessageSender 接口 + 实现
Files:
- Create:
packages/windows-tray/Neta.WeChatBridge/Uia/IMessageSender.cs - Create:
packages/windows-tray/Neta.WeChatBridge/Uia/MessageSender.cs - Test:
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/MessageSenderTests.cs(用 fake)
真实
MessageSender调 UIA + SendInput,不能 unit test;但通过 IMessageSender 接口 + fake 实现验证 endpoint 调用编排。
- Step 1: 写接口
Uia/IMessageSender.cs:
namespace Neta.WeChatBridge.Uia;
public interface IMessageSender
{
/// <summary>
/// 切到目标群窗口 → 输入文本 → 回车。
/// 返回是否发送成功 (基于"最后一条自己发的消息时间戳 > 发送前"判断)。
/// </summary>
Task<bool> SendTextAsync(string roomName, string text, CancellationToken ct);
}
- Step 2: 写实现 (UIA + SendInput)
Uia/MessageSender.cs:
using System.Windows.Automation;
using Neta.WeChatBridge.Config;
namespace Neta.WeChatBridge.Uia;
public sealed class MessageSender : IMessageSender
{
private readonly AutomationElement _root;
private readonly VersionProfile _profile;
public MessageSender(AutomationElement root, VersionProfile profile)
{
_root = root;
_profile = profile;
}
public async Task<bool> SendTextAsync(string roomName, string text, CancellationToken ct)
{
try
{
// 1. 切到目标群:点击会话列表对应条目
var sessionList = _root.FindFirst(
TreeScope.Descendants,
new AndCondition(
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.List),
new PropertyCondition(AutomationElement.NameProperty, _profile.SessionListName)));
if (sessionList is null) return false;
var roomItem = sessionList.FindFirst(
TreeScope.Children,
new AndCondition(
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.ListItem),
new PropertyCondition(AutomationElement.NameProperty, roomName)));
if (roomItem is null) return false;
if (roomItem.TryGetCurrentPattern(SelectionItemPattern.Pattern, out var pat) &&
pat is SelectionItemPattern selPat)
{
selPat.Select();
}
else
{
roomItem.SetFocus();
}
await Task.Delay(150, ct);
// 2. 找输入框 + SetFocus
var input = _root.FindFirst(
TreeScope.Descendants,
new AndCondition(
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Edit),
new PropertyCondition(AutomationElement.NameProperty, _profile.InputBoxName)));
if (input is null) return false;
input.SetFocus();
await Task.Delay(80, ct);
// 3. 输入 + 回车
SendInput.TypeUnicodeText(text);
await Task.Delay(40, ct);
SendInput.PressEnter();
await Task.Delay(120, ct);
return true;
}
catch
{
return false;
}
}
}
- Step 3: 写一个 fake + endpoint 编排测试占位
Neta.WeChatBridge.Tests/Uia/MessageSenderTests.cs:
using Xunit;
using Neta.WeChatBridge.Uia;
public class MessageSenderTests
{
private sealed class FakeSender : IMessageSender
{
public List<(string Room, string Text)> Sent { get; } = new();
public bool ReturnValue { get; set; } = true;
public Task<bool> SendTextAsync(string roomName, string text, CancellationToken ct)
{
Sent.Add((roomName, text));
return Task.FromResult(ReturnValue);
}
}
[Fact]
public async Task FakeSender_records_and_returns()
{
var fake = new FakeSender();
var ok = await fake.SendTextAsync("产品群", "hello", default);
Assert.True(ok);
Assert.Single(fake.Sent);
Assert.Equal("产品群", fake.Sent[0].Room);
Assert.Equal("hello", fake.Sent[0].Text);
}
}
真实 UIA
MessageSender不能 unit test,留给 Phase B 手工验证清单。Fake 用于 Task 14 (SendEndpoint 编排测试)。
- Step 4: 测试通过
Expected:Passed: 1。
- Step 5: Commit
git add packages/windows-tray/Neta.WeChatBridge/Uia/IMessageSender.cs \
packages/windows-tray/Neta.WeChatBridge/Uia/MessageSender.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/MessageSenderTests.cs
git commit -m "feat(bridge): add IMessageSender + UIA MessageSender"
Phase 6 · BridgeState 扩展 + HTTP 端点
架构顺序:先扩展
BridgeState让它能承载Rooms/EventQueue,再写依赖这些字段的 endpoint。反过来顺序会让 endpoint 测试一直失败到 Task 16 才通过。
Task 13: 扩展 BridgeState + 新容器字段
Files:
-
Modify:
packages/windows-tray/Neta.WeChatBridge/BridgeState.cs -
Step 1: 完整替换 BridgeState.cs
using Neta.WeChatBridge.Uia;
namespace Neta.WeChatBridge;
/// <summary>
/// 启动时填充、后续大部分只读的 bridge 状态容器。
/// Rooms / EventQueue 字段为 null 时表示尚未完成 handshake;
/// Program.cs 必须在 handshake 成功后调 <see cref="InitRuntimeContainers"/> 一次性注入。
/// </summary>
public sealed class BridgeState
{
public string WechatVersion { get; init; } = string.Empty;
public string ProfileName { get; init; } = string.Empty;
public string Wxid { get; init; } = string.Empty;
public string Nickname { get; init; } = string.Empty;
public DateTimeOffset StartedAtUtc { get; init; } = DateTimeOffset.UtcNow;
/// <summary>handshake 后由 Program.cs 注入的 backend channelId。</summary>
public int ChannelId { get; private set; }
/// <summary>handshake 后才被注入;之前访问抛 <see cref="InvalidOperationException"/>。</summary>
public RoomRegistry Rooms => _rooms
?? throw new InvalidOperationException("BridgeState.Rooms 未初始化 (handshake 未完成)");
/// <summary>handshake 后才被注入。</summary>
public RoomEventQueue EventQueue => _eventQueue
?? throw new InvalidOperationException("BridgeState.EventQueue 未初始化 (handshake 未完成)");
public bool IsRuntimeReady => _rooms is not null && _eventQueue is not null;
public readonly DiagnosticsBuffer Diagnostics = new(capacity: 5);
private RoomRegistry? _rooms;
private RoomEventQueue? _eventQueue;
/// <summary>
/// handshake 成功后调一次;重复调用抛异常。
/// 事件订阅 / dispatcher / endpoint 只能在此之后依赖这些字段。
/// </summary>
public void InitRuntimeContainers(int channelId, IEnumerable<string> enabledRooms)
{
if (_rooms is not null || _eventQueue is not null)
throw new InvalidOperationException("InitRuntimeContainers 只能调用一次");
ChannelId = channelId;
_rooms = new RoomRegistry(channelId);
foreach (var room in enabledRooms) _rooms.EnableRoom(room);
_eventQueue = new RoomEventQueue(capacity: 50);
}
}
public sealed class DiagnosticsBuffer
{
private readonly int _capacity;
private readonly LinkedList<DiagnosticsEntry> _entries = new();
private readonly object _lock = new();
public DiagnosticsBuffer(int capacity) { _capacity = capacity; }
public void Record(string category, string message)
{
lock (_lock)
{
_entries.AddFirst(new DiagnosticsEntry(DateTimeOffset.UtcNow, category, message));
while (_entries.Count > _capacity) _entries.RemoveLast();
}
}
public IReadOnlyList<DiagnosticsEntry> Snapshot()
{
lock (_lock) return _entries.ToList();
}
}
public sealed record DiagnosticsEntry(DateTimeOffset At, string Category, string Message);
设计说明:
Rooms/EventQueue改成懒字段 + 访问器校验,避免 Plan A 为它们给一个 channelId=0 的桩然后 Phase B 换掉导致 roomId 漂移 (架构师审查 B5/B10)。所有依赖它们的代码(endpoint / dispatcher / watcher)都要在 handshake 之后才构造。
- Step 2: 扩展单测
追加 packages/windows-tray/Neta.WeChatBridge.Tests/BridgeStateTests.cs:
using Xunit;
using Neta.WeChatBridge;
public class BridgeStateTests
{
[Fact]
public void Rooms_throws_before_init()
{
var s = new BridgeState();
Assert.False(s.IsRuntimeReady);
Assert.Throws<InvalidOperationException>(() => _ = s.Rooms);
Assert.Throws<InvalidOperationException>(() => _ = s.EventQueue);
}
[Fact]
public void Init_sets_channel_id_and_enables_rooms()
{
var s = new BridgeState();
s.InitRuntimeContainers(42, new[] { "产品群", "研发群" });
Assert.True(s.IsRuntimeReady);
Assert.Equal(42, s.ChannelId);
Assert.True(s.Rooms.IsEnabled("产品群"));
Assert.True(s.Rooms.IsEnabled("研发群"));
}
[Fact]
public void Init_twice_throws()
{
var s = new BridgeState();
s.InitRuntimeContainers(1, Array.Empty<string>());
Assert.Throws<InvalidOperationException>(() =>
s.InitRuntimeContainers(2, Array.Empty<string>()));
}
}
- Step 3: 测试通过
Expected:Passed: 3。Plan A 的 HealthEndpointTests 也继续通过(因为它不触碰新字段)。
- Step 4: Commit
git add packages/windows-tray/Neta.WeChatBridge/BridgeState.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/BridgeStateTests.cs
git commit -m "feat(bridge): extend BridgeState with late-bound Rooms/EventQueue"
Task 14: RoomsEndpoint
Files:
-
Create:
packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/RoomsEndpoint.cs -
Test:
packages/windows-tray/Neta.WeChatBridge.Tests/Http/RoomsEndpointTests.cs -
Step 1: 写失败测试
using System.Net.Http.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;
using Neta.WeChatBridge;
using Neta.WeChatBridge.Http.Endpoints;
using Neta.WeChatBridge.Uia;
public class RoomsEndpointTests
{
private static IHost BuildHost(BridgeState state)
{
return new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(s => s.AddSingleton(state).AddRouting());
web.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(e => RoomsEndpoint.Map(e));
});
})
.Start();
}
[Fact]
public async Task Get_rooms_returns_enabled_list()
{
var state = new BridgeState();
state.Rooms.EnableRoom("产品群");
state.Rooms.EnableRoom("研发群");
using var host = BuildHost(state);
var client = host.GetTestClient();
var body = await client.GetFromJsonAsync<RoomsResponse>("/rooms");
Assert.NotNull(body);
Assert.Equal(2, body!.Rooms.Count);
Assert.Contains("产品群", body.Rooms);
}
public sealed class RoomsResponse
{
public List<string> Rooms { get; set; } = new();
}
}
注意:本测试假定
BridgeState.Rooms字段已存在。需要先做 Task 16 (修改 BridgeState)。本 Task 暂时跳过测试运行,先写完代码,Task 16 完成后回来验证。
- Step 2: 实现
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Neta.WeChatBridge.Http.Endpoints;
public static class RoomsEndpoint
{
public static IEndpointRouteBuilder Map(IEndpointRouteBuilder app)
{
app.MapGet("/rooms", (BridgeState state) =>
{
return Results.Json(new
{
rooms = state.Rooms.EnabledList(),
});
});
return app;
}
}
- Step 3: Commit (测试在 Task 16 后跑通)
git add packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/RoomsEndpoint.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Http/RoomsEndpointTests.cs
git commit -m "feat(bridge): add /rooms endpoint (depends on BridgeState.Rooms)"
Task 15: SendEndpoint
Files:
-
Create:
packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/SendEndpoint.cs -
Test:
packages/windows-tray/Neta.WeChatBridge.Tests/Http/SendEndpointTests.cs -
Step 1: 写失败测试
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;
using Neta.WeChatBridge.Http.Endpoints;
using Neta.WeChatBridge.Uia;
public class SendEndpointTests
{
private sealed class FakeSender : IMessageSender
{
public (string Room, string Text)? LastSent;
public bool ReturnValue { get; set; } = true;
public Task<bool> SendTextAsync(string roomName, string text, CancellationToken ct)
{
LastSent = (roomName, text);
return Task.FromResult(ReturnValue);
}
}
private static IHost BuildHost(IMessageSender sender)
{
return new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(s =>
s.AddSingleton(sender).AddRouting());
web.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(e => SendEndpoint.Map(e));
});
})
.Start();
}
[Fact]
public async Task Post_send_calls_sender_with_payload()
{
var fake = new FakeSender();
using var host = BuildHost(fake);
var client = host.GetTestClient();
var resp = await client.PostAsJsonAsync("/send", new
{
roomName = "产品群",
text = "你好"
});
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.Equal("产品群", fake.LastSent?.Room);
Assert.Equal("你好", fake.LastSent?.Text);
}
[Fact]
public async Task Post_send_returns_500_when_sender_fails()
{
var fake = new FakeSender { ReturnValue = false };
using var host = BuildHost(fake);
var client = host.GetTestClient();
var resp = await client.PostAsJsonAsync("/send", new
{
roomName = "产品群",
text = "hi"
});
Assert.Equal(HttpStatusCode.InternalServerError, resp.StatusCode);
}
[Fact]
public async Task Post_send_returns_400_when_roomName_missing()
{
var fake = new FakeSender();
using var host = BuildHost(fake);
var client = host.GetTestClient();
var resp = await client.PostAsJsonAsync("/send", new { text = "hi" });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Post_send_returns_400_when_text_missing()
{
var fake = new FakeSender();
using var host = BuildHost(fake);
var client = host.GetTestClient();
var resp = await client.PostAsJsonAsync("/send", new { roomName = "x" });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
}
- Step 2: 实现
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Neta.WeChatBridge.Uia;
namespace Neta.WeChatBridge.Http.Endpoints;
public static class SendEndpoint
{
public sealed record SendRequest(string? RoomName, string? Text);
public static IEndpointRouteBuilder Map(IEndpointRouteBuilder app)
{
app.MapPost("/send", async (SendRequest body, IMessageSender sender, HttpContext ctx) =>
{
if (string.IsNullOrWhiteSpace(body.RoomName))
return Results.BadRequest(new { error = "roomName 必填" });
if (string.IsNullOrWhiteSpace(body.Text))
return Results.BadRequest(new { error = "text 必填" });
var ok = await sender.SendTextAsync(body.RoomName, body.Text, ctx.RequestAborted);
return ok
? Results.Ok(new { ok = true })
: Results.StatusCode(500);
});
return app;
}
}
- Step 3: 测试通过
Expected:Passed: 4。
- Step 4: Commit
git add packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/SendEndpoint.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Http/SendEndpointTests.cs
git commit -m "feat(bridge): add /send endpoint with sender injection"
Task 16: EnableDisableEndpoint
Files:
-
Create:
packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/EnableDisableEndpoint.cs -
Test:
packages/windows-tray/Neta.WeChatBridge.Tests/Http/EnableDisableEndpointTests.cs -
Step 1: 写失败测试
using System.Net.Http.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;
using Neta.WeChatBridge;
using Neta.WeChatBridge.Http.Endpoints;
public class EnableDisableEndpointTests
{
private static IHost BuildHost(BridgeState state)
{
return new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(s => s.AddSingleton(state).AddRouting());
web.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(e => EnableDisableEndpoint.Map(e));
});
})
.Start();
}
[Fact]
public async Task Enable_room_sets_flag()
{
var state = new BridgeState();
using var host = BuildHost(state);
var client = host.GetTestClient();
await client.PostAsJsonAsync("/enable-room", new { roomName = "产品群" });
Assert.True(state.Rooms.IsEnabled("产品群"));
}
[Fact]
public async Task Disable_room_clears_flag()
{
var state = new BridgeState();
state.Rooms.EnableRoom("产品群");
using var host = BuildHost(state);
var client = host.GetTestClient();
await client.PostAsJsonAsync("/disable-room", new { roomName = "产品群" });
Assert.False(state.Rooms.IsEnabled("产品群"));
}
}
- Step 2: 实现
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Neta.WeChatBridge.Http.Endpoints;
public static class EnableDisableEndpoint
{
public sealed record RoomRequest(string? RoomName);
public static IEndpointRouteBuilder Map(IEndpointRouteBuilder app)
{
app.MapPost("/enable-room", (RoomRequest body, BridgeState state) =>
{
if (string.IsNullOrWhiteSpace(body.RoomName))
return Results.BadRequest(new { error = "roomName 必填" });
state.Rooms.EnableRoom(body.RoomName);
return Results.Ok(new { ok = true });
});
app.MapPost("/disable-room", (RoomRequest body, BridgeState state) =>
{
if (string.IsNullOrWhiteSpace(body.RoomName))
return Results.BadRequest(new { error = "roomName 必填" });
state.Rooms.DisableRoom(body.RoomName);
return Results.Ok(new { ok = true });
});
return app;
}
}
-
Step 3: 测试通过(等 Task 16 完成 BridgeState.Rooms 字段)
-
Step 4: Commit
git add packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/EnableDisableEndpoint.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Http/EnableDisableEndpointTests.cs
git commit -m "feat(bridge): add /enable-room and /disable-room endpoints"
Phase 7 · HTTP 注入 + 事件订阅 + Worker 编排
Task 17: BridgeHttpServer 注入 sender + 所有 endpoint 注册
Files:
-
Modify:
packages/windows-tray/Neta.WeChatBridge/Http/BridgeHttpServer.cs -
Step 1: 完整替换 BridgeHttpServer.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Neta.WeChatBridge.Http.Endpoints;
using Neta.WeChatBridge.Uia;
namespace Neta.WeChatBridge.Http;
public sealed class BridgeHttpServer
{
public static WebApplication Build(
int port,
string traySecret,
BridgeState state,
IMessageSender sender)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
builder.Services.AddSingleton(state);
builder.Services.AddSingleton(sender);
builder.Services.AddRouting();
var app = builder.Build();
app.UseMiddleware<TraySecretAuth>(traySecret);
app.UseRouting();
#pragma warning disable ASP0014
app.UseEndpoints(endpoints =>
{
HealthEndpoint.Map(endpoints);
DiagEndpoint.Map(endpoints);
RoomsEndpoint.Map(endpoints);
SendEndpoint.Map(endpoints);
EnableDisableEndpoint.Map(endpoints);
});
#pragma warning restore ASP0014
return app;
}
}
- Step 2: 跑前面 Task 14/15/16 被阻塞的测试
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~RoomsEndpointTests|FullyQualifiedName~EnableDisableEndpointTests"
⚠️ 这里测试代码需要先手动初始化 state.InitRuntimeContainers,否则访问
state.Rooms会抛异常。请修改 Task 14 / Task 16 的测试 setup:
在 RoomsEndpointTests.Get_rooms_returns_enabled_list 里把 state.Rooms.EnableRoom(...) 之前插入:
state.InitRuntimeContainers(1, Array.Empty<string>());
在 EnableDisableEndpointTests 的两个测试里同样先调 state.InitRuntimeContainers(1, Array.Empty<string>());。
Expected:Passed: 3。
- Step 3: Commit
git add packages/windows-tray/Neta.WeChatBridge/Http/BridgeHttpServer.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Http/RoomsEndpointTests.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Http/EnableDisableEndpointTests.cs
git commit -m "feat(bridge): register all endpoints + inject IMessageSender"
Task 18: SessionListWatcher (UIA 事件订阅)
Files:
- Create:
packages/windows-tray/Neta.WeChatBridge/Uia/SessionListWatcher.cs
UIA 事件订阅只能在真实 WeChat 下手工验证。该组件有最小 surface (Start/Stop + 回调),无单测。
- Step 1: 实现
using System.Windows.Automation;
using Neta.WeChatBridge.Config;
namespace Neta.WeChatBridge.Uia;
public sealed class SessionListWatcher : IDisposable
{
private readonly AutomationElement _root;
private readonly VersionProfile _profile;
private readonly Action<string> _onRoomEvent;
private AutomationElement? _sessionList;
private StructureChangedEventHandler? _structureHandler;
private AutomationPropertyChangedEventHandler? _nameHandler;
private bool _disposed;
public SessionListWatcher(
AutomationElement root, VersionProfile profile, Action<string> onRoomEvent)
{
_root = root;
_profile = profile;
_onRoomEvent = onRoomEvent;
}
public bool Start()
{
_sessionList = _root.FindFirst(
TreeScope.Descendants,
new AndCondition(
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.List),
new PropertyCondition(AutomationElement.NameProperty, _profile.SessionListName)));
if (_sessionList is null) return false;
// 1. 订阅子控件增删 (新会话出现时触发)
_structureHandler = OnStructureChanged;
Automation.AddStructureChangedEventHandler(
_sessionList, TreeScope.Children, _structureHandler);
// 2. 订阅 Name 变化 (最新消息预览变 → 有新消息)
_nameHandler = OnNameChanged;
Automation.AddAutomationPropertyChangedEventHandler(
_sessionList, TreeScope.Children, _nameHandler,
AutomationElement.NameProperty);
return true;
}
private void OnStructureChanged(object sender, StructureChangedEventArgs e)
{
try
{
if (sender is AutomationElement el && !string.IsNullOrWhiteSpace(el.Current.Name))
_onRoomEvent(el.Current.Name);
}
catch { /* UIA 可能在窗口切换时抛 */ }
}
private void OnNameChanged(object sender, AutomationPropertyChangedEventArgs e)
{
try
{
if (sender is AutomationElement el && !string.IsNullOrWhiteSpace(el.Current.Name))
_onRoomEvent(el.Current.Name);
}
catch { }
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// 精确移除,不用 RemoveAllEventHandlers(它是全局的,会误删其它订阅者)
try
{
if (_sessionList is not null && _structureHandler is not null)
Automation.RemoveStructureChangedEventHandler(_sessionList, _structureHandler);
}
catch { }
try
{
if (_sessionList is not null && _nameHandler is not null)
Automation.RemoveAutomationPropertyChangedEventHandler(_sessionList, _nameHandler);
}
catch { }
}
}
- Step 2: Commit
git add packages/windows-tray/Neta.WeChatBridge/Uia/SessionListWatcher.cs
git commit -m "feat(bridge): add SessionListWatcher UIA event subscriptions"
Task 19: InboundDispatcher Worker
Files:
- Create:
packages/windows-tray/Neta.WeChatBridge/Workers/InboundDispatcher.cs - Test:
packages/windows-tray/Neta.WeChatBridge.Tests/Workers/InboundDispatcherTests.cs
核心编排逻辑:从 EventQueue dequeue → 切窗读消息 → 过滤已启用群 → 去重 → POST backend。
- Step 1: 写失败测试
using Xunit;
using Neta.WeChatBridge;
using Neta.WeChatBridge.Models;
using Neta.WeChatBridge.Uia;
using Neta.WeChatBridge.Workers;
public class InboundDispatcherTests
{
// Fake 切窗读消息器,避免真 UIA
private sealed class FakeReader : IRoomMessageReader
{
public Dictionary<string, List<ParsedMessage>> Map = new();
public List<string> SwitchedTo = new();
public Task<IReadOnlyList<ParsedMessage>> SwitchAndReadAsync(string roomName, CancellationToken ct)
{
SwitchedTo.Add(roomName);
return Task.FromResult<IReadOnlyList<ParsedMessage>>(
Map.GetValueOrDefault(roomName) ?? new List<ParsedMessage>());
}
}
private sealed class FakeBackendSink : IBackendSink
{
public List<InboundPayload> Sent = new();
public Task<bool> IngestAsync(InboundPayload payload, CancellationToken ct)
{
Sent.Add(payload);
return Task.FromResult(true);
}
}
private static BridgeState NewState(int channelId)
{
var state = new BridgeState();
state.InitRuntimeContainers(channelId, Array.Empty<string>());
return state;
}
[Fact]
public async Task Dispatcher_skips_rooms_not_enabled()
{
var state = NewState(1);
var reader = new FakeReader();
reader.Map["未启用群"] = new List<ParsedMessage> {
new(ParsedMessageType.Text, "a", "hi", DateTimeOffset.UtcNow)
};
var sink = new FakeBackendSink();
var dispatcher = new InboundDispatcher(state, reader, sink, channelId: 1);
state.EventQueue.Enqueue("未启用群");
var cts = new CancellationTokenSource();
var run = dispatcher.RunAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
try { await run; } catch (OperationCanceledException) { }
Assert.Empty(reader.SwitchedTo);
Assert.Empty(sink.Sent);
}
[Fact]
public async Task Dispatcher_switches_reads_and_posts_for_enabled_room()
{
var state = NewState(1);
state.Rooms.EnableRoom("产品群");
var reader = new FakeReader();
reader.Map["产品群"] = new List<ParsedMessage> {
new(ParsedMessageType.Text, "alice", "hello", DateTimeOffset.UtcNow)
};
var sink = new FakeBackendSink();
var dispatcher = new InboundDispatcher(state, reader, sink, channelId: 1);
state.EventQueue.Enqueue("产品群");
var cts = new CancellationTokenSource();
var run = dispatcher.RunAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
try { await run; } catch (OperationCanceledException) { }
Assert.Equal(new[] { "产品群" }, reader.SwitchedTo);
Assert.Single(sink.Sent);
Assert.Equal("alice", sink.Sent[0].SenderName);
Assert.Equal("hello", sink.Sent[0].Content);
Assert.Equal(1, sink.Sent[0].ChannelId);
}
[Fact]
public async Task Dispatcher_dedupes_same_message_on_repeat_switch()
{
var state = NewState(1);
state.Rooms.EnableRoom("产品群");
var now = DateTimeOffset.UtcNow;
var reader = new FakeReader();
var msg = new ParsedMessage(ParsedMessageType.Text, "a", "hi", now);
reader.Map["产品群"] = new List<ParsedMessage> { msg };
var sink = new FakeBackendSink();
var dispatcher = new InboundDispatcher(state, reader, sink, channelId: 1);
state.EventQueue.Enqueue("产品群");
var cts = new CancellationTokenSource();
var run = dispatcher.RunAsync(cts.Token);
await Task.Delay(80);
// 再次触发同一群事件,读到同一条消息 (同 minute + 同 sender + 同 content)
state.EventQueue.Enqueue("产品群");
await Task.Delay(80);
cts.Cancel();
try { await run; } catch (OperationCanceledException) { }
Assert.Equal(2, reader.SwitchedTo.Count); // 切窗两次
Assert.Single(sink.Sent); // 但只推一次
}
}
- Step 2: 实现接口 + dispatcher
Workers/InboundDispatcher.cs:
using Neta.WeChatBridge.Models;
using Neta.WeChatBridge.Uia;
namespace Neta.WeChatBridge.Workers;
public interface IRoomMessageReader
{
Task<IReadOnlyList<ParsedMessage>> SwitchAndReadAsync(string roomName, CancellationToken ct);
}
public interface IBackendSink
{
Task<bool> IngestAsync(InboundPayload payload, CancellationToken ct);
}
public sealed class InboundDispatcher
{
private readonly BridgeState _state;
private readonly IRoomMessageReader _reader;
private readonly IBackendSink _sink;
private readonly int _channelId;
public InboundDispatcher(
BridgeState state, IRoomMessageReader reader, IBackendSink sink, int channelId)
{
_state = state;
_reader = reader;
_sink = sink;
_channelId = channelId;
}
public async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
string roomName;
try { roomName = await _state.EventQueue.DequeueAsync(ct); }
catch (OperationCanceledException) { return; }
// 1. 未启用的群直接跳过 (前端审批后才变 enabled)
if (!_state.Rooms.IsEnabled(roomName))
{
// TODO Phase C:调 backend /open/.../room-discovered 让前端"待审批"横幅出现
continue;
}
// 2. 切窗 + 读
IReadOnlyList<ParsedMessage> messages;
try
{
var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(3));
messages = await _reader.SwitchAndReadAsync(roomName, timeoutCts.Token);
}
catch (OperationCanceledException)
{
_state.Diagnostics.Record("uia", $"switch timeout: {roomName}");
continue;
}
catch (Exception ex)
{
_state.Diagnostics.Record("uia", $"read error {roomName}: {ex.Message}");
continue;
}
var roomId = _state.Rooms.GetRoomId(roomName);
// 3. 去重 + 推送
foreach (var msg in messages)
{
var msgId = MessageIdHasher.Build(roomId, msg.SenderName, msg.Content, msg.ReceivedAt);
if (_state.Rooms.HasSeen(roomName, msgId)) continue;
_state.Rooms.RememberSeen(roomName, msgId);
var payload = new InboundPayload(
ChannelId: _channelId,
RoomName: roomName,
SenderName: msg.SenderName,
MsgType: msg.Type.ToString().ToLowerInvariant(),
Content: msg.Content,
RawHash: msgId,
ReceivedAt: msg.ReceivedAt.ToString("O"),
AttachmentPath: msg.AttachmentPath,
AtList: msg.AtList,
QuotedRef: msg.QuotedRef is null
? null
: new QuotedRefDto(msg.QuotedRef.SenderName, msg.QuotedRef.Preview));
await _sink.IngestAsync(payload, ct);
}
}
}
}
- Step 3: 测试通过
Expected:Passed: 3。
- Step 4: Commit
git add packages/windows-tray/Neta.WeChatBridge/Workers/InboundDispatcher.cs \
packages/windows-tray/Neta.WeChatBridge.Tests/Workers/InboundDispatcherTests.cs
git commit -m "feat(bridge): add InboundDispatcher worker with dedup"
Task 20: UiaRoomMessageReader + BackendSinkAdapter + Program.cs 完整接线
Files:
- Create:
packages/windows-tray/Neta.WeChatBridge/Uia/UiaRoomMessageReader.cs - Create:
packages/windows-tray/Neta.WeChatBridge/Workers/BackendSinkAdapter.cs - Modify:
packages/windows-tray/Neta.WeChatBridge/Program.cs(完整替换 Plan A 的实现)
架构师审查 B3/B4/B5/B7/B8/B10 的综合修复:
- Program.cs 给完整文件 (不再用 partial diff)
- handshake 前置 于 BridgeState.InitRuntimeContainers,channelId / enabledRooms 从响应取
- UiaRoomMessageReader 用 ControlType 静态对象比对而非 LocalizedControlType (B8)
- SendEndpoint 入参的 cancellation 不传 UIA (避免半途打断)
- Step 1: 写 UiaRoomMessageReader (用 ControlType 对象比对)
Uia/UiaRoomMessageReader.cs:
using System.Windows.Automation;
using Neta.WeChatBridge.Config;
using Neta.WeChatBridge.Models;
using Neta.WeChatBridge.Workers;
namespace Neta.WeChatBridge.Uia;
public sealed class UiaRoomMessageReader : IRoomMessageReader
{
private readonly AutomationElement _root;
private readonly VersionProfile _profile;
private readonly MessageParser _parser = new();
public UiaRoomMessageReader(AutomationElement root, VersionProfile profile)
{
_root = root;
_profile = profile;
}
public Task<IReadOnlyList<ParsedMessage>> SwitchAndReadAsync(string roomName, CancellationToken ct)
{
// 1. 切到该群
var sessionList = _root.FindFirst(
TreeScope.Descendants,
new AndCondition(
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.List),
new PropertyCondition(AutomationElement.NameProperty, _profile.SessionListName)));
var item = sessionList?.FindFirst(TreeScope.Children,
new AndCondition(
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.ListItem),
new PropertyCondition(AutomationElement.NameProperty, roomName)));
if (item is null)
return Task.FromResult<IReadOnlyList<ParsedMessage>>(Array.Empty<ParsedMessage>());
if (item.TryGetCurrentPattern(SelectionItemPattern.Pattern, out var pat) &&
pat is SelectionItemPattern sel) sel.Select();
else item.SetFocus();
Thread.Sleep(200);
// 2. 读消息列表
var messageList = _root.FindFirst(
TreeScope.Descendants,
new AndCondition(
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.List),
new PropertyCondition(AutomationElement.NameProperty, _profile.MessageListName)));
if (messageList is null)
return Task.FromResult<IReadOnlyList<ParsedMessage>>(Array.Empty<ParsedMessage>());
var items = messageList.FindAll(TreeScope.Children,
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.ListItem));
var result = new List<ParsedMessage>(items.Count);
// 只取最后 10 条,首次发现群不灌历史
var start = Math.Max(0, items.Count - 10);
for (var i = start; i < items.Count; i++)
{
var uiNode = new AutomationUiNode(items[i]);
var parsed = _parser.Parse(uiNode);
if (parsed is not null) result.Add(parsed);
}
return Task.FromResult<IReadOnlyList<ParsedMessage>>(result);
}
/// <summary>
/// AutomationElement 转成 IUiNode。**ControlTypeName 用 ProgrammaticName**,
/// 不用 LocalizedControlType (中文 Windows 会返回"按钮""窗格"等本地化名称,
/// 与 MessageParser 期望的 "Button"/"Pane"/"Text"/"Image" 不匹配)。
/// ProgrammaticName 形如 "ControlType.Button" — 取冒号后部分。
/// </summary>
private sealed class AutomationUiNode : IUiNode
{
private readonly AutomationElement _el;
public AutomationUiNode(AutomationElement el) { _el = el; }
public string Name => _el.Current.Name ?? string.Empty;
public string ControlTypeName
{
get
{
var prog = _el.Current.ControlType?.ProgrammaticName ?? string.Empty;
var idx = prog.IndexOf('.');
return idx >= 0 ? prog[(idx + 1)..] : prog;
}
}
public IReadOnlyList<IUiNode> Children
{
get
{
var res = new List<IUiNode>();
foreach (AutomationElement c in _el.FindAll(TreeScope.Children, Condition.TrueCondition))
res.Add(new AutomationUiNode(c));
return res;
}
}
}
}
- Step 2: 写 BackendSinkAdapter
Workers/BackendSinkAdapter.cs:
using Neta.WeChatBridge.Backend;
using Neta.WeChatBridge.Models;
namespace Neta.WeChatBridge.Workers;
public sealed class BackendSinkAdapter : IBackendSink
{
private readonly BackendClient _client;
public BackendSinkAdapter(BackendClient client) { _client = client; }
public Task<bool> IngestAsync(InboundPayload payload, CancellationToken ct)
=> _client.IngestInboundAsync(payload, ct);
}
- Step 3: 完整替换 Program.cs (Plan A + Plan B 整合)
using System.Text;
using Neta.WeChatBridge.Backend;
using Neta.WeChatBridge.Config;
using Neta.WeChatBridge.Http;
using Neta.WeChatBridge.Runtime;
using Neta.WeChatBridge.Uia;
using Neta.WeChatBridge.Workers;
namespace Neta.WeChatBridge;
public class Program
{
public static async Task<int> Main(string[] args)
{
try { Console.OutputEncoding = Encoding.UTF8; } catch { }
// 1. 参数解析
BridgeRuntimeInfo info;
try { info = BridgeRuntimeInfo.Parse(args); }
catch (ArgumentException ex)
{
Console.Error.WriteLine($"[bridge] 参数错误: {ex.Message}");
return 2;
}
// 2. 加载版本 profile
var profilePath = Path.Combine(AppContext.BaseDirectory, "Config", "VersionProfiles.yaml");
if (!File.Exists(profilePath))
{
Console.Error.WriteLine($"[bridge] 找不到版本 profile: {profilePath}");
return 3;
}
VersionProfileLoader loader;
try { loader = VersionProfileLoader.LoadFromFile(profilePath); }
catch (Exception ex) when (ex is IOException ||
ex is YamlDotNet.Core.YamlException ||
ex is InvalidOperationException)
{
Console.Error.WriteLine($"[bridge] 版本 profile 解析失败: {ex.Message}");
return 3;
}
// 3. 定位微信
var wechatProcess = new WeChatProcessLocator().Locate();
if (wechatProcess is null)
{
Console.Error.WriteLine("[bridge] 未找到运行中的 WeChat.exe");
return 4;
}
// 4. 版本白名单
VersionProfile? profile;
try { profile = loader.MatchByVersion(wechatProcess.FileVersion); }
catch (InvalidOperationException ex)
{
Console.Error.WriteLine($"[bridge] 版本 profile 匹配错误: {ex.Message}");
return 5;
}
if (profile is null)
{
Console.Error.WriteLine($"[bridge] 该 PC 微信版本未经适配: {wechatProcess.FileVersion}");
return 6;
}
// 5. UIA 附着
var window = WeChatWindow.Attach(wechatProcess);
if (window is null)
{
Console.Error.WriteLine("[bridge] UIA 无法附着微信主窗口");
return 7;
}
// 6. 构造 BridgeState (Rooms/EventQueue 仍未初始化)
var state = new BridgeState
{
WechatVersion = wechatProcess.FileVersion,
ProfileName = profile.Version,
Wxid = window.Wxid,
Nickname = window.Nickname,
};
// 7. 优雅退出 hook
var shutdown = new GracefulShutdown();
shutdown.HookConsoleSignals();
// 8. handshake 必须先完成,拿到 channelId + enabledRooms 才能初始化 runtime 容器
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
var backendClient = new BackendClient(http, info.BackendUrl, info.TraySecret);
var bridgeBaseUrl = $"http://127.0.0.1:{info.BridgePort}";
HandshakeResult handshake;
try
{
handshake = await backendClient.HandshakeAsync(
state.Wxid, state.Nickname, state.WechatVersion, bridgeBaseUrl, shutdown.Token);
}
catch (OperationCanceledException) { return 0; }
if (!handshake.Ok || handshake.ChannelId is null)
{
// handshake 不通过/没拿到 channelId → bridge 仍然启 HTTP 提供 /health,
// 但不启动 dispatcher (没有 channelId 不能算 roomId)。
state.Diagnostics.Record("handshake",
handshake.Error ?? "missing channelId in handshake response");
Console.Error.WriteLine(
$"[bridge] handshake 失败或缺 channelId,不启动 dispatcher: {handshake.Error}");
}
else
{
state.InitRuntimeContainers(
handshake.ChannelId.Value,
handshake.EnabledRooms ?? Array.Empty<string>());
Console.WriteLine(
$"[bridge] handshake 成功 — channelId={handshake.ChannelId} " +
$"enabledRooms={handshake.EnabledRooms?.Count ?? 0}");
}
// 9. 构造 Sender + 启 HTTP server
var sender = new MessageSender(window.Root, profile);
var app = BridgeHttpServer.Build(info.BridgePort, info.TraySecret, state, sender);
try { await app.StartAsync(shutdown.Token); }
catch (Exception ex)
{
Console.Error.WriteLine($"[bridge] HTTP server 启动失败: {ex.Message}");
return 8;
}
Console.WriteLine($"[bridge] ready — port={info.BridgePort}");
// 10. 仅当 handshake 成功才启 dispatcher + watcher
Task? dispatcherTask = null;
SessionListWatcher? watcher = null;
if (state.IsRuntimeReady)
{
watcher = new SessionListWatcher(window.Root, profile,
roomName => state.EventQueue.Enqueue(roomName));
if (!watcher.Start())
{
Console.Error.WriteLine("[bridge] SessionListWatcher 启动失败");
watcher.Dispose();
watcher = null;
}
else
{
var reader = new UiaRoomMessageReader(window.Root, profile);
var sink = new BackendSinkAdapter(backendClient);
var dispatcher = new InboundDispatcher(state, reader, sink, state.ChannelId);
dispatcherTask = dispatcher.RunAsync(shutdown.Token);
}
}
// 11. 等退出
try
{
var tasks = new List<Task> { app.WaitForShutdownAsync(shutdown.Token) };
if (dispatcherTask is not null) tasks.Add(dispatcherTask);
await Task.WhenAll(tasks);
}
catch (OperationCanceledException) { /* normal */ }
catch (Exception ex)
{
state.Diagnostics.Record("runtime", ex.ToString());
Console.Error.WriteLine($"[bridge] runtime error: {ex.Message}");
return 9;
}
finally
{
watcher?.Dispose();
}
Console.WriteLine($"[bridge] shutdown reason={shutdown.Reason ?? "normal"}");
return 0;
}
}
- Step 4: 修 SendEndpoint 不传 UIA cancellation
修改 Task 15 (SendEndpoint) 实现里:
// UIA 发送过程中不接受半途取消(client 断开时按键已经在飞了,
// 取消会导致键盘残留状态)。用 None 而不是 ctx.RequestAborted。
var ok = await sender.SendTextAsync(body.RoomName, body.Text, CancellationToken.None);
这是覆盖修改,等执行到 Task 15 时直接用本版本代码 (Plan B 已修订)。
- Step 5: 编译全量
dotnet build packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests
Expected:全部 build succeeded + 所有单测 pass。
- Step 6: 手工冒烟 (Windows 开发机)
前提:Plan C 还没合并时,backend handshake 端点不存在 → handshake 失败 → bridge 仍能启 HTTP server,但不启 dispatcher。这是预期行为。
更完整的手工验证留给 Plan C 合并后(handshake 通)的真实端到端测试。Phase B 内部冒烟:
- 启 mock backend (PowerShell HttpListener 返回
{"channelId": 1, "enabledRooms": ["测试群"]}):
$listener = [System.Net.HttpListener]::new()
$listener.Prefixes.Add("http://127.0.0.1:7071/")
$listener.Start()
while ($listener.IsListening) {
$ctx = $listener.GetContext()
$body = '{"channelId":1,"enabledRooms":["测试群"]}'
$bytes = [Text.Encoding]::UTF8.GetBytes($body)
$ctx.Response.ContentType = "application/json"
$ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
$ctx.Response.Close()
}
- 启 bridge,验证日志中
handshake 成功 — channelId=1 enabledRooms=1 curl.exe -H "x-neta-tray-secret: test-sec" http://127.0.0.1:7702/rooms应返回{"rooms":["测试群"]}- 在"测试群"发一条消息 → bridge 控制台应有切窗动作 → mock backend 应收到 POST
/inbound - 同 minute 同内容再发一次 → bridge 不应推送 (LRU 去重)
- POST
/send {"roomName":"测试群","text":"hello"}→ 应在测试群里看到 "hello" - Ctrl+C bridge → 优雅退出。
- Step 7: Commit
git add packages/windows-tray/Neta.WeChatBridge/Program.cs \
packages/windows-tray/Neta.WeChatBridge/Uia/UiaRoomMessageReader.cs \
packages/windows-tray/Neta.WeChatBridge/Workers/BackendSinkAdapter.cs \
packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/SendEndpoint.cs
git commit -m "feat(bridge): wire handshake-first bootstrap with dispatcher + watcher"
自检 (Self-Review)
1. Spec 覆盖:
| Spec 章节 | 覆盖 Task |
|---|---|
| "消息采集与图片处理 · UIA 能读到的群消息字段" | Task 10 (MessageParser) |
| "图片与文件的获取路径 · DAT-XOR 解密" | Task 5 + 6 |
| "发送回复" | Task 11 + 12 + 15 |
| "身份控制 replyIdentity" | bridge 不感知,Plan C 在 finalContent 加前缀后调 /send |
| "发送者 wxid 获取" | Plan A Task 8 (留空,靠 senderName 前缀) |
| "去重 / 首次发现" | Task 2 (MessageIdHasher) + Task 3 (RoomRegistry.RememberSeen) + Task 19 (dispatcher) + Task 20 (reader 只取最后 10 条) |
| "多群监听策略 · 事件源 / 切窗调度" | Task 4 (RoomEventQueue) + Task 18 (SessionListWatcher) + Task 19 |
| "Bridge ↔ Backend HTTP · /rooms /send /enable-room /disable-room" | Task 14 + 15 + 16 |
| "Bridge ↔ Backend HTTP · POST /inbound" | Task 8 |
| "Bridge 冷启动序列 · 步骤 4-6" | Task 18 + 19 + 20 |
| "重启不回灌" | Task 20 (UiaRoomMessageReader start = items.Count - 10) |
| "handshake 返回已启用群列表 + channelId" | Task 8 + Task 13 + Task 20 |
Spec 中未覆盖(设计上留到 Plan C/D):
- Backend 侧 inbound 入库、session_entry 触发、routeInboundMessage 分流 → Plan C
- 语音/视频 (v2)
- 前端 UI / 待审批横幅 / 归档页 → Plan D
- Tray 拉 bridge / 崩溃自愈 → Plan D (Phase E)
2. Placeholder 扫描: Task 19 dispatcher 里有一个 // TODO Phase C 注释 (room-discovered 端点需要 Plan C 的后端实现),这是刻意留的跨 plan hook,不是占位代码;代码是 no-op continue。
3. 类型一致性: IMessageSender / IRoomMessageReader / IBackendSink 在定义后所有使用点字段名、返回值一致。ParsedMessage / InboundPayload 的字段在 MessageParser / InboundDispatcher / BackendClient 三处引用一致。HandshakeResult 在 Plan A 与 Plan B Task 8 都是同一类型,Plan B 扩展了字段但保持向后兼容。
4. 跨 Plan 衔接契约:
POST /open/netaclaw/channel/uia/handshake响应必须包含{ channelId: number, enabledRooms: string[] }→ Plan C 必须实现POST /open/netaclaw/channel/uia/inboundbody schema (channelId,roomName,senderName,msgType,content,rawHash,receivedAt,attachmentPath?,atList?,quotedRef?) → Plan C controller DTO 必须匹配replyIdentity(silent/ai_prefix) 在 bridge 不感知,由 Plan C 后端在 finalContent 前拼【AI 助手】后再调POST /send- 真实 channelId 从 handshake 拿,不再走启动参数传递
5. 架构师交叉审查记录 (2026-05-09):
本 plan 在初稿后做了一轮系统架构师审查,修复了 10 个问题:
| # | 严重度 | 问题 | 修复 |
|---|---|---|---|
| B5/B6/B7/B10 | 🔴 高 | channelId 占位 0 + handshake 后重建 BridgeState 导致已用 roomId 漂移 | BridgeState.InitRuntimeContainers(channelId, enabledRooms) 单次注入;handshake 必须在容器初始化前完成;HandshakeResult 扩展 ChannelId + EnabledRooms (Task 8/13/20) |
| B8 | 🔴 高 | LocalizedControlType 在中文 Windows 返回"按钮""窗格",MessageParser 永不匹配 |
改用 ProgrammaticName 取冒号后部分 (Task 20 AutomationUiNode) |
| B2 | 🟠 中 | Automation.RemoveAllEventHandlers() 全局卸载会误删其它订阅者 |
改成精确 RemoveStructureChangedEventHandler / RemoveAutomationPropertyChangedEventHandler (Task 18) |
| B9 | 🟠 中 | endpoint Task (13/14/15) 在 BridgeState 扩展 (16) 之前 — 反 TDD | 重排:Task 13 先扩展 BridgeState + 写单测,然后才写 endpoint;endpoint 测试 setup 调 InitRuntimeContainers |
| B3/B4 | 🟠 中 | Program.cs / BridgeState 用 partial diff 展示,实施者易 merge 错 | 全部给完整文件替换 (Task 13 / Task 20) |
| B1 | 🟡 低 | RoomEventQueue 多消费者唤醒语义不严格 | 在"关键约束"标注单消费者模型 |
| SendCt | 🟡 低 | SendEndpoint 把 ctx.RequestAborted 传 UIA,client 断开会导致键盘按键残留 |
改 CancellationToken.None (Task 20 Step 4) |
| ParserCN | 🟡 低 | MessageParser AtPattern 不覆盖中文标点边界 | 在"关键约束"标注 MVP 限制,v2 再补 |
| GB1 | 文档 | dispatcher 里 // TODO Phase C 是刻意 hook,需在 plan 里说明 |
自检 §2 中说明 |
Execution Handoff
Plan B 写作完成,保存至 docs/superpowers/plans/2026-05-09-wechat-uia-b-bridge-runtime.md。
前置依赖:Plan A 必须已合并 (Plan B Task 8 修改 HandshakeResult 是向 Plan A 类型的扩展)。
下一步:写 Plan C (Backend) 之后再决定从哪个开始实施。