GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-05-09-wechat-uia-b-bridge-runtime.md
2026-05-20 21:39:12 +08:00

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.SendInputSystem.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 + 已启用群列表,才能创建 RoomRegistrySessionListWatcherBridgeStateRooms / 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 同时补齐两个缺口:

  1. Handshake 响应原本只返回 Ok,Phase B 需要拿到 真实 channelId + 已启用群列表,才能真正启动 dispatcher (架构师审查 B5/B6/B7/B10)。
  2. 新增 IngestInboundAsync 推送入站消息给 backend。
  • Step 1: 写失败测试

文件 1BackendClientInboundTests.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");
    }
}

文件 2BackendClientHandshakeExtendedTests.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 内部冒烟:

  1. 启 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()
}
  1. 启 bridge,验证日志中 handshake 成功 — channelId=1 enabledRooms=1
  2. curl.exe -H "x-neta-tray-secret: test-sec" http://127.0.0.1:7702/rooms 应返回 {"rooms":["测试群"]}
  3. 在"测试群"发一条消息 → bridge 控制台应有切窗动作 → mock backend 应收到 POST /inbound
  4. 同 minute 同内容再发一次 → bridge 不应推送 (LRU 去重)
  5. POST /send {"roomName":"测试群","text":"hello"} → 应在测试群里看到 "hello"
  6. 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/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) 之后再决定从哪个开始实施。