3237 lines
108 KiB
Markdown
3237 lines
108 KiB
Markdown
|
|
# WeChat UIA Phase B · Bridge 完整能力 Implementation Plan
|
||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** 在 Plan A 骨架之上补齐 Bridge 真正的业务能力——多群切窗事件订阅、图片 / 文件附件采集 (含 DAT-XOR 解密)、消息发送 (含 @ / 身份前缀)、@ / 引用 / 图片消息的结构化解析、向 backend 主动 POST inbound。本 plan 完成后 Bridge 可独立完成端到端"群消息 → backend"的传输,但仍**未接通 backend 真实业务** (留给 Plan C)。
|
||
|
|
|
||
|
|
**Architecture:** 在 `BridgeState` 上挂载新组件:`RoomRegistry`(已启用群名 → roomId 缓存)、`RoomEventQueue`(切窗串行队列 + 去重 + 背压)、`SessionListWatcher`(订阅 UIA `StructureChanged` + `NameProperty Changed`)、`AttachmentExtractor`(WeChat Files 目录扫描 + DAT-XOR 解密)、`MessageSender`(SetFocus + SendKeys + 时间戳确认)、`InboundDispatcher`(把读到的消息推 backend)。事件源是 `SessionListWatcher`,worker thread 串行 dequeue → 切窗 → 读消息 → 提取附件 → POST backend。所有纯逻辑严格 TDD;UIA 交互部分提供单元覆盖 + 手工验证清单。
|
||
|
|
|
||
|
|
**Tech Stack:** .NET 8 / System.Windows.Automation / WindowsInput.SendKeys (走 `User32.SendInput` 或 `System.Windows.Forms.SendKeys`) / xUnit。
|
||
|
|
|
||
|
|
**Spec:** `docs/superpowers/specs/2026-05-09-wechat-uia-channel-design.md` (主体节"消息采集与图片处理" / "多群监听策略" / "Bridge 暴露 HTTP")
|
||
|
|
|
||
|
|
**前置依赖:** Plan A (`2026-05-09-wechat-uia-a-bridge-skeleton.md`) 必须已完成并合并。Plan B 不修改 Plan A 已写文件的导出契约,只是扩展。
|
||
|
|
|
||
|
|
**关键约束:**
|
||
|
|
- **事件源 = SessionListWatcher (会话列表),不是 ChatBoxReader (聊天框)**。这是 spec 写明的;切窗只是事件触发后的动作。
|
||
|
|
- **切窗串行**——同一时刻只能有一个 worker 操作 UIA。`RoomEventQueue` 内部串行,worker thread 单线程跑 (**单消费者模型**,DequeueAsync 不保证多消费者正确)。
|
||
|
|
- **不切回原窗口**——切到目标群读消息后停在那里,用户再想看别的群手动切。
|
||
|
|
- **handshake 优先于事件订阅**——必须先完成 backend handshake 拿到真实 `channelId` + 已启用群列表,才能创建 `RoomRegistry` 与 `SessionListWatcher`。`BridgeState` 的 `Rooms` / `EventQueue` 字段在 handshake 完成前为 null,等待注入后原子可见 (架构师审查 B5/B6/B7/B10)。
|
||
|
|
- **UIA ControlType 对比用 ProgrammaticName 或静态 ControlType 对象**,不要用 `LocalizedControlType` (中文 Windows 会返回本地化字符串,与 parser 期望的 `"Text"/"Pane"/"Button"/"Image"` 不匹配) (架构师审查 B8)。
|
||
|
|
- **去重双层**:`msgId` 在 backend SQLite 上 UNIQUE (Plan C 实现),Bridge 这一层在 `RoomRegistry` 上保留每群"最新已读 msgId 集合 (LRU 200)" 防止切窗时重复读已经处理过的消息。
|
||
|
|
- **首次发现群不 backfill**——只认"现在能看到的最新一条"为起点,避免给 agent 灌历史。
|
||
|
|
- **重启不回灌**——Bridge 重启后只处理重启后新出现的事件。
|
||
|
|
- **DAT-XOR 解密走纯字节运算**(不调外部 DLL),公开算法:每字节异或一个常量,key 通过识别已知图片格式头反推。
|
||
|
|
- **键盘发送优先 `SendInput` (User32)**,因为 `System.Windows.Forms.SendKeys` 对 PUA / 非 ASCII 字符不稳。但 .NET 8 console 项目调 SendInput 需 P/Invoke 包装。
|
||
|
|
- **UIA 事件订阅的 Dispose 用精确取消** (`Automation.RemoveStructureChangedEventHandler` / `RemoveAutomationPropertyChangedEventHandler`),不用全局 `RemoveAllEventHandlers()` (架构师审查 B2)。
|
||
|
|
- **不实现**:语音转文字 (v2)、视频下载 (v2)、跨群人物志 (v2)、bridge 主动发图片到群 (MVP 不支持)、前端 UI (Plan D)、SQLite 归档表 (Plan C)、跨渠道丢弃 (Plan C)。
|
||
|
|
- 沿用 Plan A 的 Windows-only 约束、UTF-8 终端、UIA test SkippableFact 模式。
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 文件结构
|
||
|
|
|
||
|
|
### 新增 — `Neta.WeChatBridge/`
|
||
|
|
|
||
|
|
| 文件 | 责任 |
|
||
|
|
|---|---|
|
||
|
|
| `Uia/RoomRegistry.cs` | 已启用群 (roomName → roomId 稳定 id) 内存缓存 + 每群 last-msgId LRU |
|
||
|
|
| `Uia/RoomEventQueue.cs` | 切窗事件串行队列:enqueue 合并去重、背压丢弃、3 秒超时 |
|
||
|
|
| `Uia/SessionListWatcher.cs` | UIA `StructureChangedEventHandler` + `AutomationPropertyChangedEventHandler` 订阅会话列表 |
|
||
|
|
| `Uia/MessageParser.cs` | 把单个 `ListItem` UIA 控件树解析成结构化 `ParsedMessage` (text/image/file/quote/at) |
|
||
|
|
| `Uia/AttachmentExtractor.cs` | WeChat Files 目录扫描 + DAT-XOR 解密 + 拷贝到 dataDir |
|
||
|
|
| `Uia/DatXorDecoder.cs` | 纯算法:从 .dat 头字节反推 XOR key,解密整文件 |
|
||
|
|
| `Uia/MessageSender.cs` | 切群 → SetFocus 输入框 → 输入文本 → Enter → 时间戳确认 |
|
||
|
|
| `Uia/SendInput.cs` | `User32.SendInput` P/Invoke 包装 (Unicode 字符发送) |
|
||
|
|
| `Uia/MessageIdHasher.cs` | 纯函数:`sha256(roomId|sender|content|receivedAt:minute)` 合成 msgId |
|
||
|
|
| `Uia/RoomIdHasher.cs` | 纯函数:`${channelId}:room:${sha1(roomName)}` 生成 roomId |
|
||
|
|
| `Backend/BackendClient.cs` (扩展) | 新增 `IngestInboundAsync` POST /open/netaclaw/channel/uia/inbound |
|
||
|
|
| `Workers/InboundDispatcher.cs` | 消费 RoomEventQueue → 切窗 → 读消息 → 抽附件 → 调 BackendClient |
|
||
|
|
| `Http/Endpoints/SendEndpoint.cs` | `POST /send { roomName, text, atList? }` |
|
||
|
|
| `Http/Endpoints/RoomsEndpoint.cs` | `GET /rooms` 返回当前可见群列表 |
|
||
|
|
| `Http/Endpoints/EnableDisableEndpoint.cs` | `POST /enable-room` / `POST /disable-room` 修改 RoomRegistry |
|
||
|
|
| `Models/ParsedMessage.cs` | `{ Type, SenderName, Content, AtList, QuotedRef, AttachmentPath, ReceivedAt }` |
|
||
|
|
| `Models/InboundPayload.cs` | POST 给 backend 的 body record |
|
||
|
|
|
||
|
|
### 新增 — `Neta.WeChatBridge.Tests/`
|
||
|
|
|
||
|
|
| 文件 | 覆盖 |
|
||
|
|
|---|---|
|
||
|
|
| `Uia/RoomEventQueueTests.cs` | enqueue 合并 / 同名跨秒去重 / 背压丢 oldest 保 newest / 队列上限 |
|
||
|
|
| `Uia/RoomRegistryTests.cs` | enable/disable / list / 已启用群消息 LRU 上限 |
|
||
|
|
| `Uia/MessageIdHasherTests.cs` | hash 稳定 / 跨分钟边界变化 / 不同发送者 hash 不同 / 已知向量 |
|
||
|
|
| `Uia/RoomIdHasherTests.cs` | 同 channelId 同 roomName → 同 roomId / 不同 channelId → 不同 roomId / 已知向量 |
|
||
|
|
| `Uia/DatXorDecoderTests.cs` | 已知 JPEG header (0xFF 0xD8) 反推 key / PNG header / GIF header / 头部不识别返回 null |
|
||
|
|
| `Uia/AttachmentExtractorTests.cs` | 模拟 dat 文件 → 解密 → 输出到 dataDir / 无法识别时 fallback 失败处理 |
|
||
|
|
| `Uia/MessageParserTests.cs` | 用伪造的 AutomationElement 树 (mock) 测试 text/image/quote 解析 |
|
||
|
|
| `Backend/BackendClientInboundTests.cs` | inbound 请求结构、retry 策略、超时 |
|
||
|
|
| `Workers/InboundDispatcherTests.cs` | 用 mock RoomEventQueue + mock BackendClient 验证编排 |
|
||
|
|
| `Http/SendEndpointTests.cs` | 鉴权 / 参数校验 / 调用 MessageSender mock |
|
||
|
|
| `Http/RoomsEndpointTests.cs` | 返回当前 RoomRegistry 快照 |
|
||
|
|
|
||
|
|
### 修改
|
||
|
|
|
||
|
|
| 文件 | 改动 |
|
||
|
|
|---|---|
|
||
|
|
| `Neta.WeChatBridge/Program.cs` | 启动后构造 RoomRegistry / RoomEventQueue / SessionListWatcher / InboundDispatcher worker 并 hook 到 BridgeState;`/health` 拓展返回队列长度 |
|
||
|
|
| `Neta.WeChatBridge/BridgeState.cs` | 新增 `RoomRegistry Rooms` / `RoomEventQueue EventQueue` 字段 |
|
||
|
|
| `Neta.WeChatBridge/Http/BridgeHttpServer.cs` | 注册 SendEndpoint / RoomsEndpoint / EnableDisableEndpoint |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 1 · 纯函数层 (hash + queue + registry)
|
||
|
|
|
||
|
|
### Task 1: RoomIdHasher
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Uia/RoomIdHasher.cs`
|
||
|
|
- Test: `packages/windows-tray/Neta.WeChatBridge.Tests/Uia/RoomIdHasherTests.cs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: 写失败测试**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Xunit;
|
||
|
|
using Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public class RoomIdHasherTests
|
||
|
|
{
|
||
|
|
[Fact]
|
||
|
|
public void Build_returns_stable_id_for_same_inputs()
|
||
|
|
{
|
||
|
|
var a = RoomIdHasher.Build(channelId: 7, roomName: "产品研发群");
|
||
|
|
var b = RoomIdHasher.Build(channelId: 7, roomName: "产品研发群");
|
||
|
|
Assert.Equal(a, b);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Build_differs_when_channel_id_differs()
|
||
|
|
{
|
||
|
|
var a = RoomIdHasher.Build(7, "产品研发群");
|
||
|
|
var b = RoomIdHasher.Build(8, "产品研发群");
|
||
|
|
Assert.NotEqual(a, b);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Build_differs_when_room_name_differs()
|
||
|
|
{
|
||
|
|
var a = RoomIdHasher.Build(7, "群A");
|
||
|
|
var b = RoomIdHasher.Build(7, "群B");
|
||
|
|
Assert.NotEqual(a, b);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Build_format_is_channel_room_sha1()
|
||
|
|
{
|
||
|
|
var id = RoomIdHasher.Build(42, "test");
|
||
|
|
Assert.StartsWith("42:room:", id);
|
||
|
|
// sha1 hex 长度 40
|
||
|
|
Assert.Equal("42:room:".Length + 40, id.Length);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Build_throws_on_empty_room_name()
|
||
|
|
{
|
||
|
|
Assert.Throws<ArgumentException>(() => RoomIdHasher.Build(1, ""));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: 运行测试确认失败**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cd packages/windows-tray
|
||
|
|
dotnet test Neta.WeChatBridge.Tests --filter "FullyQualifiedName~RoomIdHasherTests"
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: 实现**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using System.Security.Cryptography;
|
||
|
|
using System.Text;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public static class RoomIdHasher
|
||
|
|
{
|
||
|
|
public static string Build(int channelId, string roomName)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(roomName))
|
||
|
|
throw new ArgumentException("roomName 不能为空", nameof(roomName));
|
||
|
|
var bytes = Encoding.UTF8.GetBytes(roomName);
|
||
|
|
var hash = SHA1.HashData(bytes);
|
||
|
|
var hex = Convert.ToHexString(hash).ToLowerInvariant();
|
||
|
|
return $"{channelId}:room:{hex}";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: 测试通过**
|
||
|
|
|
||
|
|
Expected:`Passed: 5`。
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Uia/RoomIdHasher.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/RoomIdHasherTests.cs
|
||
|
|
git commit -m "feat(bridge): add RoomIdHasher stable id generator"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 2: MessageIdHasher
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Uia/MessageIdHasher.cs`
|
||
|
|
- Test: `packages/windows-tray/Neta.WeChatBridge.Tests/Uia/MessageIdHasherTests.cs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: 写失败测试**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Xunit;
|
||
|
|
using Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public class MessageIdHasherTests
|
||
|
|
{
|
||
|
|
private static DateTimeOffset T(int year, int mo, int d, int h, int m, int s) =>
|
||
|
|
new(year, mo, d, h, m, s, TimeSpan.Zero);
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Build_is_stable_for_same_inputs()
|
||
|
|
{
|
||
|
|
var a = MessageIdHasher.Build("room1", "alice", "hi", T(2026,5,9,12,30,15));
|
||
|
|
var b = MessageIdHasher.Build("room1", "alice", "hi", T(2026,5,9,12,30,15));
|
||
|
|
Assert.Equal(a, b);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Build_collapses_seconds_within_same_minute()
|
||
|
|
{
|
||
|
|
// spec 要求精度到分钟
|
||
|
|
var a = MessageIdHasher.Build("room1", "alice", "hi", T(2026,5,9,12,30,15));
|
||
|
|
var b = MessageIdHasher.Build("room1", "alice", "hi", T(2026,5,9,12,30,59));
|
||
|
|
Assert.Equal(a, b);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Build_changes_across_minute_boundary()
|
||
|
|
{
|
||
|
|
var a = MessageIdHasher.Build("room1", "alice", "hi", T(2026,5,9,12,30,59));
|
||
|
|
var b = MessageIdHasher.Build("room1", "alice", "hi", T(2026,5,9,12,31,0));
|
||
|
|
Assert.NotEqual(a, b);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Theory]
|
||
|
|
[InlineData("room2", "alice", "hi")]
|
||
|
|
[InlineData("room1", "bob", "hi")]
|
||
|
|
[InlineData("room1", "alice", "hello")]
|
||
|
|
public void Build_differs_when_any_field_differs(string room, string sender, string content)
|
||
|
|
{
|
||
|
|
var baseId = MessageIdHasher.Build("room1", "alice", "hi", T(2026,5,9,12,30,15));
|
||
|
|
var other = MessageIdHasher.Build(room, sender, content, T(2026,5,9,12,30,15));
|
||
|
|
Assert.NotEqual(baseId, other);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Build_returns_64_char_hex()
|
||
|
|
{
|
||
|
|
var id = MessageIdHasher.Build("r", "s", "c", T(2026,1,1,0,0,0));
|
||
|
|
Assert.Equal(64, id.Length);
|
||
|
|
Assert.Matches("^[0-9a-f]+$", id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: 运行测试确认失败**
|
||
|
|
|
||
|
|
- [ ] **Step 3: 实现**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using System.Security.Cryptography;
|
||
|
|
using System.Text;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public static class MessageIdHasher
|
||
|
|
{
|
||
|
|
public static string Build(
|
||
|
|
string roomId, string senderName, string content, DateTimeOffset receivedAt)
|
||
|
|
{
|
||
|
|
// 精度到分钟,与 spec 一致
|
||
|
|
var minute = receivedAt.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm");
|
||
|
|
var raw = $"{roomId}|{senderName}|{content}|{minute}";
|
||
|
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
|
||
|
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: 测试通过**
|
||
|
|
|
||
|
|
Expected:`Passed: 7` (1+1+1+3+1)。
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Uia/MessageIdHasher.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/MessageIdHasherTests.cs
|
||
|
|
git commit -m "feat(bridge): add MessageIdHasher with minute-precision dedup"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 3: RoomRegistry
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Uia/RoomRegistry.cs`
|
||
|
|
- Test: `packages/windows-tray/Neta.WeChatBridge.Tests/Uia/RoomRegistryTests.cs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: 写失败测试**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Xunit;
|
||
|
|
using Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public class RoomRegistryTests
|
||
|
|
{
|
||
|
|
[Fact]
|
||
|
|
public void Enable_adds_room_and_marks_enabled()
|
||
|
|
{
|
||
|
|
var reg = new RoomRegistry(channelId: 1);
|
||
|
|
reg.EnableRoom("产品群");
|
||
|
|
Assert.True(reg.IsEnabled("产品群"));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Disable_removes_enabled_flag()
|
||
|
|
{
|
||
|
|
var reg = new RoomRegistry(1);
|
||
|
|
reg.EnableRoom("产品群");
|
||
|
|
reg.DisableRoom("产品群");
|
||
|
|
Assert.False(reg.IsEnabled("产品群"));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void IsEnabled_returns_false_for_unknown_room()
|
||
|
|
{
|
||
|
|
var reg = new RoomRegistry(1);
|
||
|
|
Assert.False(reg.IsEnabled("never-seen"));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void GetRoomId_returns_stable_id()
|
||
|
|
{
|
||
|
|
var reg = new RoomRegistry(7);
|
||
|
|
var a = reg.GetRoomId("产品群");
|
||
|
|
var b = reg.GetRoomId("产品群");
|
||
|
|
Assert.Equal(a, b);
|
||
|
|
Assert.StartsWith("7:room:", a);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void RememberSeen_keeps_lru_within_capacity()
|
||
|
|
{
|
||
|
|
var reg = new RoomRegistry(1, seenCapacityPerRoom: 3);
|
||
|
|
reg.RememberSeen("产品群", "msg1");
|
||
|
|
reg.RememberSeen("产品群", "msg2");
|
||
|
|
reg.RememberSeen("产品群", "msg3");
|
||
|
|
reg.RememberSeen("产品群", "msg4");
|
||
|
|
|
||
|
|
Assert.False(reg.HasSeen("产品群", "msg1")); // 被挤出
|
||
|
|
Assert.True(reg.HasSeen("产品群", "msg2"));
|
||
|
|
Assert.True(reg.HasSeen("产品群", "msg4"));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void HasSeen_isolates_per_room()
|
||
|
|
{
|
||
|
|
var reg = new RoomRegistry(1);
|
||
|
|
reg.RememberSeen("A", "msg1");
|
||
|
|
Assert.False(reg.HasSeen("B", "msg1"));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void EnabledList_returns_all_enabled_rooms()
|
||
|
|
{
|
||
|
|
var reg = new RoomRegistry(1);
|
||
|
|
reg.EnableRoom("a");
|
||
|
|
reg.EnableRoom("b");
|
||
|
|
reg.DisableRoom("a");
|
||
|
|
var list = reg.EnabledList();
|
||
|
|
Assert.Single(list);
|
||
|
|
Assert.Contains("b", list);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Operations_are_thread_safe()
|
||
|
|
{
|
||
|
|
var reg = new RoomRegistry(1);
|
||
|
|
Parallel.For(0, 100, i =>
|
||
|
|
{
|
||
|
|
reg.EnableRoom($"room{i}");
|
||
|
|
reg.RememberSeen($"room{i}", $"msg{i}");
|
||
|
|
});
|
||
|
|
Assert.Equal(100, reg.EnabledList().Count);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: 运行测试确认失败**
|
||
|
|
|
||
|
|
- [ ] **Step 3: 实现**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
namespace Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public sealed class RoomRegistry
|
||
|
|
{
|
||
|
|
private readonly int _channelId;
|
||
|
|
private readonly int _seenCapacityPerRoom;
|
||
|
|
private readonly object _lock = new();
|
||
|
|
private readonly HashSet<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**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Uia/RoomRegistry.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/RoomRegistryTests.cs
|
||
|
|
git commit -m "feat(bridge): add RoomRegistry with per-room LRU dedup"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 4: RoomEventQueue (合并去重 + 背压 + 单线程串行)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Uia/RoomEventQueue.cs`
|
||
|
|
- Test: `packages/windows-tray/Neta.WeChatBridge.Tests/Uia/RoomEventQueueTests.cs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: 写失败测试**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Xunit;
|
||
|
|
using Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public class RoomEventQueueTests
|
||
|
|
{
|
||
|
|
[Fact]
|
||
|
|
public async Task Enqueue_then_dequeue_returns_room_name()
|
||
|
|
{
|
||
|
|
var q = new RoomEventQueue(capacity: 50);
|
||
|
|
q.Enqueue("产品群");
|
||
|
|
var dequeued = await q.DequeueAsync(default);
|
||
|
|
Assert.Equal("产品群", dequeued);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Enqueue_same_room_twice_collapses_to_one()
|
||
|
|
{
|
||
|
|
var q = new RoomEventQueue(50);
|
||
|
|
q.Enqueue("a");
|
||
|
|
q.Enqueue("a");
|
||
|
|
Assert.Equal(1, q.Count);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Distinct_rooms_preserved_in_fifo_order()
|
||
|
|
{
|
||
|
|
var q = new RoomEventQueue(50);
|
||
|
|
q.Enqueue("a");
|
||
|
|
q.Enqueue("b");
|
||
|
|
q.Enqueue("c");
|
||
|
|
Assert.Equal("a", await q.DequeueAsync(default));
|
||
|
|
Assert.Equal("b", await q.DequeueAsync(default));
|
||
|
|
Assert.Equal("c", await q.DequeueAsync(default));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Enqueue_beyond_capacity_drops_oldest()
|
||
|
|
{
|
||
|
|
var q = new RoomEventQueue(capacity: 3);
|
||
|
|
q.Enqueue("a");
|
||
|
|
q.Enqueue("b");
|
||
|
|
q.Enqueue("c");
|
||
|
|
q.Enqueue("d"); // a 被挤掉
|
||
|
|
Assert.Equal(3, q.Count);
|
||
|
|
Assert.False(q.Contains("a"));
|
||
|
|
Assert.True(q.Contains("d"));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Dequeue_blocks_until_enqueued()
|
||
|
|
{
|
||
|
|
var q = new RoomEventQueue(50);
|
||
|
|
var task = q.DequeueAsync(default);
|
||
|
|
Assert.False(task.IsCompleted);
|
||
|
|
|
||
|
|
q.Enqueue("late");
|
||
|
|
var result = await task;
|
||
|
|
Assert.Equal("late", result);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Dequeue_is_cancelled_by_token()
|
||
|
|
{
|
||
|
|
var q = new RoomEventQueue(50);
|
||
|
|
var cts = new CancellationTokenSource();
|
||
|
|
var task = q.DequeueAsync(cts.Token);
|
||
|
|
cts.Cancel();
|
||
|
|
await Assert.ThrowsAnyAsync<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: 实现**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using System.Threading.Channels;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public sealed class RoomEventQueue
|
||
|
|
{
|
||
|
|
private readonly int _capacity;
|
||
|
|
private readonly object _lock = new();
|
||
|
|
private readonly LinkedList<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**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Uia/RoomEventQueue.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/RoomEventQueueTests.cs
|
||
|
|
git commit -m "feat(bridge): add RoomEventQueue with merge dedup + backpressure"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 2 · 附件 (DAT-XOR + AttachmentExtractor)
|
||
|
|
|
||
|
|
### Task 5: DatXorDecoder
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Uia/DatXorDecoder.cs`
|
||
|
|
- Test: `packages/windows-tray/Neta.WeChatBridge.Tests/Uia/DatXorDecoderTests.cs`
|
||
|
|
|
||
|
|
> **算法**:WeChat .dat 是把图片整文件每字节 XOR 同一个 8-bit 常量。识别 key 的方法:已知图片格式头第一字节 (JPEG=0xFF, PNG=0x89, GIF=0x47),用 dat 的第一字节 XOR 已知头字节得到 key,再验证第二字节也匹配。
|
||
|
|
|
||
|
|
- [ ] **Step 1: 写失败测试**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Xunit;
|
||
|
|
using Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public class DatXorDecoderTests
|
||
|
|
{
|
||
|
|
[Fact]
|
||
|
|
public void DetectKey_recovers_jpeg_xor_key()
|
||
|
|
{
|
||
|
|
// 真实 JPEG 头:FF D8 FF E0 ...
|
||
|
|
byte key = 0x77;
|
||
|
|
var encoded = new byte[] {
|
||
|
|
(byte)(0xFF ^ key), (byte)(0xD8 ^ key),
|
||
|
|
(byte)(0xFF ^ key), (byte)(0xE0 ^ key),
|
||
|
|
};
|
||
|
|
var detected = DatXorDecoder.DetectKey(encoded);
|
||
|
|
Assert.NotNull(detected);
|
||
|
|
Assert.Equal(key, detected!.Value.Key);
|
||
|
|
Assert.Equal("jpg", detected.Value.Extension);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void DetectKey_recovers_png_xor_key()
|
||
|
|
{
|
||
|
|
// PNG: 89 50 4E 47
|
||
|
|
byte key = 0xA3;
|
||
|
|
var encoded = new byte[] {
|
||
|
|
(byte)(0x89 ^ key), (byte)(0x50 ^ key),
|
||
|
|
(byte)(0x4E ^ key), (byte)(0x47 ^ key),
|
||
|
|
};
|
||
|
|
var detected = DatXorDecoder.DetectKey(encoded);
|
||
|
|
Assert.NotNull(detected);
|
||
|
|
Assert.Equal(key, detected!.Value.Key);
|
||
|
|
Assert.Equal("png", detected.Value.Extension);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void DetectKey_recovers_gif_xor_key()
|
||
|
|
{
|
||
|
|
// GIF: 47 49 46 38
|
||
|
|
byte key = 0x10;
|
||
|
|
var encoded = new byte[] {
|
||
|
|
(byte)(0x47 ^ key), (byte)(0x49 ^ key),
|
||
|
|
(byte)(0x46 ^ key), (byte)(0x38 ^ key),
|
||
|
|
};
|
||
|
|
var detected = DatXorDecoder.DetectKey(encoded);
|
||
|
|
Assert.Equal(0x10, detected!.Value.Key);
|
||
|
|
Assert.Equal("gif", detected.Value.Extension);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void DetectKey_returns_null_when_unknown_format()
|
||
|
|
{
|
||
|
|
// 随机字节,无法对应任何已知格式
|
||
|
|
var encoded = new byte[] { 0x12, 0x34, 0x56, 0x78 };
|
||
|
|
Assert.Null(DatXorDecoder.DetectKey(encoded));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void DetectKey_returns_null_when_input_too_short()
|
||
|
|
{
|
||
|
|
Assert.Null(DatXorDecoder.DetectKey(new byte[] { 0x00 }));
|
||
|
|
Assert.Null(DatXorDecoder.DetectKey(Array.Empty<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: 实现**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
namespace Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public static class DatXorDecoder
|
||
|
|
{
|
||
|
|
public sealed record DetectedKey(byte Key, string Extension);
|
||
|
|
|
||
|
|
private static readonly (byte[] Magic, string Ext)[] KnownFormats =
|
||
|
|
{
|
||
|
|
(new byte[] { 0xFF, 0xD8, 0xFF }, "jpg"),
|
||
|
|
(new byte[] { 0x89, 0x50, 0x4E, 0x47 }, "png"),
|
||
|
|
(new byte[] { 0x47, 0x49, 0x46, 0x38 }, "gif"),
|
||
|
|
(new byte[] { 0x42, 0x4D }, "bmp"),
|
||
|
|
};
|
||
|
|
|
||
|
|
public static DetectedKey? DetectKey(ReadOnlySpan<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**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Uia/DatXorDecoder.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/DatXorDecoderTests.cs
|
||
|
|
git commit -m "feat(bridge): add DatXorDecoder for WeChat image .dat decryption"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 6: AttachmentExtractor
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Uia/AttachmentExtractor.cs`
|
||
|
|
- Test: `packages/windows-tray/Neta.WeChatBridge.Tests/Uia/AttachmentExtractorTests.cs`
|
||
|
|
|
||
|
|
> 这个组件需要文件系统副作用,测试用临时目录隔离。
|
||
|
|
|
||
|
|
- [ ] **Step 1: 写失败测试**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Xunit;
|
||
|
|
using Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public class AttachmentExtractorTests : IDisposable
|
||
|
|
{
|
||
|
|
private readonly string _tempDataDir;
|
||
|
|
private readonly string _tempCacheDir;
|
||
|
|
|
||
|
|
public AttachmentExtractorTests()
|
||
|
|
{
|
||
|
|
_tempDataDir = Path.Combine(Path.GetTempPath(), $"neta-test-data-{Guid.NewGuid():N}");
|
||
|
|
_tempCacheDir = Path.Combine(Path.GetTempPath(), $"neta-test-cache-{Guid.NewGuid():N}");
|
||
|
|
Directory.CreateDirectory(_tempDataDir);
|
||
|
|
Directory.CreateDirectory(_tempCacheDir);
|
||
|
|
}
|
||
|
|
|
||
|
|
public void Dispose()
|
||
|
|
{
|
||
|
|
try { Directory.Delete(_tempDataDir, true); } catch { }
|
||
|
|
try { Directory.Delete(_tempCacheDir, true); } catch { }
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void ExtractImage_decrypts_jpg_dat_and_writes_to_dataDir()
|
||
|
|
{
|
||
|
|
// 构造一个加密 dat (实际 jpg 头 FF D8 FF E0 + 假数据)
|
||
|
|
byte key = 0x33;
|
||
|
|
var plaintext = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x12, 0x34 };
|
||
|
|
var encoded = DatXorDecoder.Decrypt(plaintext, key);
|
||
|
|
|
||
|
|
var datPath = Path.Combine(_tempCacheDir, "abc.dat");
|
||
|
|
File.WriteAllBytes(datPath, encoded);
|
||
|
|
|
||
|
|
var extractor = new AttachmentExtractor(_tempDataDir);
|
||
|
|
var result = extractor.ExtractImage(channelId: 1, datPath: datPath, msgIdHash: "msg-1");
|
||
|
|
|
||
|
|
Assert.True(result.Ok);
|
||
|
|
Assert.Equal("jpg", Path.GetExtension(result.OutputPath!).TrimStart('.'));
|
||
|
|
Assert.True(File.Exists(result.OutputPath));
|
||
|
|
Assert.Equal(plaintext, File.ReadAllBytes(result.OutputPath!));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void ExtractImage_falls_back_to_jpg_cache_if_present()
|
||
|
|
{
|
||
|
|
// 微信打开过的图片会同时有 abc.dat + abc.jpg
|
||
|
|
var jpgPath = Path.Combine(_tempCacheDir, "abc.jpg");
|
||
|
|
var datPath = Path.Combine(_tempCacheDir, "abc.dat");
|
||
|
|
File.WriteAllBytes(jpgPath, new byte[] { 0xFF, 0xD8, 0x99, 0x88 });
|
||
|
|
File.WriteAllBytes(datPath, new byte[] { 0x00 }); // 不应该被读
|
||
|
|
|
||
|
|
var extractor = new AttachmentExtractor(_tempDataDir);
|
||
|
|
var result = extractor.ExtractImage(channelId: 1, datPath: datPath, msgIdHash: "msg-1");
|
||
|
|
|
||
|
|
Assert.True(result.Ok);
|
||
|
|
Assert.Equal(new byte[] { 0xFF, 0xD8, 0x99, 0x88 }, File.ReadAllBytes(result.OutputPath!));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void ExtractImage_returns_failure_on_unknown_format()
|
||
|
|
{
|
||
|
|
var datPath = Path.Combine(_tempCacheDir, "weird.dat");
|
||
|
|
File.WriteAllBytes(datPath, new byte[] { 0x12, 0x34, 0x56, 0x78 });
|
||
|
|
|
||
|
|
var extractor = new AttachmentExtractor(_tempDataDir);
|
||
|
|
var result = extractor.ExtractImage(1, datPath, "msg-1");
|
||
|
|
|
||
|
|
Assert.False(result.Ok);
|
||
|
|
Assert.Contains("unknown", result.Error!.ToLowerInvariant());
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void ExtractImage_returns_failure_when_dat_missing()
|
||
|
|
{
|
||
|
|
var extractor = new AttachmentExtractor(_tempDataDir);
|
||
|
|
var result = extractor.ExtractImage(1, Path.Combine(_tempCacheDir, "missing.dat"), "msg-1");
|
||
|
|
Assert.False(result.Ok);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void ExtractImage_writes_to_correct_subdir_layout()
|
||
|
|
{
|
||
|
|
// dataDir/wechat-uploads/<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: 实现**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
namespace Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public sealed class AttachmentExtractor
|
||
|
|
{
|
||
|
|
private readonly string _dataDir;
|
||
|
|
|
||
|
|
public AttachmentExtractor(string dataDir)
|
||
|
|
{
|
||
|
|
_dataDir = dataDir;
|
||
|
|
}
|
||
|
|
|
||
|
|
public sealed record ExtractResult(bool Ok, string? OutputPath = null, string? Error = null);
|
||
|
|
|
||
|
|
public ExtractResult ExtractImage(int channelId, string datPath, string msgIdHash)
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// 1. 优先用 .jpg 缓存(微信曾打开过该图)
|
||
|
|
var jpgCache = Path.ChangeExtension(datPath, ".jpg");
|
||
|
|
byte[] plaintext;
|
||
|
|
string ext;
|
||
|
|
if (File.Exists(jpgCache))
|
||
|
|
{
|
||
|
|
plaintext = File.ReadAllBytes(jpgCache);
|
||
|
|
ext = "jpg";
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
if (!File.Exists(datPath))
|
||
|
|
return new ExtractResult(false, Error: $"dat not found: {datPath}");
|
||
|
|
|
||
|
|
var encoded = File.ReadAllBytes(datPath);
|
||
|
|
var detected = DatXorDecoder.DetectKey(encoded);
|
||
|
|
if (detected is null)
|
||
|
|
return new ExtractResult(false, Error: "unknown image format");
|
||
|
|
plaintext = DatXorDecoder.Decrypt(encoded, detected.Key);
|
||
|
|
ext = detected.Extension;
|
||
|
|
}
|
||
|
|
|
||
|
|
var ym = DateTime.UtcNow.ToString("yyyy-MM");
|
||
|
|
var outDir = Path.Combine(_dataDir, "wechat-uploads", channelId.ToString(), ym);
|
||
|
|
Directory.CreateDirectory(outDir);
|
||
|
|
var outPath = Path.Combine(outDir, $"{msgIdHash}.{ext}");
|
||
|
|
File.WriteAllBytes(outPath, plaintext);
|
||
|
|
return new ExtractResult(true, OutputPath: outPath);
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
return new ExtractResult(false, Error: ex.Message);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: 测试通过**
|
||
|
|
|
||
|
|
Expected:`Passed: 5`。
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Uia/AttachmentExtractor.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/AttachmentExtractorTests.cs
|
||
|
|
git commit -m "feat(bridge): add AttachmentExtractor with jpg-cache fallback"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 3 · 模型 + Backend Inbound
|
||
|
|
|
||
|
|
### Task 7: ParsedMessage / InboundPayload 数据类
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Models/ParsedMessage.cs`
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Models/InboundPayload.cs`
|
||
|
|
|
||
|
|
> 纯数据类,无逻辑。直接写,无需 TDD。
|
||
|
|
|
||
|
|
- [ ] **Step 1: 实现**
|
||
|
|
|
||
|
|
`Models/ParsedMessage.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
namespace Neta.WeChatBridge.Models;
|
||
|
|
|
||
|
|
public enum ParsedMessageType
|
||
|
|
{
|
||
|
|
Text,
|
||
|
|
Image,
|
||
|
|
File,
|
||
|
|
Voice,
|
||
|
|
Video,
|
||
|
|
System,
|
||
|
|
Quote,
|
||
|
|
}
|
||
|
|
|
||
|
|
public sealed record QuotedRef(string SenderName, string Preview);
|
||
|
|
|
||
|
|
public sealed record ParsedMessage(
|
||
|
|
ParsedMessageType Type,
|
||
|
|
string SenderName,
|
||
|
|
string Content,
|
||
|
|
DateTimeOffset ReceivedAt,
|
||
|
|
IReadOnlyList<string>? AtList = null,
|
||
|
|
QuotedRef? QuotedRef = null,
|
||
|
|
string? AttachmentPath = null
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
`Models/InboundPayload.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
namespace Neta.WeChatBridge.Models;
|
||
|
|
|
||
|
|
public sealed record InboundPayload(
|
||
|
|
int ChannelId,
|
||
|
|
string RoomName,
|
||
|
|
string SenderName,
|
||
|
|
string MsgType, // "text" / "image" / "file" / "voice" / "video" / "system" / "quote"
|
||
|
|
string Content,
|
||
|
|
string RawHash, // = MessageIdHasher.Build(...)
|
||
|
|
string ReceivedAt, // ISO8601
|
||
|
|
string? AttachmentPath = null,
|
||
|
|
IReadOnlyList<string>? AtList = null,
|
||
|
|
QuotedRefDto? QuotedRef = null
|
||
|
|
);
|
||
|
|
|
||
|
|
public sealed record QuotedRefDto(string SenderName, string Preview);
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: 编译**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
dotnet build packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj
|
||
|
|
```
|
||
|
|
Expected: Build succeeded。
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Models
|
||
|
|
git commit -m "feat(bridge): add ParsedMessage and InboundPayload models"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 8: BackendClient 扩展 (handshake 响应 + IngestInboundAsync)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `packages/windows-tray/Neta.WeChatBridge/Backend/BackendClient.cs`
|
||
|
|
- Test: `packages/windows-tray/Neta.WeChatBridge.Tests/Backend/BackendClientInboundTests.cs`
|
||
|
|
- Test (新增): `packages/windows-tray/Neta.WeChatBridge.Tests/Backend/BackendClientHandshakeExtendedTests.cs`
|
||
|
|
|
||
|
|
> 本 Task 同时补齐两个缺口:
|
||
|
|
> 1. Handshake 响应原本只返回 `Ok`,Phase B 需要拿到 **真实 channelId + 已启用群列表**,才能真正启动 dispatcher (架构师审查 B5/B6/B7/B10)。
|
||
|
|
> 2. 新增 `IngestInboundAsync` 推送入站消息给 backend。
|
||
|
|
|
||
|
|
- [ ] **Step 1: 写失败测试**
|
||
|
|
|
||
|
|
**文件 1** — `BackendClientInboundTests.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using System.Net;
|
||
|
|
using System.Net.Http.Json;
|
||
|
|
using System.Text.Json;
|
||
|
|
using Xunit;
|
||
|
|
using Neta.WeChatBridge.Backend;
|
||
|
|
using Neta.WeChatBridge.Models;
|
||
|
|
|
||
|
|
public class BackendClientInboundTests
|
||
|
|
{
|
||
|
|
private sealed class StubHandler : HttpMessageHandler
|
||
|
|
{
|
||
|
|
public HttpRequestMessage? LastRequest;
|
||
|
|
public string? LastBody;
|
||
|
|
public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||
|
|
|
||
|
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||
|
|
HttpRequestMessage request, CancellationToken ct)
|
||
|
|
{
|
||
|
|
LastRequest = request;
|
||
|
|
if (request.Content is not null)
|
||
|
|
LastBody = await request.Content.ReadAsStringAsync(ct);
|
||
|
|
return new HttpResponseMessage(StatusCode);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static InboundPayload MakePayload(string content = "hi") => new(
|
||
|
|
ChannelId: 7, RoomName: "产品群", SenderName: "小王",
|
||
|
|
MsgType: "text", Content: content, RawHash: "abc123",
|
||
|
|
ReceivedAt: "2026-05-09T12:30:00Z");
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Ingest_posts_to_correct_url_with_secret_and_body()
|
||
|
|
{
|
||
|
|
var handler = new StubHandler();
|
||
|
|
var client = new BackendClient(new HttpClient(handler), "http://127.0.0.1:7071", "sec");
|
||
|
|
var ok = await client.IngestInboundAsync(MakePayload(), default);
|
||
|
|
|
||
|
|
Assert.True(ok);
|
||
|
|
Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method);
|
||
|
|
Assert.Equal(
|
||
|
|
"http://127.0.0.1:7071/open/netaclaw/channel/uia/inbound",
|
||
|
|
handler.LastRequest.RequestUri!.ToString());
|
||
|
|
Assert.Equal("sec", handler.LastRequest.Headers.GetValues("x-neta-tray-secret").First());
|
||
|
|
|
||
|
|
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||
|
|
Assert.Equal(7, doc.RootElement.GetProperty("channelId").GetInt32());
|
||
|
|
Assert.Equal("text", doc.RootElement.GetProperty("msgType").GetString());
|
||
|
|
Assert.Equal("abc123", doc.RootElement.GetProperty("rawHash").GetString());
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Ingest_returns_false_on_non_2xx()
|
||
|
|
{
|
||
|
|
var handler = new StubHandler { StatusCode = HttpStatusCode.InternalServerError };
|
||
|
|
var client = new BackendClient(new HttpClient(handler), "http://127.0.0.1:7071", "sec");
|
||
|
|
Assert.False(await client.IngestInboundAsync(MakePayload(), default));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Ingest_returns_false_on_network_error()
|
||
|
|
{
|
||
|
|
var client = new BackendClient(
|
||
|
|
new HttpClient(new ThrowingHandler()), "http://127.0.0.1:7071", "sec");
|
||
|
|
Assert.False(await client.IngestInboundAsync(MakePayload(), default));
|
||
|
|
}
|
||
|
|
|
||
|
|
private sealed class ThrowingHandler : HttpMessageHandler
|
||
|
|
{
|
||
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage r, CancellationToken c)
|
||
|
|
=> throw new HttpRequestException("connect refused");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**文件 2** — `BackendClientHandshakeExtendedTests.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using System.Net;
|
||
|
|
using System.Net.Http.Json;
|
||
|
|
using Xunit;
|
||
|
|
using Neta.WeChatBridge.Backend;
|
||
|
|
|
||
|
|
public class BackendClientHandshakeExtendedTests
|
||
|
|
{
|
||
|
|
private sealed class JsonHandler : HttpMessageHandler
|
||
|
|
{
|
||
|
|
public object ResponseBody { get; set; } = new { };
|
||
|
|
public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||
|
|
protected override Task<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`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using System.Net.Http.Json;
|
||
|
|
using System.Text.Json;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge.Backend;
|
||
|
|
|
||
|
|
public sealed record HandshakeResult(
|
||
|
|
bool Ok,
|
||
|
|
string? Error = null,
|
||
|
|
int? ChannelId = null,
|
||
|
|
IReadOnlyList<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)**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~BackendClient"
|
||
|
|
```
|
||
|
|
Expected:Plan A 原 `BackendClientTests` (3) + `BackendClientInboundTests` (3) + `BackendClientHandshakeExtendedTests` (2) = `Passed: 8`。
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Backend/BackendClient.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Backend/BackendClientInboundTests.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Backend/BackendClientHandshakeExtendedTests.cs
|
||
|
|
git commit -m "feat(bridge): extend HandshakeResult + add IngestInboundAsync"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 4 · UIA 解析 (MessageParser)
|
||
|
|
|
||
|
|
> 这层依赖 UIA tree 结构;真实 UIA 树测试只能用本地手工冒烟。但我们把"控件树 → ParsedMessage"的逻辑抽到一个**接口**(IUiNode),可以用 fake node 跑单测。
|
||
|
|
|
||
|
|
### Task 9: IUiNode 抽象 + StructuredItemSnapshot
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Uia/IUiNode.cs`
|
||
|
|
|
||
|
|
> 抽象 UIA 控件节点最低依赖,便于测试 mock。
|
||
|
|
|
||
|
|
- [ ] **Step 1: 实现**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
namespace Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
/// <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**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Uia/IUiNode.cs
|
||
|
|
git commit -m "feat(bridge): add IUiNode abstraction for parser tests"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 10: MessageParser
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Uia/MessageParser.cs`
|
||
|
|
- Test: `packages/windows-tray/Neta.WeChatBridge.Tests/Uia/MessageParserTests.cs`
|
||
|
|
|
||
|
|
> Phase B MVP:文本 / 图片 / 引用 / @ 解析。文件 / 语音 / 视频识别为类型,但 attachmentPath 暂留空 (用 spec 表里的 fallback 策略)。
|
||
|
|
|
||
|
|
- [ ] **Step 1: 写失败测试**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Xunit;
|
||
|
|
using Neta.WeChatBridge.Uia;
|
||
|
|
using Neta.WeChatBridge.Models;
|
||
|
|
|
||
|
|
public class MessageParserTests
|
||
|
|
{
|
||
|
|
private static StaticUiNode N(string name, string type, params IUiNode[] children) =>
|
||
|
|
new() { Name = name, ControlTypeName = type, Children = children };
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Parse_text_message()
|
||
|
|
{
|
||
|
|
// ListItem
|
||
|
|
// Button: "小王" ← 发送者
|
||
|
|
// Pane (bubble)
|
||
|
|
// Text: "你好"
|
||
|
|
// Text: "12:30" ← 时间戳
|
||
|
|
var item = N("", "ListItem",
|
||
|
|
N("小王", "Button"),
|
||
|
|
N("", "Pane", N("你好", "Text")),
|
||
|
|
N("12:30", "Text"));
|
||
|
|
|
||
|
|
var parser = new MessageParser();
|
||
|
|
var parsed = parser.Parse(item);
|
||
|
|
|
||
|
|
Assert.NotNull(parsed);
|
||
|
|
Assert.Equal(ParsedMessageType.Text, parsed!.Type);
|
||
|
|
Assert.Equal("小王", parsed.SenderName);
|
||
|
|
Assert.Equal("你好", parsed.Content);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Parse_at_mention_extracts_alias_list()
|
||
|
|
{
|
||
|
|
var item = N("", "ListItem",
|
||
|
|
N("张三", "Button"),
|
||
|
|
N("", "Pane", N("@小神 @lisa 帮看下", "Text")));
|
||
|
|
|
||
|
|
var parsed = new MessageParser().Parse(item);
|
||
|
|
Assert.NotNull(parsed);
|
||
|
|
Assert.Equal(ParsedMessageType.Text, parsed!.Type);
|
||
|
|
Assert.Contains("小神", parsed.AtList);
|
||
|
|
Assert.Contains("lisa", parsed.AtList);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Parse_image_message_marks_type_image()
|
||
|
|
{
|
||
|
|
// 气泡里出现 Image 控件
|
||
|
|
var item = N("", "ListItem",
|
||
|
|
N("Alice", "Button"),
|
||
|
|
N("", "Pane",
|
||
|
|
N("[图片]", "Text"),
|
||
|
|
N("", "Image")));
|
||
|
|
|
||
|
|
var parsed = new MessageParser().Parse(item);
|
||
|
|
Assert.Equal(ParsedMessageType.Image, parsed!.Type);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Parse_quoted_message_captures_quote_ref()
|
||
|
|
{
|
||
|
|
// 引用:气泡顶部子控件 "老板: 这个方案明天就上线"
|
||
|
|
var item = N("", "ListItem",
|
||
|
|
N("张三", "Button"),
|
||
|
|
N("", "Pane",
|
||
|
|
N("老板: 这个方案明天就上线", "Text"), // 引用块文本
|
||
|
|
N("确定可以上线吗?", "Text"))); // 当前消息正文
|
||
|
|
|
||
|
|
var parsed = new MessageParser().Parse(item);
|
||
|
|
Assert.NotNull(parsed!.QuotedRef);
|
||
|
|
Assert.Equal("老板", parsed.QuotedRef!.SenderName);
|
||
|
|
Assert.Contains("这个方案明天就上线", parsed.QuotedRef.Preview);
|
||
|
|
Assert.Equal("确定可以上线吗?", parsed.Content);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Parse_returns_null_when_no_sender()
|
||
|
|
{
|
||
|
|
// 系统消息没有 sender Button
|
||
|
|
var item = N("", "ListItem",
|
||
|
|
N("", "Pane", N("张三 撤回了一条消息", "Text")));
|
||
|
|
|
||
|
|
var parsed = new MessageParser().Parse(item);
|
||
|
|
Assert.NotNull(parsed);
|
||
|
|
Assert.Equal(ParsedMessageType.System, parsed!.Type);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Parse_ignores_empty_item()
|
||
|
|
{
|
||
|
|
var item = N("", "ListItem");
|
||
|
|
Assert.Null(new MessageParser().Parse(item));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: 运行测试确认失败**
|
||
|
|
|
||
|
|
- [ ] **Step 3: 实现**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using System.Text.RegularExpressions;
|
||
|
|
using Neta.WeChatBridge.Models;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public sealed class MessageParser
|
||
|
|
{
|
||
|
|
// @<alias> — 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**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Uia/MessageParser.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/MessageParserTests.cs
|
||
|
|
git commit -m "feat(bridge): add MessageParser for text/image/quote/at"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 5 · 发送 (SendInput + MessageSender)
|
||
|
|
|
||
|
|
### Task 11: SendInput P/Invoke 包装
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Uia/SendInput.cs`
|
||
|
|
|
||
|
|
> 用 `User32.SendInput` 发 Unicode 文本,比 `System.Windows.Forms.SendKeys` 对中文/emoji 稳定。无 unit test (操作系统 API),仅手工验证。
|
||
|
|
|
||
|
|
- [ ] **Step 1: 实现**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using System.Runtime.InteropServices;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
/// <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**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Uia/SendInput.cs
|
||
|
|
git commit -m "feat(bridge): add SendInput P/Invoke wrapper for unicode keys"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 12: MessageSender 接口 + 实现
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Uia/IMessageSender.cs`
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Uia/MessageSender.cs`
|
||
|
|
- Test: `packages/windows-tray/Neta.WeChatBridge.Tests/Uia/MessageSenderTests.cs` (用 fake)
|
||
|
|
|
||
|
|
> 真实 `MessageSender` 调 UIA + SendInput,不能 unit test;但**通过 IMessageSender 接口** + fake 实现验证 endpoint 调用编排。
|
||
|
|
|
||
|
|
- [ ] **Step 1: 写接口**
|
||
|
|
|
||
|
|
`Uia/IMessageSender.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
namespace Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public interface IMessageSender
|
||
|
|
{
|
||
|
|
/// <summary>
|
||
|
|
/// 切到目标群窗口 → 输入文本 → 回车。
|
||
|
|
/// 返回是否发送成功 (基于"最后一条自己发的消息时间戳 > 发送前"判断)。
|
||
|
|
/// </summary>
|
||
|
|
Task<bool> SendTextAsync(string roomName, string text, CancellationToken ct);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: 写实现 (UIA + SendInput)**
|
||
|
|
|
||
|
|
`Uia/MessageSender.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using System.Windows.Automation;
|
||
|
|
using Neta.WeChatBridge.Config;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public sealed class MessageSender : IMessageSender
|
||
|
|
{
|
||
|
|
private readonly AutomationElement _root;
|
||
|
|
private readonly VersionProfile _profile;
|
||
|
|
|
||
|
|
public MessageSender(AutomationElement root, VersionProfile profile)
|
||
|
|
{
|
||
|
|
_root = root;
|
||
|
|
_profile = profile;
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<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`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Xunit;
|
||
|
|
using Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public class MessageSenderTests
|
||
|
|
{
|
||
|
|
private sealed class FakeSender : IMessageSender
|
||
|
|
{
|
||
|
|
public List<(string Room, string Text)> Sent { get; } = new();
|
||
|
|
public bool ReturnValue { get; set; } = true;
|
||
|
|
public Task<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**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Uia/IMessageSender.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge/Uia/MessageSender.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Uia/MessageSenderTests.cs
|
||
|
|
git commit -m "feat(bridge): add IMessageSender + UIA MessageSender"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 6 · BridgeState 扩展 + HTTP 端点
|
||
|
|
|
||
|
|
> **架构顺序**:先扩展 `BridgeState` 让它能承载 `Rooms` / `EventQueue`,再写依赖这些字段的 endpoint。反过来顺序会让 endpoint 测试一直失败到 Task 16 才通过。
|
||
|
|
|
||
|
|
### Task 13: 扩展 BridgeState + 新容器字段
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `packages/windows-tray/Neta.WeChatBridge/BridgeState.cs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: 完整替换 BridgeState.cs**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge;
|
||
|
|
|
||
|
|
/// <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`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Xunit;
|
||
|
|
using Neta.WeChatBridge;
|
||
|
|
|
||
|
|
public class BridgeStateTests
|
||
|
|
{
|
||
|
|
[Fact]
|
||
|
|
public void Rooms_throws_before_init()
|
||
|
|
{
|
||
|
|
var s = new BridgeState();
|
||
|
|
Assert.False(s.IsRuntimeReady);
|
||
|
|
Assert.Throws<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**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/BridgeState.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/BridgeStateTests.cs
|
||
|
|
git commit -m "feat(bridge): extend BridgeState with late-bound Rooms/EventQueue"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 14: RoomsEndpoint
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/RoomsEndpoint.cs`
|
||
|
|
- Test: `packages/windows-tray/Neta.WeChatBridge.Tests/Http/RoomsEndpointTests.cs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: 写失败测试**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using System.Net.Http.Json;
|
||
|
|
using Microsoft.AspNetCore.Builder;
|
||
|
|
using Microsoft.AspNetCore.Hosting;
|
||
|
|
using Microsoft.AspNetCore.TestHost;
|
||
|
|
using Microsoft.Extensions.DependencyInjection;
|
||
|
|
using Microsoft.Extensions.Hosting;
|
||
|
|
using Xunit;
|
||
|
|
using Neta.WeChatBridge;
|
||
|
|
using Neta.WeChatBridge.Http.Endpoints;
|
||
|
|
using Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public class RoomsEndpointTests
|
||
|
|
{
|
||
|
|
private static IHost BuildHost(BridgeState state)
|
||
|
|
{
|
||
|
|
return new HostBuilder()
|
||
|
|
.ConfigureWebHost(web =>
|
||
|
|
{
|
||
|
|
web.UseTestServer();
|
||
|
|
web.ConfigureServices(s => s.AddSingleton(state).AddRouting());
|
||
|
|
web.Configure(app =>
|
||
|
|
{
|
||
|
|
app.UseRouting();
|
||
|
|
app.UseEndpoints(e => RoomsEndpoint.Map(e));
|
||
|
|
});
|
||
|
|
})
|
||
|
|
.Start();
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Get_rooms_returns_enabled_list()
|
||
|
|
{
|
||
|
|
var state = new BridgeState();
|
||
|
|
state.Rooms.EnableRoom("产品群");
|
||
|
|
state.Rooms.EnableRoom("研发群");
|
||
|
|
|
||
|
|
using var host = BuildHost(state);
|
||
|
|
var client = host.GetTestClient();
|
||
|
|
var body = await client.GetFromJsonAsync<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: 实现**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Microsoft.AspNetCore.Builder;
|
||
|
|
using Microsoft.AspNetCore.Http;
|
||
|
|
using Microsoft.AspNetCore.Routing;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge.Http.Endpoints;
|
||
|
|
|
||
|
|
public static class RoomsEndpoint
|
||
|
|
{
|
||
|
|
public static IEndpointRouteBuilder Map(IEndpointRouteBuilder app)
|
||
|
|
{
|
||
|
|
app.MapGet("/rooms", (BridgeState state) =>
|
||
|
|
{
|
||
|
|
return Results.Json(new
|
||
|
|
{
|
||
|
|
rooms = state.Rooms.EnabledList(),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
return app;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit (测试在 Task 16 后跑通)**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/RoomsEndpoint.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Http/RoomsEndpointTests.cs
|
||
|
|
git commit -m "feat(bridge): add /rooms endpoint (depends on BridgeState.Rooms)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 15: SendEndpoint
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/SendEndpoint.cs`
|
||
|
|
- Test: `packages/windows-tray/Neta.WeChatBridge.Tests/Http/SendEndpointTests.cs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: 写失败测试**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using System.Net;
|
||
|
|
using System.Net.Http.Json;
|
||
|
|
using Microsoft.AspNetCore.Builder;
|
||
|
|
using Microsoft.AspNetCore.Hosting;
|
||
|
|
using Microsoft.AspNetCore.TestHost;
|
||
|
|
using Microsoft.Extensions.DependencyInjection;
|
||
|
|
using Microsoft.Extensions.Hosting;
|
||
|
|
using Xunit;
|
||
|
|
using Neta.WeChatBridge.Http.Endpoints;
|
||
|
|
using Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public class SendEndpointTests
|
||
|
|
{
|
||
|
|
private sealed class FakeSender : IMessageSender
|
||
|
|
{
|
||
|
|
public (string Room, string Text)? LastSent;
|
||
|
|
public bool ReturnValue { get; set; } = true;
|
||
|
|
public Task<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: 实现**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Microsoft.AspNetCore.Builder;
|
||
|
|
using Microsoft.AspNetCore.Http;
|
||
|
|
using Microsoft.AspNetCore.Routing;
|
||
|
|
using Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge.Http.Endpoints;
|
||
|
|
|
||
|
|
public static class SendEndpoint
|
||
|
|
{
|
||
|
|
public sealed record SendRequest(string? RoomName, string? Text);
|
||
|
|
|
||
|
|
public static IEndpointRouteBuilder Map(IEndpointRouteBuilder app)
|
||
|
|
{
|
||
|
|
app.MapPost("/send", async (SendRequest body, IMessageSender sender, HttpContext ctx) =>
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(body.RoomName))
|
||
|
|
return Results.BadRequest(new { error = "roomName 必填" });
|
||
|
|
if (string.IsNullOrWhiteSpace(body.Text))
|
||
|
|
return Results.BadRequest(new { error = "text 必填" });
|
||
|
|
|
||
|
|
var ok = await sender.SendTextAsync(body.RoomName, body.Text, ctx.RequestAborted);
|
||
|
|
return ok
|
||
|
|
? Results.Ok(new { ok = true })
|
||
|
|
: Results.StatusCode(500);
|
||
|
|
});
|
||
|
|
return app;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: 测试通过**
|
||
|
|
|
||
|
|
Expected:`Passed: 4`。
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/SendEndpoint.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Http/SendEndpointTests.cs
|
||
|
|
git commit -m "feat(bridge): add /send endpoint with sender injection"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 16: EnableDisableEndpoint
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/EnableDisableEndpoint.cs`
|
||
|
|
- Test: `packages/windows-tray/Neta.WeChatBridge.Tests/Http/EnableDisableEndpointTests.cs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: 写失败测试**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using System.Net.Http.Json;
|
||
|
|
using Microsoft.AspNetCore.Builder;
|
||
|
|
using Microsoft.AspNetCore.Hosting;
|
||
|
|
using Microsoft.AspNetCore.TestHost;
|
||
|
|
using Microsoft.Extensions.DependencyInjection;
|
||
|
|
using Microsoft.Extensions.Hosting;
|
||
|
|
using Xunit;
|
||
|
|
using Neta.WeChatBridge;
|
||
|
|
using Neta.WeChatBridge.Http.Endpoints;
|
||
|
|
|
||
|
|
public class EnableDisableEndpointTests
|
||
|
|
{
|
||
|
|
private static IHost BuildHost(BridgeState state)
|
||
|
|
{
|
||
|
|
return new HostBuilder()
|
||
|
|
.ConfigureWebHost(web =>
|
||
|
|
{
|
||
|
|
web.UseTestServer();
|
||
|
|
web.ConfigureServices(s => s.AddSingleton(state).AddRouting());
|
||
|
|
web.Configure(app =>
|
||
|
|
{
|
||
|
|
app.UseRouting();
|
||
|
|
app.UseEndpoints(e => EnableDisableEndpoint.Map(e));
|
||
|
|
});
|
||
|
|
})
|
||
|
|
.Start();
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Enable_room_sets_flag()
|
||
|
|
{
|
||
|
|
var state = new BridgeState();
|
||
|
|
using var host = BuildHost(state);
|
||
|
|
var client = host.GetTestClient();
|
||
|
|
|
||
|
|
await client.PostAsJsonAsync("/enable-room", new { roomName = "产品群" });
|
||
|
|
Assert.True(state.Rooms.IsEnabled("产品群"));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Disable_room_clears_flag()
|
||
|
|
{
|
||
|
|
var state = new BridgeState();
|
||
|
|
state.Rooms.EnableRoom("产品群");
|
||
|
|
using var host = BuildHost(state);
|
||
|
|
var client = host.GetTestClient();
|
||
|
|
|
||
|
|
await client.PostAsJsonAsync("/disable-room", new { roomName = "产品群" });
|
||
|
|
Assert.False(state.Rooms.IsEnabled("产品群"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: 实现**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Microsoft.AspNetCore.Builder;
|
||
|
|
using Microsoft.AspNetCore.Http;
|
||
|
|
using Microsoft.AspNetCore.Routing;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge.Http.Endpoints;
|
||
|
|
|
||
|
|
public static class EnableDisableEndpoint
|
||
|
|
{
|
||
|
|
public sealed record RoomRequest(string? RoomName);
|
||
|
|
|
||
|
|
public static IEndpointRouteBuilder Map(IEndpointRouteBuilder app)
|
||
|
|
{
|
||
|
|
app.MapPost("/enable-room", (RoomRequest body, BridgeState state) =>
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(body.RoomName))
|
||
|
|
return Results.BadRequest(new { error = "roomName 必填" });
|
||
|
|
state.Rooms.EnableRoom(body.RoomName);
|
||
|
|
return Results.Ok(new { ok = true });
|
||
|
|
});
|
||
|
|
app.MapPost("/disable-room", (RoomRequest body, BridgeState state) =>
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(body.RoomName))
|
||
|
|
return Results.BadRequest(new { error = "roomName 必填" });
|
||
|
|
state.Rooms.DisableRoom(body.RoomName);
|
||
|
|
return Results.Ok(new { ok = true });
|
||
|
|
});
|
||
|
|
return app;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: 测试通过**(等 Task 16 完成 BridgeState.Rooms 字段)
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/EnableDisableEndpoint.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Http/EnableDisableEndpointTests.cs
|
||
|
|
git commit -m "feat(bridge): add /enable-room and /disable-room endpoints"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phase 7 · HTTP 注入 + 事件订阅 + Worker 编排
|
||
|
|
|
||
|
|
### Task 17: BridgeHttpServer 注入 sender + 所有 endpoint 注册
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `packages/windows-tray/Neta.WeChatBridge/Http/BridgeHttpServer.cs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: 完整替换 BridgeHttpServer.cs**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Microsoft.AspNetCore.Builder;
|
||
|
|
using Microsoft.Extensions.DependencyInjection;
|
||
|
|
using Microsoft.Extensions.Hosting;
|
||
|
|
using Neta.WeChatBridge.Http.Endpoints;
|
||
|
|
using Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge.Http;
|
||
|
|
|
||
|
|
public sealed class BridgeHttpServer
|
||
|
|
{
|
||
|
|
public static WebApplication Build(
|
||
|
|
int port,
|
||
|
|
string traySecret,
|
||
|
|
BridgeState state,
|
||
|
|
IMessageSender sender)
|
||
|
|
{
|
||
|
|
var builder = WebApplication.CreateBuilder();
|
||
|
|
builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
|
||
|
|
builder.Services.AddSingleton(state);
|
||
|
|
builder.Services.AddSingleton(sender);
|
||
|
|
builder.Services.AddRouting();
|
||
|
|
|
||
|
|
var app = builder.Build();
|
||
|
|
app.UseMiddleware<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 被阻塞的测试**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests --filter "FullyQualifiedName~RoomsEndpointTests|FullyQualifiedName~EnableDisableEndpointTests"
|
||
|
|
```
|
||
|
|
|
||
|
|
> ⚠️ 这里测试代码需要**先手动初始化 state.InitRuntimeContainers**,否则访问 `state.Rooms` 会抛异常。请修改 Task 14 / Task 16 的测试 setup:
|
||
|
|
|
||
|
|
在 `RoomsEndpointTests.Get_rooms_returns_enabled_list` 里把 `state.Rooms.EnableRoom(...)` 之前插入:
|
||
|
|
```csharp
|
||
|
|
state.InitRuntimeContainers(1, Array.Empty<string>());
|
||
|
|
```
|
||
|
|
|
||
|
|
在 `EnableDisableEndpointTests` 的两个测试里同样先调 `state.InitRuntimeContainers(1, Array.Empty<string>());`。
|
||
|
|
|
||
|
|
Expected:`Passed: 3`。
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Http/BridgeHttpServer.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Http/RoomsEndpointTests.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Http/EnableDisableEndpointTests.cs
|
||
|
|
git commit -m "feat(bridge): register all endpoints + inject IMessageSender"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 18: SessionListWatcher (UIA 事件订阅)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Uia/SessionListWatcher.cs`
|
||
|
|
|
||
|
|
> UIA 事件订阅只能在真实 WeChat 下手工验证。该组件有最小 surface (Start/Stop + 回调),无单测。
|
||
|
|
|
||
|
|
- [ ] **Step 1: 实现**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using System.Windows.Automation;
|
||
|
|
using Neta.WeChatBridge.Config;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public sealed class SessionListWatcher : IDisposable
|
||
|
|
{
|
||
|
|
private readonly AutomationElement _root;
|
||
|
|
private readonly VersionProfile _profile;
|
||
|
|
private readonly Action<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**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Uia/SessionListWatcher.cs
|
||
|
|
git commit -m "feat(bridge): add SessionListWatcher UIA event subscriptions"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 19: InboundDispatcher Worker
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Workers/InboundDispatcher.cs`
|
||
|
|
- Test: `packages/windows-tray/Neta.WeChatBridge.Tests/Workers/InboundDispatcherTests.cs`
|
||
|
|
|
||
|
|
> 核心编排逻辑:从 EventQueue dequeue → 切窗读消息 → 过滤已启用群 → 去重 → POST backend。
|
||
|
|
|
||
|
|
- [ ] **Step 1: 写失败测试**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Xunit;
|
||
|
|
using Neta.WeChatBridge;
|
||
|
|
using Neta.WeChatBridge.Models;
|
||
|
|
using Neta.WeChatBridge.Uia;
|
||
|
|
using Neta.WeChatBridge.Workers;
|
||
|
|
|
||
|
|
public class InboundDispatcherTests
|
||
|
|
{
|
||
|
|
// Fake 切窗读消息器,避免真 UIA
|
||
|
|
private sealed class FakeReader : IRoomMessageReader
|
||
|
|
{
|
||
|
|
public Dictionary<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`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
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**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Workers/InboundDispatcher.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge.Tests/Workers/InboundDispatcherTests.cs
|
||
|
|
git commit -m "feat(bridge): add InboundDispatcher worker with dedup"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 20: UiaRoomMessageReader + BackendSinkAdapter + Program.cs 完整接线
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Uia/UiaRoomMessageReader.cs`
|
||
|
|
- Create: `packages/windows-tray/Neta.WeChatBridge/Workers/BackendSinkAdapter.cs`
|
||
|
|
- Modify: `packages/windows-tray/Neta.WeChatBridge/Program.cs` (完整替换 Plan A 的实现)
|
||
|
|
|
||
|
|
> **架构师审查 B3/B4/B5/B7/B8/B10 的综合修复**:
|
||
|
|
> - Program.cs 给完整文件 (不再用 partial diff)
|
||
|
|
> - handshake **前置** 于 BridgeState.InitRuntimeContainers,channelId / enabledRooms 从响应取
|
||
|
|
> - UiaRoomMessageReader 用 ControlType 静态对象比对而非 LocalizedControlType (B8)
|
||
|
|
> - SendEndpoint 入参的 cancellation 不传 UIA (避免半途打断)
|
||
|
|
|
||
|
|
- [ ] **Step 1: 写 UiaRoomMessageReader (用 ControlType 对象比对)**
|
||
|
|
|
||
|
|
`Uia/UiaRoomMessageReader.cs`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using System.Windows.Automation;
|
||
|
|
using Neta.WeChatBridge.Config;
|
||
|
|
using Neta.WeChatBridge.Models;
|
||
|
|
using Neta.WeChatBridge.Workers;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge.Uia;
|
||
|
|
|
||
|
|
public sealed class UiaRoomMessageReader : IRoomMessageReader
|
||
|
|
{
|
||
|
|
private readonly AutomationElement _root;
|
||
|
|
private readonly VersionProfile _profile;
|
||
|
|
private readonly MessageParser _parser = new();
|
||
|
|
|
||
|
|
public UiaRoomMessageReader(AutomationElement root, VersionProfile profile)
|
||
|
|
{
|
||
|
|
_root = root;
|
||
|
|
_profile = profile;
|
||
|
|
}
|
||
|
|
|
||
|
|
public Task<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`:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using Neta.WeChatBridge.Backend;
|
||
|
|
using Neta.WeChatBridge.Models;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge.Workers;
|
||
|
|
|
||
|
|
public sealed class BackendSinkAdapter : IBackendSink
|
||
|
|
{
|
||
|
|
private readonly BackendClient _client;
|
||
|
|
public BackendSinkAdapter(BackendClient client) { _client = client; }
|
||
|
|
public Task<bool> IngestAsync(InboundPayload payload, CancellationToken ct)
|
||
|
|
=> _client.IngestInboundAsync(payload, ct);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: 完整替换 Program.cs (Plan A + Plan B 整合)**
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
using System.Text;
|
||
|
|
using Neta.WeChatBridge.Backend;
|
||
|
|
using Neta.WeChatBridge.Config;
|
||
|
|
using Neta.WeChatBridge.Http;
|
||
|
|
using Neta.WeChatBridge.Runtime;
|
||
|
|
using Neta.WeChatBridge.Uia;
|
||
|
|
using Neta.WeChatBridge.Workers;
|
||
|
|
|
||
|
|
namespace Neta.WeChatBridge;
|
||
|
|
|
||
|
|
public class Program
|
||
|
|
{
|
||
|
|
public static async Task<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) 实现里:
|
||
|
|
|
||
|
|
```csharp
|
||
|
|
// UIA 发送过程中不接受半途取消(client 断开时按键已经在飞了,
|
||
|
|
// 取消会导致键盘残留状态)。用 None 而不是 ctx.RequestAborted。
|
||
|
|
var ok = await sender.SendTextAsync(body.RoomName, body.Text, CancellationToken.None);
|
||
|
|
```
|
||
|
|
|
||
|
|
> 这是覆盖修改,等执行到 Task 15 时直接用本版本代码 (Plan B 已修订)。
|
||
|
|
|
||
|
|
- [ ] **Step 5: 编译全量**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
dotnet build packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj
|
||
|
|
dotnet test packages/windows-tray/Neta.WeChatBridge.Tests
|
||
|
|
```
|
||
|
|
Expected:全部 build succeeded + 所有单测 pass。
|
||
|
|
|
||
|
|
- [ ] **Step 6: 手工冒烟 (Windows 开发机)**
|
||
|
|
|
||
|
|
前提:Plan C 还没合并时,backend handshake 端点不存在 → handshake 失败 → bridge 仍能启 HTTP server,但不启 dispatcher。这是预期行为。
|
||
|
|
|
||
|
|
更完整的手工验证留给 Plan C 合并后(handshake 通)的真实端到端测试。Phase B 内部冒烟:
|
||
|
|
|
||
|
|
1. 启 mock backend (PowerShell HttpListener 返回 `{"channelId": 1, "enabledRooms": ["测试群"]}`):
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
$listener = [System.Net.HttpListener]::new()
|
||
|
|
$listener.Prefixes.Add("http://127.0.0.1:7071/")
|
||
|
|
$listener.Start()
|
||
|
|
while ($listener.IsListening) {
|
||
|
|
$ctx = $listener.GetContext()
|
||
|
|
$body = '{"channelId":1,"enabledRooms":["测试群"]}'
|
||
|
|
$bytes = [Text.Encoding]::UTF8.GetBytes($body)
|
||
|
|
$ctx.Response.ContentType = "application/json"
|
||
|
|
$ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
|
||
|
|
$ctx.Response.Close()
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
2. 启 bridge,验证日志中 `handshake 成功 — channelId=1 enabledRooms=1`
|
||
|
|
3. `curl.exe -H "x-neta-tray-secret: test-sec" http://127.0.0.1:7702/rooms` 应返回 `{"rooms":["测试群"]}`
|
||
|
|
4. 在"测试群"发一条消息 → bridge 控制台应有切窗动作 → mock backend 应收到 POST `/inbound`
|
||
|
|
5. 同 minute 同内容再发一次 → bridge 不应推送 (LRU 去重)
|
||
|
|
6. POST `/send {"roomName":"测试群","text":"hello"}` → 应在测试群里看到 "hello"
|
||
|
|
7. Ctrl+C bridge → 优雅退出。
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add packages/windows-tray/Neta.WeChatBridge/Program.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge/Uia/UiaRoomMessageReader.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge/Workers/BackendSinkAdapter.cs \
|
||
|
|
packages/windows-tray/Neta.WeChatBridge/Http/Endpoints/SendEndpoint.cs
|
||
|
|
git commit -m "feat(bridge): wire handshake-first bootstrap with dispatcher + watcher"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 自检 (Self-Review)
|
||
|
|
|
||
|
|
**1. Spec 覆盖:**
|
||
|
|
|
||
|
|
| Spec 章节 | 覆盖 Task |
|
||
|
|
|---|---|
|
||
|
|
| "消息采集与图片处理 · UIA 能读到的群消息字段" | Task 10 (MessageParser) |
|
||
|
|
| "图片与文件的获取路径 · DAT-XOR 解密" | Task 5 + 6 |
|
||
|
|
| "发送回复" | Task 11 + 12 + 15 |
|
||
|
|
| "身份控制 replyIdentity" | bridge 不感知,Plan C 在 finalContent 加前缀后调 /send |
|
||
|
|
| "发送者 wxid 获取" | Plan A Task 8 (留空,靠 senderName 前缀) |
|
||
|
|
| "去重 / 首次发现" | Task 2 (MessageIdHasher) + Task 3 (RoomRegistry.RememberSeen) + Task 19 (dispatcher) + Task 20 (reader 只取最后 10 条) |
|
||
|
|
| "多群监听策略 · 事件源 / 切窗调度" | Task 4 (RoomEventQueue) + Task 18 (SessionListWatcher) + Task 19 |
|
||
|
|
| "Bridge ↔ Backend HTTP · /rooms /send /enable-room /disable-room" | Task 14 + 15 + 16 |
|
||
|
|
| "Bridge ↔ Backend HTTP · POST /inbound" | Task 8 |
|
||
|
|
| "Bridge 冷启动序列 · 步骤 4-6" | Task 18 + 19 + 20 |
|
||
|
|
| "重启不回灌" | Task 20 (UiaRoomMessageReader `start = items.Count - 10`) |
|
||
|
|
| "handshake 返回已启用群列表 + channelId" | Task 8 + Task 13 + Task 20 |
|
||
|
|
|
||
|
|
**Spec 中未覆盖(设计上留到 Plan C/D):**
|
||
|
|
|
||
|
|
- Backend 侧 inbound 入库、session_entry 触发、routeInboundMessage 分流 → Plan C
|
||
|
|
- 语音/视频 (v2)
|
||
|
|
- 前端 UI / 待审批横幅 / 归档页 → Plan D
|
||
|
|
- Tray 拉 bridge / 崩溃自愈 → Plan D (Phase E)
|
||
|
|
|
||
|
|
**2. Placeholder 扫描:** Task 19 dispatcher 里有一个 `// TODO Phase C` 注释 (room-discovered 端点需要 Plan C 的后端实现),这是刻意留的跨 plan hook,不是占位代码;代码是 no-op continue。
|
||
|
|
|
||
|
|
**3. 类型一致性:** `IMessageSender` / `IRoomMessageReader` / `IBackendSink` 在定义后所有使用点字段名、返回值一致。`ParsedMessage` / `InboundPayload` 的字段在 MessageParser / InboundDispatcher / BackendClient 三处引用一致。`HandshakeResult` 在 Plan A 与 Plan B Task 8 都是同一类型,Plan B 扩展了字段但保持向后兼容。
|
||
|
|
|
||
|
|
**4. 跨 Plan 衔接契约:**
|
||
|
|
- `POST /open/netaclaw/channel/uia/handshake` 响应必须包含 `{ channelId: number, enabledRooms: string[] }` → Plan C 必须实现
|
||
|
|
- `POST /open/netaclaw/channel/uia/inbound` body schema (`channelId`, `roomName`, `senderName`, `msgType`, `content`, `rawHash`, `receivedAt`, `attachmentPath?`, `atList?`, `quotedRef?`) → Plan C controller DTO 必须匹配
|
||
|
|
- `replyIdentity` (`silent` / `ai_prefix`) 在 bridge **不感知**,由 Plan C 后端在 finalContent 前拼 `【AI 助手】` 后再调 `POST /send`
|
||
|
|
- 真实 channelId 从 handshake 拿,不再走启动参数传递
|
||
|
|
|
||
|
|
**5. 架构师交叉审查记录 (2026-05-09):**
|
||
|
|
|
||
|
|
本 plan 在初稿后做了一轮系统架构师审查,修复了 10 个问题:
|
||
|
|
|
||
|
|
| # | 严重度 | 问题 | 修复 |
|
||
|
|
|---|---|---|---|
|
||
|
|
| B5/B6/B7/B10 | 🔴 高 | channelId 占位 0 + handshake 后重建 BridgeState 导致已用 roomId 漂移 | `BridgeState.InitRuntimeContainers(channelId, enabledRooms)` 单次注入;handshake 必须在容器初始化前完成;`HandshakeResult` 扩展 `ChannelId` + `EnabledRooms` (Task 8/13/20) |
|
||
|
|
| B8 | 🔴 高 | `LocalizedControlType` 在中文 Windows 返回"按钮""窗格",MessageParser 永不匹配 | 改用 `ProgrammaticName` 取冒号后部分 (Task 20 `AutomationUiNode`) |
|
||
|
|
| B2 | 🟠 中 | `Automation.RemoveAllEventHandlers()` 全局卸载会误删其它订阅者 | 改成精确 `RemoveStructureChangedEventHandler` / `RemoveAutomationPropertyChangedEventHandler` (Task 18) |
|
||
|
|
| B9 | 🟠 中 | endpoint Task (13/14/15) 在 BridgeState 扩展 (16) 之前 — 反 TDD | 重排:Task 13 先扩展 BridgeState + 写单测,然后才写 endpoint;endpoint 测试 setup 调 `InitRuntimeContainers` |
|
||
|
|
| B3/B4 | 🟠 中 | Program.cs / BridgeState 用 partial diff 展示,实施者易 merge 错 | 全部给完整文件替换 (Task 13 / Task 20) |
|
||
|
|
| B1 | 🟡 低 | RoomEventQueue 多消费者唤醒语义不严格 | 在"关键约束"标注**单消费者模型** |
|
||
|
|
| SendCt | 🟡 低 | SendEndpoint 把 `ctx.RequestAborted` 传 UIA,client 断开会导致键盘按键残留 | 改 `CancellationToken.None` (Task 20 Step 4) |
|
||
|
|
| ParserCN | 🟡 低 | MessageParser AtPattern 不覆盖中文标点边界 | 在"关键约束"标注 MVP 限制,v2 再补 |
|
||
|
|
| GB1 | 文档 | dispatcher 里 `// TODO Phase C` 是刻意 hook,需在 plan 里说明 | 自检 §2 中说明 |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Execution Handoff
|
||
|
|
|
||
|
|
Plan B 写作完成,保存至 `docs/superpowers/plans/2026-05-09-wechat-uia-b-bridge-runtime.md`。
|
||
|
|
|
||
|
|
前置依赖:Plan A 必须已合并 (Plan B Task 8 修改 `HandshakeResult` 是向 Plan A 类型的扩展)。
|
||
|
|
|
||
|
|
下一步:写 Plan C (Backend) 之后再决定从哪个开始实施。
|