# 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|sender|content|receivedAt:minute)` 合成 msgId | | `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: 写失败测试** ```csharp 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(() => RoomIdHasher.Build(1, "")); } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd packages/windows-tray dotnet test Neta.WeChatBridge.Tests --filter "FullyQualifiedName~RoomIdHasherTests" ``` - [ ] **Step 3: 实现** ```csharp 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** ```bash 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: 写失败测试** ```csharp 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: 实现** ```csharp 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** ```bash 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: 写失败测试** ```csharp 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: 实现** ```csharp namespace Neta.WeChatBridge.Uia; public sealed class RoomRegistry { private readonly int _channelId; private readonly int _seenCapacityPerRoom; private readonly object _lock = new(); private readonly HashSet _enabled = new(StringComparer.Ordinal); // 每群一个 LRU:LinkedList head=最新,tail=最旧;HashSet 加速查询 private readonly Dictionary> _seenOrder = new(); private readonly Dictionary> _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 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(); _seenOrder[roomName] = list; _seenSet[roomName] = new HashSet(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** ```bash 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: 写失败测试** ```csharp 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(() => task); } [Fact] public void OnDrop_callback_fires_when_oldest_evicted() { var dropped = new List(); 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: 实现** ```csharp using System.Threading.Channels; namespace Neta.WeChatBridge.Uia; public sealed class RoomEventQueue { private readonly int _capacity; private readonly object _lock = new(); private readonly LinkedList _order = new(); private readonly HashSet _set = new(StringComparer.Ordinal); private readonly Action? _onDrop; private TaskCompletionSource? _waiter; public RoomEventQueue(int capacity, Action? 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? 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 DequeueAsync(CancellationToken ct) { while (true) { ct.ThrowIfCancellationRequested(); TaskCompletionSource waitFor; lock (_lock) { if (_order.Count > 0) { var head = _order.First!.Value; _order.RemoveFirst(); _set.Remove(head); return head; } _waiter ??= new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); waitFor = _waiter; } using var reg = ct.Register(() => waitFor.TrySetCanceled(ct)); await waitFor.Task; } } } ``` - [ ] **Step 4: 测试通过** Expected:`Passed: 7`。 - [ ] **Step 5: Commit** ```bash 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: 写失败测试** ```csharp 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())); } [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: 实现** ```csharp 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 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 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** ```bash 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: 写失败测试** ```csharp 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//YYYY-MM/.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: 实现** ```csharp 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** ```bash 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`: ```csharp 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? AtList = null, QuotedRef? QuotedRef = null, string? AttachmentPath = null ); ``` `Models/InboundPayload.cs`: ```csharp 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? AtList = null, QuotedRefDto? QuotedRef = null ); public sealed record QuotedRefDto(string SenderName, string Preview); ``` - [ ] **Step 2: 编译** ```bash dotnet build packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj ``` Expected: Build succeeded。 - [ ] **Step 3: Commit** ```bash 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 同时补齐两个缺口: > 1. Handshake 响应原本只返回 `Ok`,Phase B 需要拿到 **真实 channelId + 已启用群列表**,才能真正启动 dispatcher (架构师审查 B5/B6/B7/B10)。 > 2. 新增 `IngestInboundAsync` 推送入站消息给 backend。 - [ ] **Step 1: 写失败测试** **文件 1** — `BackendClientInboundTests.cs`: ```csharp 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 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 SendAsync(HttpRequestMessage r, CancellationToken c) => throw new HttpRequestException("connect refused"); } } ``` **文件 2** — `BackendClientHandshakeExtendedTests.cs`: ```csharp 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 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`: ```csharp 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? 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 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(); try { using var doc = await resp.Content.ReadFromJsonAsync(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 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)** ```bash 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** ```bash 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: 实现** ```csharp namespace Neta.WeChatBridge.Uia; /// /// 抽象一个可枚举的 UI 节点 (UIA / 测试 mock 共享接口)。 /// 只暴露 MessageParser 需要的最小集合。 /// public interface IUiNode { string Name { get; } string ControlTypeName { get; } // "Text" / "Pane" / "Button" / "Image" / "ListItem" IReadOnlyList Children { get; } } public sealed class StaticUiNode : IUiNode { public string Name { get; init; } = string.Empty; public string ControlTypeName { get; init; } = string.Empty; public IReadOnlyList Children { get; init; } = Array.Empty(); } ``` - [ ] **Step 2: Commit** ```bash 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: 写失败测试** ```csharp 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: 实现** ```csharp using System.Text.RegularExpressions; using Neta.WeChatBridge.Models; namespace Neta.WeChatBridge.Uia; public sealed class MessageParser { // @ — alias 必须前后有边界(空白/字符串边界/标点) private static readonly Regex AtPattern = new( @"(?:^|\s)@([^\s@,;:!?]+)(?=$|[\s,;:!?])", RegexOptions.Compiled); // 引用消息块:形如 ": " (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(); 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** ```bash 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: 实现** ```csharp using System.Runtime.InteropServices; namespace Neta.WeChatBridge.Uia; /// /// User32.SendInput 包装。仅支持 KEYBOARD INPUT。 /// 用于把 Unicode 字符送入当前焦点输入框 (绕过 SendKeys 的 ASCII 限制)。 /// 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(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()); } 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()); } 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** ```bash 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`: ```csharp namespace Neta.WeChatBridge.Uia; public interface IMessageSender { /// /// 切到目标群窗口 → 输入文本 → 回车。 /// 返回是否发送成功 (基于"最后一条自己发的消息时间戳 > 发送前"判断)。 /// Task SendTextAsync(string roomName, string text, CancellationToken ct); } ``` - [ ] **Step 2: 写实现 (UIA + SendInput)** `Uia/MessageSender.cs`: ```csharp 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 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`: ```csharp 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 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** ```bash 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** ```csharp using Neta.WeChatBridge.Uia; namespace Neta.WeChatBridge; /// /// 启动时填充、后续大部分只读的 bridge 状态容器。 /// Rooms / EventQueue 字段为 null 时表示尚未完成 handshake; /// Program.cs 必须在 handshake 成功后调 一次性注入。 /// 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; /// handshake 后由 Program.cs 注入的 backend channelId。 public int ChannelId { get; private set; } /// handshake 后才被注入;之前访问抛 public RoomRegistry Rooms => _rooms ?? throw new InvalidOperationException("BridgeState.Rooms 未初始化 (handshake 未完成)"); /// handshake 后才被注入。 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; /// /// handshake 成功后调一次;重复调用抛异常。 /// 事件订阅 / dispatcher / endpoint 只能在此之后依赖这些字段。 /// public void InitRuntimeContainers(int channelId, IEnumerable 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 _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 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`: ```csharp 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(() => _ = s.Rooms); Assert.Throws(() => _ = 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()); Assert.Throws(() => s.InitRuntimeContainers(2, Array.Empty())); } } ``` - [ ] **Step 3: 测试通过** Expected:`Passed: 3`。Plan A 的 `HealthEndpointTests` 也继续通过(因为它不触碰新字段)。 - [ ] **Step 4: Commit** ```bash 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: 写失败测试** ```csharp 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("/rooms"); Assert.NotNull(body); Assert.Equal(2, body!.Rooms.Count); Assert.Contains("产品群", body.Rooms); } public sealed class RoomsResponse { public List Rooms { get; set; } = new(); } } ``` > 注意:本测试假定 `BridgeState.Rooms` 字段已存在。需要先做 Task 16 (修改 BridgeState)。本 Task 暂时跳过测试运行,先写完代码,Task 16 完成后回来验证。 - [ ] **Step 2: 实现** ```csharp 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 后跑通)** ```bash 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: 写失败测试** ```csharp 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 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: 实现** ```csharp 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** ```bash 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: 写失败测试** ```csharp 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: 实现** ```csharp 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** ```bash 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** ```csharp 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(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 被阻塞的测试** ```bash 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(...)` 之前插入: ```csharp state.InitRuntimeContainers(1, Array.Empty()); ``` 在 `EnableDisableEndpointTests` 的两个测试里同样先调 `state.InitRuntimeContainers(1, Array.Empty());`。 Expected:`Passed: 3`。 - [ ] **Step 3: Commit** ```bash 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: 实现** ```csharp 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 _onRoomEvent; private AutomationElement? _sessionList; private StructureChangedEventHandler? _structureHandler; private AutomationPropertyChangedEventHandler? _nameHandler; private bool _disposed; public SessionListWatcher( AutomationElement root, VersionProfile profile, Action 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** ```bash 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: 写失败测试** ```csharp 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> Map = new(); public List SwitchedTo = new(); public Task> SwitchAndReadAsync(string roomName, CancellationToken ct) { SwitchedTo.Add(roomName); return Task.FromResult>( Map.GetValueOrDefault(roomName) ?? new List()); } } private sealed class FakeBackendSink : IBackendSink { public List Sent = new(); public Task 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()); return state; } [Fact] public async Task Dispatcher_skips_rooms_not_enabled() { var state = NewState(1); var reader = new FakeReader(); reader.Map["未启用群"] = new List { 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 { 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 { 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`: ```csharp using Neta.WeChatBridge.Models; using Neta.WeChatBridge.Uia; namespace Neta.WeChatBridge.Workers; public interface IRoomMessageReader { Task> SwitchAndReadAsync(string roomName, CancellationToken ct); } public interface IBackendSink { Task 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 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** ```bash 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`: ```csharp 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> 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>(Array.Empty()); 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>(Array.Empty()); var items = messageList.FindAll(TreeScope.Children, new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.ListItem)); var result = new List(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>(result); } /// /// AutomationElement 转成 IUiNode。**ControlTypeName 用 ProgrammaticName**, /// 不用 LocalizedControlType (中文 Windows 会返回"按钮""窗格"等本地化名称, /// 与 MessageParser 期望的 "Button"/"Pane"/"Text"/"Image" 不匹配)。 /// ProgrammaticName 形如 "ControlType.Button" — 取冒号后部分。 /// 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 Children { get { var res = new List(); foreach (AutomationElement c in _el.FindAll(TreeScope.Children, Condition.TrueCondition)) res.Add(new AutomationUiNode(c)); return res; } } } } ``` - [ ] **Step 2: 写 BackendSinkAdapter** `Workers/BackendSinkAdapter.cs`: ```csharp 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 IngestAsync(InboundPayload payload, CancellationToken ct) => _client.IngestInboundAsync(payload, ct); } ``` - [ ] **Step 3: 完整替换 Program.cs (Plan A + Plan B 整合)** ```csharp 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 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()); 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 { 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) 实现里: ```csharp // UIA 发送过程中不接受半途取消(client 断开时按键已经在飞了, // 取消会导致键盘残留状态)。用 None 而不是 ctx.RequestAborted。 var ok = await sender.SendTextAsync(body.RoomName, body.Text, CancellationToken.None); ``` > 这是覆盖修改,等执行到 Task 15 时直接用本版本代码 (Plan B 已修订)。 - [ ] **Step 5: 编译全量** ```bash 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 内部冒烟: 1. 启 mock backend (PowerShell HttpListener 返回 `{"channelId": 1, "enabledRooms": ["测试群"]}`): ```powershell $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() } ``` 2. 启 bridge,验证日志中 `handshake 成功 — channelId=1 enabledRooms=1` 3. `curl.exe -H "x-neta-tray-secret: test-sec" http://127.0.0.1:7702/rooms` 应返回 `{"rooms":["测试群"]}` 4. 在"测试群"发一条消息 → bridge 控制台应有切窗动作 → mock backend 应收到 POST `/inbound` 5. 同 minute 同内容再发一次 → bridge 不应推送 (LRU 去重) 6. POST `/send {"roomName":"测试群","text":"hello"}` → 应在测试群里看到 "hello" 7. Ctrl+C bridge → 优雅退出。 - [ ] **Step 7: Commit** ```bash 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/inbound` body 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) 之后再决定从哪个开始实施。