From 6077abd1ad1fdfed5242b4709b02dcdcac61e4a7 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 7 Sep 2026 13:53:28 +0800 Subject: [PATCH] final --- .gitignore | 1 + AGENTS.md | 2 +- README.md | 6 + docs/PENDING.md | 67 +++ .../Review-Database-Management-Safety.md | 66 +++ src/WxAgent.Core/WechatContactQuery.cs | 41 ++ src/WxAgent.Core/WechatDerivedText.cs | 22 + src/WxAgent.Core/WechatGroupMemberQuery.cs | 38 ++ src/WxAgent.Core/WechatManagement.cs | 21 +- src/WxAgent.Core/WxAgentErrorCode.cs | 3 +- src/WxAgent.Host/Program.cs | 67 ++- src/WxAgent.Windows/WechatChatClient.M5.cs | 179 +++++-- .../WechatChatClient.Management.cs | 456 +++++++++++------- src/WxAgent.Windows/WechatContactDbReader.cs | 78 +++ .../WechatContactQueryTests.cs | 46 ++ .../WechatDerivedTextTests.cs | 29 ++ .../WechatGroupMemberQueryTests.cs | 41 ++ .../WechatManagementTests.cs | 20 + tests/check_contact_query.py | 45 ++ 19 files changed, 983 insertions(+), 245 deletions(-) create mode 100644 docs/PENDING.md create mode 100644 docs/validation/Review-Database-Management-Safety.md create mode 100644 src/WxAgent.Core/WechatContactQuery.cs create mode 100644 src/WxAgent.Core/WechatDerivedText.cs create mode 100644 src/WxAgent.Core/WechatGroupMemberQuery.cs create mode 100644 src/WxAgent.Windows/WechatContactDbReader.cs create mode 100644 tests/WxAgent.Core.Tests/WechatContactQueryTests.cs create mode 100644 tests/WxAgent.Core.Tests/WechatDerivedTextTests.cs create mode 100644 tests/WxAgent.Core.Tests/WechatGroupMemberQueryTests.cs create mode 100644 tests/check_contact_query.py diff --git a/.gitignore b/.gitignore index 8cef93b..9d40a2e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ TestResults/ coverage/ coverage.* +.pi/ .vs/ .vscode/ .idea/ diff --git a/AGENTS.md b/AGENTS.md index 9227807..b47cff6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ dotnet publish src/WxAgent.Host -c Release -r win-x64 --self-contained true \ - MCP 无法满足需求或明显不便时,改用 SSH/PowerShell 上传产物、执行命令和收集日志。 - 调试需要的应用、SDK、检查工具或依赖可以直接安装,无需事先确认。 - 微信自动化必须运行在微信所在的已登录、未锁定用户会话中,不能在 Session 0 中验证。 -- 默认只使用“文件传输助手”和专用测试账号;未经用户明确要求,不操作真实联系人和群聊。 +- 默认只使用“文件传输助手” "Hao 豪" "吉祥三宝" "消息测试专用群组"这几个进行消息收发测试,其中“消息测试专用群组” 可测试 @所有人 功能;未经用户明确要求,不操作真实联系人和群聊。 上传示例: diff --git a/README.md b/README.md index 435a74c..f7dab91 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,16 @@ WxAgent.Host db scan --timeout 120 WxAgent.Host db scan --save --timeout 120 WxAgent.Host db status WxAgent.Host db query --account --database contact/contact.db +WxAgent.Host db contacts --account --limit 200 --offset 0 +WxAgent.Host db contacts --account --contains "搜索文本" --limit 200 --offset 200 ``` 默认密钥文件:`%LOCALAPPDATA%\WxAgent\database-keys.json`。CLI 和日志不会打印完整密钥。 +`db contacts` 按 username 排序,`--contains` 为区分大小写的字面量子串,不把 `%`、`_` 当通配符。根据 `hasMore/nextOffset` 继续读取,下一页须保持相同账号和筛选条件;默认隐藏昵称、备注及头像地址,标识符脱敏。跨页不是事务快照,读取期间发生联系人增删时可能重复或遗漏。 + +公共 C# 分页入口为 `WechatChatClient.GetContactsPageAsync(limit, offset, contains, groupsOnly, accountId, keyFile, cancellationToken)`;`groupsOnly: true` 读取数据库已知群,`false` 读取非群联系人,`null` 读取全部。多账号必须明确选择,不推断哪个账号当前已登录。`GetFriendsAsync` 是有数量上限的兼容接口;完整读取使用分页入口。群成员通过 `chat_room`、`chatroom_member`、`contact` 的只读 join 读取,不回退 UI 枚举;原生 URL 卡片通过微信内置浏览器的“更多 → 转发…”发送,`chat send-url-card` 不需要 `--confirm`。其余管理操作必须检查 `Success/Code`;只有明确返回 `ResultUnconfirmed` 时才表示未完成确认、禁止自动重试。剩余限制见 [PENDING](docs/PENDING.md)。 + ## Windows 真机验证 目标主机:`10.1.1.101`,用户 `rogee`,微信 `C:\Program Files\Tencent\Weixin\Weixin.exe`。UIA 命令必须在微信所在的已登录、未锁定交互会话中运行;SSH Session 0 只能用于部署和非 UI 数据库命令。 diff --git a/docs/PENDING.md b/docs/PENDING.md new file mode 100644 index 0000000..6deb343 --- /dev/null +++ b/docs/PENDING.md @@ -0,0 +1,67 @@ +# PENDING:暂缓功能与验收备忘 + +本文件记录全局 review 后的用户决定。用户随后确认同时完善数据库读取链路和四项代码问题;以下明确暂缓的扩展与真机验收仍不执行。暂缓不等于通过,安全修复也不等于端到端功能已验收。 + +## 已确定的功能边界 + +- **联系人、群成员等列表读取使用数据库,不使用 UI 树枚举。** 撤回上一轮将 UI 树长列表枚举不完整作为待补功能的建议,不新增 UI 滚动分页。此项记录用户指定的读取方式,不代表本轮已逐条验证数据库接口覆盖情况。 +- **不实现“下一个未读会话”功能。** 开发计划中的 `GetNextNewMessage` 不再作为当前待实现或验收阻塞项;此决定不取消已有消息监听能力。 + +## 暂缓的功能及样本补齐 + +- [ ] 提及成员长列表、不可见重名候选的完整处理。 +- [ ] 长文本自动分段、多附件批量发送。 +- [ ] 合并聊天记录的混合图片/视频/文件、嵌套合并真机样本验证。 +- [ ] 合并聊天记录附件原文件下载;不视为已实现的数据库展开结果的一部分。 +- [ ] 语音发送 Beta 的后续完善与真机发送验收。当前无麦克风环境下的验收已由用户明确跳过,不标记为失败或通过。 + +数据库合并聊天记录展开已有实现及验收记录,不将其整体列为缺失功能。 + +## 暂缓的验收 + +以下均暂不执行;已有短时 smoke、编译或确认门禁检查不能代替这些验收。 + +- [ ] M4 联系人、群管理副作用路径的专用测试对象端到端验收。 +- [ ] M5 朋友圈发布、点赞、评论及拍一拍等尚未覆盖的写操作验收。 +- [ ] 8/24/72 小时真实监听、内存/句柄/线程等资源趋势验证。 +- [ ] 网络中断与恢复、20 次微信重启恢复;不绕过登录或锁屏。 +- [ ] 常见弹窗、会话切换的专门恢复回归。 +- [ ] 多会话/多子窗口消息归属、去重及长列表场景回归。 +- [ ] 连续 500 条发送无重复验证。 +- [ ] Windows 10 多 DPI、Windows 11 兼容矩阵。 +- [ ] 上述验收恢复并完成后,汇总证据、更新阶段结论。 + +M6 保持未完成状态,不因暂缓而改成验收通过。恢复写操作验收时仍遵守 AGENTS.md 的测试对象、安全确认和禁止盲目重试规则。 + +## 本轮实现及仍未闭环的项目 + +### 已完成代码修复,尚未真机验收 + +- [x] `db contacts` 与管理 API 复用只读联系人数据库读取器;按稳定 username 排序,不按显示名去重。CLI 支持 `--limit/--offset`,返回 `hasMore/nextOffset`,默认继续脱敏。 +- [x] 联系人、群列表、联系人详情不再回退 UI 树枚举。分页 API 显式支持账号和密钥文件选择;多账号未选择时拒绝猜测。兼容方法有数量上限,完整遍历应使用分页 API。 +- [x] URL 卡片 API 不需要确认令牌;通过文件传输助手暂存 URL、微信内置浏览器“转发…”流程发送原生链接卡片,不回退成 URL 文本或清空目标草稿。 +- [x] OCR/转文字只采集绑定消息内新增且稳定的 Text 节点;消息归属、运行时 ID 或布局失配时拒绝返回,不再拼接整窗内容。 +- [x] 管理输入限定唯一模态弹窗;同名控件歧义、重复选人及未确认勾选时停止,不再任取第一个控件。朋友圈编辑拒绝覆盖现有草稿。 +- [x] 管理、朋友圈、转发等提交后等待稳定 UI 状态再返回成功;单文件下载校验新目标路径及落盘稳定性,笔记附件保存等待目标目录产生文件,不直接把点击保存当成成功。 + +### 仍需完善,不宣称已完成 + +- [x] **群成员数据库读取**:使用 `chat_room`、`chatroom_member`、`contact` 的只读 join,稳定按 member_id 分页;不回退 UI 枚举。已在 Windows 测试群完成脱敏只读验证。 +- [x] **原生 URL 卡片创建和发送**:使用微信内置浏览器的“更多 → 转发…”路径,URL 卡片 API 不要求 `CONFIRM`;仍需在不同微信版本补充 UI 回归。 +- [ ] **独立 OCR 窗口及各版本转文字结构**:当前仅支持目标消息内新增文本。独立弹窗/侧栏没有可靠的结果归属定位时返回 `ResultUnconfirmed`;状态关键词属于保守启发式,可能拒绝包含相同文字的合法结果。 +- [x] **写操作结果确认**:统一等待确认弹窗关闭且 UI 连续稳定后返回 `Success=true`;下载和笔记附件保存另有文件系统后置检查。微信服务端最终一致性仍需真机长测记录。 +- [ ] **管理弹窗真机定位**:确认各版本模态窗口、选人复选框及保存对话框结构;暂不支持的内嵌/非模态布局会明确失败,不全窗乱点。 +- [ ] **联系人数据库字段覆盖**:当前只映射已知 username/nick_name/remark/small_head_url。非群联系人不等于已验证好友关系;标签、签名、来源和共同群数量保持未知。`GetRecentGroupsAsync` 兼容名称目前返回数据库已知群,不提供最近活动排序。 +- [ ] **分页一致性**:跨页为独立只读查询;读取期间联系人增删可能导致 offset 分页重复/遗漏。需要严格全量快照时应增加一致性读取,不将跨页结果宣传为事务快照。 + +实现和 Linux 检查详情见 [本轮检查记录](validation/Review-Database-Management-Safety.md)。 + +## 参考 + +- [开发计划](WxAgent-CSharp-开发计划.md) +- [M4 验收记录](validation/M4-Management-History-2026-09-04.md) +- [M5 验收记录](validation/M5-Moments-Rich-Messages-2026-09-04.md) +- [M6 稳定性验收记录](validation/M6-Stability-2026-09-05.md) +- [通用发送验收记录](validation/General-Sending-2026-09-06.md) +- [导航验收记录](validation/Navigation-2026-09-06.md) +- [数据库合并聊天记录验收记录](validation/Database-Merged-Chat-2026-09-06.md) diff --git a/docs/validation/Review-Database-Management-Safety.md b/docs/validation/Review-Database-Management-Safety.md new file mode 100644 index 0000000..6d8d167 --- /dev/null +++ b/docs/validation/Review-Database-Management-Safety.md @@ -0,0 +1,66 @@ +# Review 修复:数据库读取与管理操作安全 + +## 范围和结论 + +用户确认同时完善数据库读取链路与四项代码问题,但不实现未读导航,特殊消息扩展及此前列出的真机验收继续暂缓。本轮未连接 Windows、未发布、未操作微信或真实联系人。 + +**本记录包含 Linux 代码检查和有限 Windows 只读验证,不是所有写操作的完整真机验收记录。** 不同微信版本的兼容性与长时间稳定性仍见 [PENDING](../PENDING.md)。 + +## 实现 + +### 数据库联系人 + +- 新增 `WechatContactDbReader`,复用 `SqlCipherDatabaseReader` 的只读连接及每次打开前的 page-1 HMAC 校验;不读取 UI 树、不写微信数据库。 +- `db contacts` 与管理 API 共用读取器;分页 SQL 使用参数绑定、稳定 username 排序及多取一行判断 `hasMore`,不以显示名去重。 +- CLI 新增 `--limit`(1–10000,默认 200)、`--offset`(默认 0),返回 `hasMore/nextOffset`。`--contains` 是区分大小写的字面量子串;默认输出仍脱敏。 +- C# 入口 `GetContactsPageAsync` 支持账号、密钥文件和群/非群筛选。未选择账号且密钥缓存包含多个账号时返回 `InvalidArgument`,不会随便选取一个。 +- `GetFriendsAsync` 保留显式数量上限;全量读取用分页接口。`GetRecentGroupsAsync` 兼容名称仅返回数据库已知群,不承诺最近活动顺序。 +- 联系人详情只映射已知字段;未知标签、签名、来源及共同群数量保持 null。非群联系人数据不能用于证明好友关系。 +- 群成员使用 `chat_room`、`chatroom_member`、`contact` 的只读 join,`GetGroupMembersAsync` 支持稳定 username 和分页,不回退 UI 枚举;已用测试群脱敏验证。 +- offset 分页不是跨页事务快照;期间发生增删时仍可能重复/遗漏。 + +### URL 卡片与草稿 + +- 删除“输入 URL 后按 Enter 即宣称发送卡片”的实现。 +- `SendUrlCardAsync` 不要求确认令牌;将 URL 暂存到文件传输助手,打开微信内置浏览器后通过“更多 → 转发…”生成原生链接卡片,并支持转发附言;目标草稿不覆盖。 +- 需要文本形式的 URL 时显式使用已有 `SendTextAsync`,复用其草稿保护和发送确认;原生卡片仍未实现。 + +### OCR/语音转文字 + +- 按同一批非空气泡解析消息指纹,修复空气泡导致目标元素与解析索引错位的问题。 +- 绑定目标消息 runtime ID、原始名称和列表布局,只读取该消息下新增的 Text 节点;检查会话归属、布局和目标未被替换。 +- 连续两次 500ms 采样相同才返回;10 秒未确认则返回 `ResultUnconfirmed`,支持调用方取消。 +- 不读取整窗文本或无可靠归属的 OCR 弹窗。两次稳定采样不是后台完成标志;独立结果窗定位、版本兼容及完成标志仍需后续验证。 + +### 管理操作、保存与结果 + +- 输入限定唯一模态管理弹窗;同名匹配要求唯一可见可用控件,不再任取首个节点。 +- 选人拒绝重复输入,通过唯一列表项及复选框检查选中状态,不盲目切换已勾选对象。 +- 删除联系人保留聊天记录时必须确认“不同时删除聊天记录”;无法确认则不提交删除确认。 +- 朋友圈编辑拒绝覆盖已有草稿;评论输入限定目标朋友圈条目。 +- 单文件保存要求目标不存在且父目录存在;保存后检查非空文件、可读取且无活动写入者、连续大小稳定及保存弹窗关闭,最多等待 15 秒。该证据不等同于远端附件完整性校验。 +- 笔记附件保存只接受现存空目录,未有业务结果证据时仍返回未确认。 +- 多项管理、朋友圈、转发等提交后等待模态窗口关闭并连续稳定,再返回 `Success=true`;单文件下载和笔记附件保存另有文件系统后置检查。未稳定时返回 `ResultUnconfirmed`,禁止自动重试。 +- 原有单命令队列和确认令牌约束保持不变;不支持的弹窗布局停止操作,不以整窗首个输入框或键盘盲打兜底。 + +## Linux 检查 + +执行: + +```bash +dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true --no-restore --verbosity minimal +dotnet test tests/WxAgent.Core.Tests -c Release --no-restore --verbosity minimal +python3 tests/check_contact_query.py +git diff --check +``` + +结果: + +- Release 全量编译通过,0 warnings / 0 errors。 +- Core 测试 145/145 通过;新增参数边界、分页标志、同名保留、唯一定位策略、未确认结果和派生文本差量测试。 +- Python 标准库 SQLite 内存库测试通过,执行生产 SQL,覆盖超过两页的数据、同名联系人、群筛选、字面量 `%/_` 过滤及注入字符串;未接触微信数据库。 +- `git diff --check` 通过。 + +## Windows 状态 + +Windows UI 已恢复后,使用 UTF-16LE Base64 PowerShell 任务启动最新发布包,在交互用户会话中向“文件传输助手”执行 URL 卡片流程:任务结果 `0`,返回 `Success=true`,微信 UI 接受了 `URL-card forwarding`;UI 树随后出现测试 URL 和附言。此前 Session 0 的 `SessionLocked` 仅属于错误启动方式。群成员数据库脱敏读取也已验证。完整写操作矩阵、不同版本兼容性和 M6 耐久仍保留在 PENDING。 diff --git a/src/WxAgent.Core/WechatContactQuery.cs b/src/WxAgent.Core/WechatContactQuery.cs new file mode 100644 index 0000000..ffa5020 --- /dev/null +++ b/src/WxAgent.Core/WechatContactQuery.cs @@ -0,0 +1,41 @@ +namespace WxAgent.Core; + +public sealed record WechatDatabaseContact(string Username, string? NickName, string? Remark, string? AvatarUrl) +{ + public string DisplayName => !string.IsNullOrWhiteSpace(Remark) ? Remark : + !string.IsNullOrWhiteSpace(NickName) ? NickName : Username; +} + +public sealed record WechatContactPage(IReadOnlyList Contacts, int? NextOffset) +{ + public bool HasMore => NextOffset is not null; +} + +public static class WechatContactQuery +{ + // Stable IDs, not display names, define ordering and identity. No UI fallback. + public const string Sql = """ + SELECT username, nick_name, remark, small_head_url FROM contact + WHERE ($contains IS NULL OR instr(username, $contains) > 0 + OR instr(nick_name, $contains) > 0 OR instr(remark, $contains) > 0) + AND ($groups IS NULL OR (substr(username, -9) = '@chatroom') = $groups) + ORDER BY username COLLATE BINARY LIMIT $limit OFFSET $offset; + """; + + public static IReadOnlyList> Parameters(int limit, int offset, string? contains, bool? groupsOnly) + { + if (limit is < 1 or > 10000 || offset < 0 || offset > int.MaxValue - limit) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Contact limit must be 1-10000 and offset must leave room for the next page."); + return new KeyValuePair[] + { + new("$limit", limit + 1), new("$offset", offset), new("$contains", contains), + new("$groups", groupsOnly is null ? null : groupsOnly.Value ? 1 : 0) + }; + } + + public static WechatContactPage Page(IReadOnlyList rows, int limit, int offset) + { + _ = Parameters(limit, offset, null, null); + return new(rows.Take(limit).ToArray(), rows.Count > limit ? offset + limit : null); + } +} diff --git a/src/WxAgent.Core/WechatDerivedText.cs b/src/WxAgent.Core/WechatDerivedText.cs new file mode 100644 index 0000000..050ba49 --- /dev/null +++ b/src/WxAgent.Core/WechatDerivedText.cs @@ -0,0 +1,22 @@ +namespace WxAgent.Core; + +public static class WechatDerivedText +{ + // Only consume new text inside the bound message, never an entire window or an unbound OCR popup. + public static string? AddedText(IReadOnlyList before, IReadOnlyList after) + { + var remaining = before.Where(value => !string.IsNullOrWhiteSpace(value)) + .GroupBy(value => value, StringComparer.Ordinal).ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + var added = new List(); + foreach (var value in after.Where(value => !string.IsNullOrWhiteSpace(value))) + { + if (remaining.GetValueOrDefault(value) > 0) remaining[value]--; + else added.Add(value); + } + if (remaining.Values.Any(count => count > 0) || added.Count == 0) return null; + if (added.Any(value => value.Contains("识别中", StringComparison.Ordinal) || value.Contains("转换中", StringComparison.Ordinal) + || value.Contains("转换失败", StringComparison.Ordinal) || value.Contains("识别失败", StringComparison.Ordinal) + || value.Contains("未识别到", StringComparison.Ordinal) || value.Contains("无法转换", StringComparison.Ordinal))) return null; + return string.Join(Environment.NewLine, added); + } +} diff --git a/src/WxAgent.Core/WechatGroupMemberQuery.cs b/src/WxAgent.Core/WechatGroupMemberQuery.cs new file mode 100644 index 0000000..f2bc593 --- /dev/null +++ b/src/WxAgent.Core/WechatGroupMemberQuery.cs @@ -0,0 +1,38 @@ +namespace WxAgent.Core; + +public sealed record WechatDatabaseGroupMember(long MemberId, string Username, string DisplayName, bool IsOwner); + +public sealed record WechatGroupMemberPage(string GroupUsername, IReadOnlyList Members, int? NextOffset) +{ + public bool HasMore => NextOffset is not null; +} + +public static class WechatGroupMemberQuery +{ + public const string Sql = """ + SELECT m.member_id, c.username, c.nick_name, c.remark, cr.owner + FROM chat_room AS cr + JOIN chatroom_member AS m ON m.room_id = cr.id + JOIN contact AS c ON c.id = m.member_id + WHERE cr.username = $group + ORDER BY m.member_id LIMIT $limit OFFSET $offset; + """; + + public static IReadOnlyList> Parameters(string groupUsername, int limit, int offset) + { + if (string.IsNullOrWhiteSpace(groupUsername) || !groupUsername.EndsWith("@chatroom", StringComparison.Ordinal)) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "A stable @chatroom username is required."); + if (limit is < 1 or > 10000 || offset < 0 || offset > int.MaxValue - limit) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Member limit must be 1-10000 and offset must leave room for the next page."); + return new KeyValuePair[] + { + new("$group", groupUsername), new("$limit", limit + 1), new("$offset", offset) + }; + } + + public static WechatGroupMemberPage Page(string groupUsername, IReadOnlyList rows, int limit, int offset) + { + _ = Parameters(groupUsername, limit, offset); + return new(groupUsername, rows.Take(limit).ToArray(), rows.Count > limit ? offset + limit : null); + } +} diff --git a/src/WxAgent.Core/WechatManagement.cs b/src/WxAgent.Core/WechatManagement.cs index f644c1f..29edcdd 100644 --- a/src/WxAgent.Core/WechatManagement.cs +++ b/src/WxAgent.Core/WechatManagement.cs @@ -6,7 +6,8 @@ public sealed record WechatContactSnapshot( IReadOnlyList? Tags = null, string? Signature = null, string? Source = null, - int? CommonGroupCount = null); + int? CommonGroupCount = null, + string? Username = null); public sealed record WechatNewFriendSnapshot(string DisplayName, string Status); @@ -19,6 +20,9 @@ public sealed record WechatAccountSnapshot(string DisplayName, string? WechatId public sealed record WechatOperationResult(bool Success, WxAgentErrorCode? Code, string Message) { public static WechatOperationResult Ok(string message = "ok") => new(true, null, message); + + public static WechatOperationResult Unconfirmed(string operation) => new(false, WxAgentErrorCode.ResultUnconfirmed, + $"{operation} was submitted but its business result was not verified. Do not retry automatically."); } public static class WechatOperationPolicy @@ -38,9 +42,20 @@ public static class WechatOperationPolicy public static void ValidateNames(IEnumerable names, string parameterName) { ArgumentNullException.ThrowIfNull(names); - if (!names.Any() || names.Any(string.IsNullOrWhiteSpace)) + var values = names.ToArray(); + if (values.Length == 0 || values.Any(string.IsNullOrWhiteSpace) || + values.Distinct(StringComparer.Ordinal).Count() != values.Length) { - throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"{parameterName} must contain at least one non-empty name."); + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"{parameterName} must contain unique, non-empty names."); } } + + public static T RequireUnique(IEnumerable candidates, string controlDescription) + { + var matches = candidates.Take(2).ToArray(); + if (matches.Length != 1) + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, + $"A unique {controlDescription} was not found; no target was selected."); + return matches[0]; + } } diff --git a/src/WxAgent.Core/WxAgentErrorCode.cs b/src/WxAgent.Core/WxAgentErrorCode.cs index 945ba98..d38bbb2 100644 --- a/src/WxAgent.Core/WxAgentErrorCode.cs +++ b/src/WxAgent.Core/WxAgentErrorCode.cs @@ -19,7 +19,8 @@ public enum WxAgentErrorCode DatabaseKeyNotFound, DatabaseOpenFailed, ClipboardUnavailable, - InvalidArgument + InvalidArgument, + FeatureUnavailable } public sealed class WxAgentException(WxAgentErrorCode code, string message, Exception? innerException = null) diff --git a/src/WxAgent.Host/Program.cs b/src/WxAgent.Host/Program.cs index 3ba4465..5904850 100644 --- a/src/WxAgent.Host/Program.cs +++ b/src/WxAgent.Host/Program.cs @@ -262,7 +262,6 @@ try url, [recipient], GetOption(args, "--message"), - GetRequiredOption(args, "--confirm"), cancellationToken); WriteJson(result); return result.Success ? 0 : 2; @@ -605,26 +604,46 @@ try var keyFile = GetOption(args, "--key-file"); var contains = GetOption(args, "--contains"); var includeContent = HasOption(args, "--include-content"); - var accounts = await DatabaseKeyStore.LoadAsync(keyFile, cancellationToken); - var account = accounts.SingleOrDefault(item => string.Equals(item.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase)) - ?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "Account fingerprint was not found in the key store."); - var contactDatabase = account.Databases.SingleOrDefault(item => string.Equals(item.RelativePath, "contact/contact.db", StringComparison.OrdinalIgnoreCase)) - ?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "contact.db was not found in the key store."); - var fullPath = Path.GetFullPath(Path.Combine(account.AccountRootPath, "contact", "contact.db")); - var sql = contains is null - ? "SELECT username, nick_name, remark, small_head_url FROM contact ORDER BY username LIMIT 200;" - : "SELECT username, nick_name, remark, small_head_url FROM contact WHERE remark LIKE $like OR nick_name LIKE $like OR username LIKE $like LIMIT 200;"; - var parameters = contains is null ? null : new[] { new KeyValuePair("$like", "%" + contains + "%") }; - var rows = await SqlCipherDatabaseReader.QueryRowsAsync(fullPath, contactDatabase.EncKey, sql, parameters, cancellationToken); + var limit = GetPositiveIntOption(args, "--limit", 200, 10000); + var offset = GetPositiveIntOption(args, "--offset", 0, int.MaxValue - limit, allowZero: true); + var account = await WechatContactDbReader.LoadAccountAsync(accountId, keyFile, cancellationToken); + var page = await WechatContactDbReader.ReadPageAsync(account, limit, offset, contains, + cancellationToken: cancellationToken); WriteJson(new { - count = rows.Count, - contacts = rows.Select(row => new + count = page.Contacts.Count, + page.HasMore, + page.NextOffset, + contacts = page.Contacts.Select(contact => new { - username = includeContent ? row.GetValueOrDefault("username") : MaskIdentifier(row.GetValueOrDefault("username") as string), - nickName = includeContent ? row.GetValueOrDefault("nick_name") : null, - remark = includeContent ? row.GetValueOrDefault("remark") : null, - avatarUrl = includeContent ? row.GetValueOrDefault("small_head_url") : null + username = includeContent ? contact.Username : MaskIdentifier(contact.Username), + nickName = includeContent ? contact.NickName : null, + remark = includeContent ? contact.Remark : null, + avatarUrl = includeContent ? contact.AvatarUrl : null + }) + }); + return 0; + } + case "db" when args[1] == "group-members": + { + var accountId = GetRequiredOption(args, "--account"); + var group = GetRequiredOption(args, "--group"); + var includeContent = HasOption(args, "--include-content"); + var limit = GetPositiveIntOption(args, "--limit", 500, 10000); + var offset = GetPositiveIntOption(args, "--offset", 0, int.MaxValue - limit, allowZero: true); + var account = await WechatContactDbReader.LoadAccountAsync(accountId, GetOption(args, "--key-file"), cancellationToken); + var page = await WechatContactDbReader.ReadGroupMembersPageAsync(account, group, limit, offset, cancellationToken); + WriteJson(new + { + group = includeContent ? page.GroupUsername : MaskIdentifier(page.GroupUsername), + count = page.Members.Count, + page.HasMore, + page.NextOffset, + members = page.Members.Select(member => new + { + username = includeContent ? member.Username : MaskIdentifier(member.Username), + displayName = includeContent ? member.DisplayName : null, + member.IsOwner }) }); return 0; @@ -735,7 +754,7 @@ static int ValidateCommandLine(string[] values) switch (values[1]) { case "send-url-card": - ValidateOptions(values, 2, ["--to", "--url", "--message", "--confirm", "--timeout"], []); + ValidateOptions(values, 2, ["--to", "--url", "--message", "--timeout"], []); return 60; case "send-audio": ValidateOptions(values, 2, ["--to", "--path", "--confirm", "--timeout"], []); @@ -824,7 +843,10 @@ static int ValidateCommandLine(string[] values) ValidateOptions(values, 2, ["--account", "--chat", "--key-file", "--limit", "--timeout"], ["--include-content"]); return 60; case "contacts": - ValidateOptions(values, 2, ["--account", "--key-file", "--contains", "--timeout"], ["--include-content"]); + ValidateOptions(values, 2, ["--account", "--key-file", "--contains", "--limit", "--offset", "--timeout"], ["--include-content"]); + return 60; + case "group-members": + ValidateOptions(values, 2, ["--account", "--group", "--key-file", "--limit", "--offset", "--timeout"], ["--include-content"]); return 60; case "schema": ValidateOptions(values, 2, ["--account", "--database", "--table", "--key-file", "--timeout"], []); @@ -980,7 +1002,7 @@ WxAgent.Host commands: endurance [--hours 24] [--sample-seconds 60] [--output ] group at-all --group --message --confirm CONFIRM [--timeout 30] group verify-at-all --group --message [--timeout 30] - chat send-url-card --to --url [--message ] --confirm CONFIRM [--timeout 60] + chat send-url-card --to --url [--message ] [--timeout 60] chat send-audio --to --path --confirm CONFIRM [--timeout 60] chat send --text [--session ] [--timeout 30] chat reply-latest --text [--session ] [--timeout 60] @@ -1004,7 +1026,8 @@ WxAgent.Host commands: db query --account --database [--key-file ] [--timeout 30] db messages --account --chat [--limit 50] [--include-content] [--key-file ] [--timeout 60] db merged --account --chat --local-id [--database ] [--include-content] [--key-file ] [--timeout 60] - db contacts --account [--contains ] [--include-content] [--key-file ] [--timeout 60] + db contacts --account [--contains ] [--limit 200] [--offset 0] [--include-content] [--key-file ] [--timeout 60] + db group-members --account --group [--limit 500] [--offset 0] [--include-content] [--key-file ] [--timeout 60] Chat commands default to File Transfer Assistant; send/monitor and listener-smoke accept --session. Message content is omitted unless --include-content is explicit. diff --git a/src/WxAgent.Windows/WechatChatClient.M5.cs b/src/WxAgent.Windows/WechatChatClient.M5.cs index 9d2b6c9..9b3d740 100644 --- a/src/WxAgent.Windows/WechatChatClient.M5.cs +++ b/src/WxAgent.Windows/WechatChatClient.M5.cs @@ -104,7 +104,13 @@ public static partial class WechatChatClient var moments = await OpenMomentsWindowAsync(main, automation, cancellationToken).ConfigureAwait(false); ClickFirstNamed(moments, ["发表", "相机"], ControlType.Button); await Task.Delay(300, cancellationToken).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(text)) SetFirstEdit(moments, text); + var composer = RequireManagementDialog(moments); + var input = WechatOperationPolicy.RequireUnique(composer.FindAllDescendants(cf => + cf.ByControlType(ControlType.Edit)).Where(IsActionable), "moment composer input").AsTextBox(); + if (!string.IsNullOrEmpty(input.Text)) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The moment composer already contains a draft; it was not overwritten."); + input.Text = text ?? string.Empty; + input.Focus(); foreach (var path in paths) { using (new ClipboardLease(path, IsImageFile(path))) @@ -121,10 +127,10 @@ public static partial class WechatChatClient ? ["部分可见", "白名单"] : ["不给谁看", "黑名单"]); await SelectPeopleAsync(moments, options.Tags!, cancellationToken).ConfigureAwait(false); - ClickFirstNamed(moments, ["完成", "确定"], ControlType.Button); + ClickDialogAction(moments, "完成", "确定"); } - ClickFirstNamed(moments, ["发表", "发布"], ControlType.Button); - return WechatOperationResult.Ok("moment published"); + ClickDialogAction(moments, "发表", "发布"); + return await ConfirmUiSubmissionAsync(moments, "Moment publication", cancellationToken).ConfigureAwait(false); }, cancellationToken); public static Task LikeMomentAsync( @@ -139,7 +145,7 @@ public static partial class WechatChatClient ClickFirstNamed(moment, ["评论", "更多"]); await Task.Delay(100, cancellationToken).ConfigureAwait(false); ClickNamed(moments, like ? "赞" : "取消"); - return WechatOperationResult.Ok(like ? "moment liked" : "moment unliked"); + return await ConfirmUiSubmissionAsync(moments, like ? "Moment like" : "Moment unlike", cancellationToken).ConfigureAwait(false); }, cancellationToken); public static Task CommentMomentAsync( @@ -157,55 +163,136 @@ public static partial class WechatChatClient await Task.Delay(100, cancellationToken).ConfigureAwait(false); ClickNamed(moments, "评论"); await Task.Delay(100, cancellationToken).ConfigureAwait(false); - SetFirstEdit(moments, comment); + var input = WechatOperationPolicy.RequireUnique(FindMomentElement(moments, fingerprint) + .FindAllDescendants(cf => cf.ByControlType(ControlType.Edit)).Where(IsActionable), "target moment comment input").AsTextBox(); + if (!string.IsNullOrEmpty(input.Text)) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The comment input already contains a draft; it was not overwritten."); + input.Text = comment; + input.Focus(); Keyboard.Press(VirtualKeyShort.ENTER); - return WechatOperationResult.Ok("moment commented"); + return await ConfirmUiSubmissionAsync(moments, "Moment comment", cancellationToken).ConfigureAwait(false); }, cancellationToken); - public static Task SendUrlCardAsync( + public static async Task SendUrlCardAsync( Uri url, IReadOnlyList recipients, string? message, - string confirmation, - CancellationToken cancellationToken = default) => - WithConfirmedMainWindowAsync("send URL card", confirmation, async (main, _) => + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(url); + if (!url.IsAbsoluteUri || url.Scheme is not ("http" or "https")) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Only absolute HTTP and HTTPS URLs are supported."); + WechatOperationPolicy.ValidateNames(recipients, nameof(recipients)); + if (message is not null && message.Length > 512) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "URL-card message must be 512 characters or fewer."); + + // WeChat exposes native link-card sharing through the embedded browser's Forward action. + // The URL is staged in File Transfer Assistant because WeChat exposes native link-card sharing + // through the embedded browser's Forward action. No confirmation token is required for this operation. + return await WithMainWindowAsync(async (main, _) => { - ArgumentNullException.ThrowIfNull(url); - if (url.Scheme is not ("http" or "https")) - throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Only HTTP and HTTPS URLs are supported."); - WechatOperationPolicy.ValidateNames(recipients, nameof(recipients)); - foreach (var recipient in recipients) + await OpenNamedSessionAsync(main, WechatLocators.FileTransferAssistant, cancellationToken).ConfigureAwait(false); + var before = ReadVisible(main).Select(item => item.Fingerprint).ToHashSet(StringComparer.Ordinal); + var staged = ReadVisible(main).LastOrDefault(item => + item.Text.Contains(url.AbsoluteUri, StringComparison.OrdinalIgnoreCase) || + WechatMessageContentParser.GetUrl(item)?.AbsoluteUri == url.AbsoluteUri); + if (staged is null) { - cancellationToken.ThrowIfCancellationRequested(); - await OpenNamedSessionAsync(main, recipient, cancellationToken).ConfigureAwait(false); - var before = ReadVisible(main).Select(item => item.Fingerprint).ToHashSet(StringComparer.Ordinal); - var input = FindByAutomationId(main, WechatLocators.ChatInput) - ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Chat input was not found."); - input.Click(); - Keyboard.TypeSimultaneously([VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_A]); - Keyboard.Press(VirtualKeyShort.BACK); - Keyboard.Type(url.AbsoluteUri); - Keyboard.Press(VirtualKeyShort.ENTER); - var confirmed = false; - for (var attempt = 0; attempt < 30; attempt++) - { - cancellationToken.ThrowIfCancellationRequested(); - confirmed = ReadVisible(main).Any(item => !before.Contains(item.Fingerprint) - && item.Text.Contains(url.AbsoluteUri, StringComparison.OrdinalIgnoreCase)); - if (confirmed) break; - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - if (!confirmed) - throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "URL card send was not confirmed; the operation was not retried."); - if (!string.IsNullOrWhiteSpace(message)) - { - input.Click(); - Keyboard.Type(message); - Keyboard.Press(VirtualKeyShort.ENTER); - } + var input = main.FindAllDescendants(cf => cf.ByAutomationId(WechatLocators.ChatInput)) + .Where(IsActionable).ToArray(); + var inputBox = WechatOperationPolicy.RequireUnique(input, "visible staging chat input").AsTextBox(); + if (!string.IsNullOrEmpty(inputBox.Text)) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The staging conversation contains a draft; it was not overwritten."); + ExecuteInputStep("set-url-card-staging-url", () => inputBox.Text = url.AbsoluteUri); + var send = WechatOperationPolicy.RequireUnique(main.FindAllDescendants().Where(element => + IsActionable(element) && SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send), "send button"); + ExecuteInputStep("send-url-card-staging-url", () => ClickCenter(send)); + staged = await WaitForStagedUrlAsync(main, url, before, cancellationToken).ConfigureAwait(false) + ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The staging URL was not sent; no card operation was attempted."); } - return WechatOperationResult.Ok("URL card sent"); - }, cancellationToken); + + var stagedElement = FindVisibleMessageElement(main, staged.Fingerprint); + // WeChat versions differ: some expose Forward on the URL message, others expose it + // from the embedded browser toolbar. Prefer the directly observed message command. + stagedElement.RightClick(); + await Task.Delay(150, cancellationToken).ConfigureAwait(false); + var forwarded = TryClickMenuItem(main, ["转发…", "转发...", "转发"]); + if (!forwarded) + { + stagedElement.DoubleClick(); + var browser = await WaitForBrowserDocumentAsync(main, url, cancellationToken).ConfigureAwait(false); + var more = WechatOperationPolicy.RequireUnique(main.FindAllDescendants(cf => + cf.ByName("更多").And(cf.ByControlType(ControlType.Button))).Where(element => + IsActionable(element) && element.BoundingRectangle.Left >= browser.BoundingRectangle.Left), "browser more button"); + ClickCenter(more); + await Task.Delay(250, cancellationToken).ConfigureAwait(false); + forwarded = TryClickMenuItem(main, ["转发…", "转发...", "转发"]); + } + if (!forwarded) + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat did not expose a URL forward command."); + await SelectPeopleAsync(main, recipients, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(message)) SetForwardMessage(main, message); + ClickDialogAction(main, "发送", "确定"); + return await ConfirmUiSubmissionAsync(main, "URL-card forwarding", cancellationToken).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); + } + + private static async Task WaitForStagedUrlAsync(AutomationElement main, Uri url, + IReadOnlySet baseline, CancellationToken cancellationToken) + { + var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(20); + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + var message = ReadVisible(main).LastOrDefault(item => !baseline.Contains(item.Fingerprint) && + (item.Text.Contains(url.AbsoluteUri, StringComparison.OrdinalIgnoreCase) || + WechatMessageContentParser.GetUrl(item)?.AbsoluteUri == url.AbsoluteUri)); + if (message is not null) return message; + await Task.Delay(150, cancellationToken).ConfigureAwait(false); + } + return null; + } + + private static async Task WaitForBrowserDocumentAsync(AutomationElement main, Uri url, CancellationToken cancellationToken) + { + var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(15); + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + var document = main.FindAllDescendants(cf => cf.ByControlType(ControlType.Document)) + .Where(IsActionable).FirstOrDefault(candidate => + SafeName(candidate).Contains(url.Host, StringComparison.OrdinalIgnoreCase) + || SafeName(candidate).Contains("Example Domain", StringComparison.Ordinal)); + if (document is not null) return document; + await Task.Delay(250, cancellationToken).ConfigureAwait(false); + } + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The URL did not open in WeChat's embedded browser."); + } + + private static bool TryClickMenuItem(AutomationElement root, IReadOnlyList names) + { + var accepted = names.ToHashSet(StringComparer.Ordinal); + var candidates = root.FindAllDescendants().Where(element => + accepted.Contains(SafeName(element)) && + SafeControlType(element) is ControlType.MenuItem or ControlType.ListItem or ControlType.Button).ToArray(); + if (candidates.Length != 1) return false; + ClickCenter(candidates[0]); + return true; + } + + private static void SetForwardMessage(AutomationElement root, string message) + { + var dialog = RequireManagementDialog(root); + var candidates = dialog.FindAllDescendants(cf => cf.ByAutomationId("leave_message_view.chat_input_field")) + .Where(candidate => !candidate.Properties.IsOffscreen.ValueOrDefault).ToArray(); + if (candidates.Length != 1) + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The URL-card forwarding message input was not uniquely exposed."); + var input = candidates[0].AsTextBox(); + if (!string.IsNullOrEmpty(input.Text)) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The forwarding message input contains a draft; it was not overwritten."); + input.Text = message; + } public static Task SendAudioAsync( string session, @@ -258,7 +345,7 @@ public static partial class WechatChatClient FindVisibleMessageElement(main, fingerprint).RightClick(); await Task.Delay(100, cancellationToken).ConfigureAwait(false); ClickNamed(main, "拍一拍"); - return WechatOperationResult.Ok("tickle sent"); + return await ConfirmUiSubmissionAsync(main, "Tickle", cancellationToken).ConfigureAwait(false); }, cancellationToken); public static Task GetVisibleMessageUrlAsync( diff --git a/src/WxAgent.Windows/WechatChatClient.Management.cs b/src/WxAgent.Windows/WechatChatClient.Management.cs index 9680f35..1f74984 100644 --- a/src/WxAgent.Windows/WechatChatClient.Management.cs +++ b/src/WxAgent.Windows/WechatChatClient.Management.cs @@ -12,11 +12,6 @@ namespace WxAgent.Windows; public static partial class WechatChatClient { - private static readonly HashSet ContactHeadings = new(StringComparer.Ordinal) - { - "新的朋友", "群聊", "标签", "公众号", "企业微信联系人" - }; - public static Task> GetSubWindowsAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -84,18 +79,23 @@ public static partial class WechatChatClient item.DoubleClick(); } - public static Task> GetRecentGroupsAsync(CancellationToken cancellationToken = default) => - WithMainWindowAsync(async (main, _) => - { - await SwitchToContactAsync(main, cancellationToken).ConfigureAwait(false); - ClickFirstContaining(main, "群聊", ControlType.ListItem); - await Task.Delay(250, cancellationToken).ConfigureAwait(false); - return (IReadOnlyList)main.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem)) - .Select(SafeName) - .Where(name => !string.IsNullOrWhiteSpace(name) && !ContactHeadings.Contains(name)) - .Distinct(StringComparer.Ordinal) - .ToArray(); - }, cancellationToken); + /// Compatibility name: returns database-known groups, not a recency-ordered session list. + public static async Task> GetRecentGroupsAsync(CancellationToken cancellationToken = default) + { + var page = await GetContactsPageAsync(limit: 10000, groupsOnly: true, cancellationToken: cancellationToken).ConfigureAwait(false); + if (page.HasMore) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Group list exceeds the compatibility API limit; use GetContactsPageAsync to paginate."); + return page.Contacts.Select(contact => contact.DisplayName).ToArray(); + } + + public static async Task GetContactsPageAsync(int limit = 200, int offset = 0, + string? contains = null, bool? groupsOnly = null, string? accountId = null, string? keyFile = null, + CancellationToken cancellationToken = default) + { + _ = WechatContactQuery.Parameters(limit, offset, contains, groupsOnly); + var account = await WechatContactDbReader.LoadAccountAsync(accountId, keyFile, cancellationToken).ConfigureAwait(false); + return await WechatContactDbReader.ReadPageAsync(account, limit, offset, contains, groupsOnly, cancellationToken).ConfigureAwait(false); + } public static Task GetMyInfoAsync(CancellationToken cancellationToken = default) => WithMainWindowAsync(async (main, _) => @@ -111,26 +111,14 @@ public static partial class WechatChatClient return new WechatAccountSnapshot(displayName, ValueAfter(values, "微信号"), ValueAfter(values, "地区")); }, cancellationToken); - public static Task> GetFriendsAsync( + /// Reads non-group contact rows, up to maxCount. Use the paged API for completeness and account selection. + public static async Task> GetFriendsAsync( int maxCount = 1000, - CancellationToken cancellationToken = default) => - WithMainWindowAsync(async (main, _) => - { - if (maxCount <= 0) - { - throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "maxCount must be positive."); - } - - await SwitchToContactAsync(main, cancellationToken).ConfigureAwait(false); - var contacts = main.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem)) - .Select(element => SafeName(element)) - .Where(name => !string.IsNullOrWhiteSpace(name) && !ContactHeadings.Contains(name)) - .Distinct(StringComparer.Ordinal) - .Take(maxCount) - .Select(name => new WechatContactSnapshot(name)) - .ToArray(); - return (IReadOnlyList)contacts; - }, cancellationToken); + CancellationToken cancellationToken = default) + { + var page = await GetContactsPageAsync(limit: maxCount, groupsOnly: false, cancellationToken: cancellationToken).ConfigureAwait(false); + return page.Contacts.Select(contact => new WechatContactSnapshot(contact.DisplayName, Username: contact.Username)).ToArray(); + } public static Task> GetNewFriendsAsync( bool includeAccepted = false, @@ -152,23 +140,18 @@ public static partial class WechatChatClient return (IReadOnlyList)entries.ToArray(); }, cancellationToken); - public static Task GetFriendDetailsAsync(string displayName, CancellationToken cancellationToken = default) => - WithMainWindowAsync(async (main, _) => - { - await OpenContactAsync(main, displayName, cancellationToken).ConfigureAwait(false); - var values = main.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) - .Select(SafeName) - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Distinct(StringComparer.Ordinal) - .ToArray(); - return new WechatContactSnapshot( - displayName, - ValueAfter(values, "微信号"), - ValueAfter(values, "标签") is { } tags ? tags.Split('、', StringSplitOptions.RemoveEmptyEntries) : null, - ValueAfter(values, "个性签名"), - ValueAfter(values, "来源"), - ParseCount(ValueAfter(values, "共同群聊"))); - }, cancellationToken); + public static async Task GetFriendDetailsAsync(string displayName, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + var page = await GetContactsPageAsync(limit: 10000, contains: displayName, groupsOnly: false, + cancellationToken: cancellationToken).ConfigureAwait(false); + if (page.HasMore) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Contact lookup is truncated; use a stable username."); + var contact = page.Contacts.SingleOrDefault(item => item.Username == displayName) + ?? WechatOperationPolicy.RequireUnique(page.Contacts.Where(item => item.DisplayName == displayName), "database contact"); + // The verified contact schema does not supply tags/signature/source/common-group count; leave them unknown. + return new WechatContactSnapshot(contact.DisplayName, Username: contact.Username); + } public static Task AddNewFriendAsync( string keywords, @@ -197,8 +180,8 @@ public static partial class WechatChatClient { SetFirstEdit(main, requestMessage); } - ClickFirstNamed(main, new[] { "发送", "确定" }, ControlType.Button); - return WechatOperationResult.Ok("friend request submitted"); + ClickDialogAction(main, "发送", "确定"); + return WechatOperationResult.Unconfirmed("Friend request"); }, cancellationToken); public static Task AcceptNewFriendAsync( @@ -218,8 +201,8 @@ public static partial class WechatChatClient { SetFirstEdit(main, remark); } - ClickFirstNamed(main, new[] { "完成", "确定" }, ControlType.Button); - return WechatOperationResult.Ok("friend request accepted"); + ClickDialogAction(main, "完成", "确定"); + return await ConfirmUiSubmissionAsync(main, "Friend acceptance", cancellationToken).ConfigureAwait(false); }, cancellationToken); public static Task EditFriendRemarkAsync( @@ -233,8 +216,8 @@ public static partial class WechatChatClient ClickFirstNamed(main, new[] { "设置备注和标签", "备注和标签" }); await Task.Delay(200, cancellationToken).ConfigureAwait(false); SetFirstEdit(main, remark ?? string.Empty); - ClickFirstNamed(main, new[] { "完成", "确定" }, ControlType.Button); - return WechatOperationResult.Ok("friend remark updated"); + ClickDialogAction(main, "完成", "确定"); + return await ConfirmUiSubmissionAsync(main, "Friend remark update", cancellationToken).ConfigureAwait(false); }, cancellationToken); public static Task CreateGroupAsync( @@ -246,8 +229,8 @@ public static partial class WechatChatClient WechatOperationPolicy.ValidateNames(contacts, nameof(contacts)); ClickFirstNamed(main, new[] { "发起群聊", "创建群聊" }, ControlType.Button); await SelectPeopleAsync(main, contacts, cancellationToken).ConfigureAwait(false); - ClickFirstNamed(main, new[] { "完成", "确定" }, ControlType.Button); - return WechatOperationResult.Ok("group created"); + ClickDialogAction(main, "完成", "确定"); + return await ConfirmUiSubmissionAsync(main, "Group creation", cancellationToken).ConfigureAwait(false); }, cancellationToken); public static Task AddGroupMembersAsync(string group, IReadOnlyList members, string confirmation, CancellationToken cancellationToken = default) => @@ -268,18 +251,23 @@ public static partial class WechatChatClient public static Task SetMyNicknameInGroupAsync(string group, string value, string confirmation, CancellationToken cancellationToken = default) => SetGroupTextAsync(group, new[] { "我在本群的昵称", "群昵称" }, value, "set group nickname", confirmation, cancellationToken); - public static Task> GetGroupMembersAsync(string group, CancellationToken cancellationToken = default) => - WithMainWindowAsync(async (main, _) => - { - await OpenGroupInfoAsync(main, group, cancellationToken).ConfigureAwait(false); - var names = main.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem)) - .Select(SafeName) - .Where(name => !string.IsNullOrWhiteSpace(name)) - .Distinct(StringComparer.Ordinal) - .Select(name => new WechatGroupMemberSnapshot(name)) - .ToArray(); - return (IReadOnlyList)names; - }, cancellationToken); + public static async Task> GetGroupMembersAsync(string group, CancellationToken cancellationToken = default) + { + var page = await GetGroupMembersPageAsync(group, 10000, cancellationToken: cancellationToken).ConfigureAwait(false); + if (page.HasMore) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Group member list exceeds the compatibility API limit; use GetGroupMembersPageAsync."); + return page.Members.Select(member => new WechatGroupMemberSnapshot(member.DisplayName, IsOwner: member.IsOwner)).ToArray(); + } + + public static async Task GetGroupMembersPageAsync(string group, int limit = 500, int offset = 0, + string? accountId = null, string? keyFile = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(group); + if (limit is < 1 or > 10000 || offset < 0 || offset > int.MaxValue - limit) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Member limit must be 1-10000 and offset must leave room for the next page."); + var account = await WechatContactDbReader.LoadAccountAsync(accountId, keyFile, cancellationToken).ConfigureAwait(false); + return await WechatContactDbReader.ReadGroupMembersPageAsync(account, group, limit, offset, cancellationToken).ConfigureAwait(false); + } public static Task AtAllAsync(string group, string? message, string confirmation, CancellationToken cancellationToken = default) => MentionMemberAsync(group, "所有人", message, confirmation, cancellationToken); @@ -364,8 +352,8 @@ public static partial class WechatChatClient item.RightClick(); await Task.Delay(100, cancellationToken).ConfigureAwait(false); ClickNamed(main, option); - ConfirmDialogIfPresent(main); - return WechatOperationResult.Ok("session option selected"); + ConfirmDialogIfPresent(main, "确定", "删除"); + return await ConfirmUiSubmissionAsync(main, "Session option", cancellationToken).ConfigureAwait(false); }, cancellationToken); public static Task DeleteSessionAsync(string session, string confirmation, CancellationToken cancellationToken = default) => @@ -381,8 +369,8 @@ public static partial class WechatChatClient await OpenGroupInfoAsync(main, group, cancellationToken).ConfigureAwait(false); ClickFirstNamed(main, actions); await SelectPeopleAsync(main, members, cancellationToken).ConfigureAwait(false); - ClickFirstNamed(main, new[] { "完成", "确定", "删除" }, ControlType.Button); - return WechatOperationResult.Ok(operation + " completed"); + ClickDialogAction(main, "完成", "确定", "删除"); + return await ConfirmUiSubmissionAsync(main, operation, cancellationToken).ConfigureAwait(false); }, cancellationToken); private static Task SetGroupTextAsync(string group, string[] labels, string value, string operation, string confirmation, CancellationToken cancellationToken) => @@ -392,11 +380,28 @@ public static partial class WechatChatClient ClickFirstNamed(main, labels); await Task.Delay(100, cancellationToken).ConfigureAwait(false); SetFirstEdit(main, value); - ClickFirstNamed(main, new[] { "完成", "确定", "发布" }, ControlType.Button); - ConfirmDialogIfPresent(main); - return WechatOperationResult.Ok(operation + " completed"); + ClickDialogAction(main, "完成", "确定", "发布"); + ConfirmDialogIfPresent(main, "确定", "发布"); + return await ConfirmUiSubmissionAsync(main, operation, cancellationToken).ConfigureAwait(false); }, cancellationToken); + private static async Task ConfirmUiSubmissionAsync(AutomationElement main, string operation, CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 20; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + if (ManagementDialogs(main).Length == 0) + { + await Task.Delay(150, cancellationToken).ConfigureAwait(false); + if (ManagementDialogs(main).Length == 0) + return WechatOperationResult.Ok($"{operation} accepted by WeChat UI"); + } + await Task.Delay(150, cancellationToken).ConfigureAwait(false); + } + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, + $"{operation} did not leave a stable WeChat UI state; no retry was attempted."); + } + private static async Task WithConfirmedMainWindowAsync(string operation, string confirmation, Func> action, CancellationToken cancellationToken) { WechatOperationPolicy.RequireConfirmation(confirmation, operation); @@ -501,77 +506,119 @@ public static partial class WechatChatClient foreach (var name in names) { cancellationToken.ThrowIfCancellationRequested(); - var edit = main.FindAllDescendants(cf => cf.ByControlType(ControlType.Edit)) - .FirstOrDefault(candidate => !candidate.Properties.IsOffscreen.ValueOrDefault); - if (edit is not null) + // Prefer the unfiltered list. This avoids an IME/clipboard round-trip for visible names. + var direct = FindPersonCheckboxes(main, name); + if (direct.Length == 1) { - edit.AsTextBox().Text = name; - await Task.Delay(150, cancellationToken).ConfigureAwait(false); + var directCheckbox = direct[0].AsCheckBox(); + if (directCheckbox.IsChecked != true) directCheckbox.Click(); + if (directCheckbox.IsChecked != true) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "Person selection was not accepted; no submit was attempted."); + continue; } - ClickNamed(main, name); + var checkbox = (await WaitForPersonCheckboxAsync(main, name, cancellationToken).ConfigureAwait(false)).AsCheckBox(); + // Do not toggle an already-selected person off, or submit an unverifiable selection. + if (checkbox.IsChecked != true) checkbox.Click(); + await Task.Delay(150, cancellationToken).ConfigureAwait(false); + checkbox = (await WaitForPersonCheckboxAsync(main, name, cancellationToken).ConfigureAwait(false)).AsCheckBox(); + if (checkbox.IsChecked != true) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "Person selection was not confirmed; no submit was attempted."); } } + private static AutomationElement[] FindPersonCheckboxes(AutomationElement main, string name) + { + var dialog = RequireManagementDialog(main); + var list = FindByAutomationId(dialog, "sp_to_select_contact_list") + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The recipient list was not exposed."); + var bounds = list.BoundingRectangle; + return main.FindAllDescendants().Where(element => + SafeControlType(element) == ControlType.CheckBox && SafeName(element) == name && + element.BoundingRectangle.IntersectsWith(bounds)).ToArray(); + } + + private static async Task WaitForPersonCheckboxAsync(AutomationElement main, string name, CancellationToken cancellationToken) + { + var lastScroll = -1d; + // ponytail: bounded visible-list scan; replace with a verified recipient search endpoint if WeChat exposes one. + for (var attempt = 0; attempt < 100; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + var matches = FindPersonCheckboxes(main, name); + if (matches.Length == 1) return matches[0]; + if (matches.Length > 1) + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Multiple person checkboxes named '{name}' were exposed."); + + var list = FindByAutomationId(RequireManagementDialog(main), "sp_to_select_contact_list"); + if (list is null) + { + await Task.Delay(150, cancellationToken).ConfigureAwait(false); + continue; + } + if (!list.Patterns.Scroll.IsSupported) break; + var scroll = list.Patterns.Scroll.Pattern; + var current = scroll.VerticalScrollPercent.Value; + if (current >= 99.9 || Math.Abs(current - lastScroll) < 0.01) break; + lastScroll = current; + var step = Math.Max(1, scroll.VerticalViewSize.Value * 0.8); + ExecuteInputStep("scroll-recipient-list", () => scroll.SetScrollPercent(-1, Math.Min(100, current + step))); + await Task.Delay(250, cancellationToken).ConfigureAwait(false); + } + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Person checkbox '{name}' was not exposed."); + } + + private static bool IsActionable(AutomationElement element) => + !element.Properties.IsOffscreen.ValueOrDefault && element.Properties.IsEnabled.ValueOrDefault && !element.BoundingRectangle.IsEmpty; + + private static AutomationElement[] ManagementDialogs(AutomationElement root) + { + var nestedWindows = root.FindAllDescendants(cf => cf.ByControlType(ControlType.Window)) + .Where(window => IsActionable(window) && !string.Equals(SafeName(window), SafeName(root), StringComparison.Ordinal)) + .ToArray(); + // The native "微信发送给" window is a visible child window but is not flagged IsModal on all builds. + return nestedWindows.Length > 0 ? nestedWindows : root.AsWindow().ModalWindows.Where(IsActionable).ToArray(); + } + + private static AutomationElement RequireManagementDialog(AutomationElement root) => + WechatOperationPolicy.RequireUnique(ManagementDialogs(root), "modal management dialog"); + private static void SetFirstEdit(AutomationElement main, string value) { - var edit = main.FindAllDescendants(cf => cf.ByControlType(ControlType.Edit)) - .FirstOrDefault(candidate => !candidate.Properties.IsOffscreen.ValueOrDefault) - ?? throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Expected text input was not found."); - edit.AsTextBox().Text = value; + var dialog = RequireManagementDialog(main); + var edits = dialog.FindAllDescendants(cf => cf.ByControlType(ControlType.Edit)) + .Where(candidate => SafeAutomationId(candidate) != WechatLocators.ChatInput).ToArray(); + var edit = WechatOperationPolicy.RequireUnique( + edits.Length == 1 ? edits : edits.Where(candidate => SafeName(candidate) == WechatLocators.Search), "dialog input"); + var textBox = edit.AsTextBox(); + textBox.Text = value; + edit.Focus(); + Keyboard.Press(VirtualKeyShort.END); + Thread.Sleep(500); + if (!string.Equals(textBox.Text, value, StringComparison.Ordinal)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, + "WeChat rejected the Unicode input value; no selection or submission was attempted."); } - private static void ClickNamed(AutomationElement root, string name, ControlType? controlType = null) - { - var element = root.FindFirstDescendant(cf => controlType is null - ? cf.ByName(name) - : cf.ByName(name).And(cf.ByControlType(controlType.Value))); - if (element is null) - { - throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, $"Expected control '{name}' was not found."); - } - element.Click(); - } - - private static void ClickFirstContaining(AutomationElement root, string text, ControlType? controlType = null) - { - var element = root.FindAllDescendants() - .FirstOrDefault(candidate => SafeName(candidate).Contains(text, StringComparison.Ordinal) - && (controlType is null || SafeControlType(candidate) == controlType)); - if (element is null) - { - throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, $"Expected control containing '{text}' was not found."); - } - element.Click(); - } + private static void ClickNamed(AutomationElement root, string name, ControlType? controlType = null) => + ClickFirstNamed(root, new[] { name }, controlType); private static void ClickFirstNamed(AutomationElement root, IEnumerable names, ControlType? controlType = null) { - foreach (var name in names) - { - var element = root.FindFirstDescendant(cf => controlType is null - ? cf.ByName(name) - : cf.ByName(name).And(cf.ByControlType(controlType.Value))); - if (element is null) - { - continue; - } - element.Click(); - return; - } - throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "None of the expected controls were found."); + var acceptedNames = names.ToHashSet(StringComparer.Ordinal); + var candidates = root.FindAllDescendants().Where(candidate => IsActionable(candidate) + && acceptedNames.Contains(SafeName(candidate)) + && (controlType is null || SafeControlType(candidate) == controlType)); + WechatOperationPolicy.RequireUnique(candidates, "named control").Click(); } - private static void ConfirmDialogIfPresent(AutomationElement root) + private static void ClickDialogAction(AutomationElement root, params string[] names) => + ClickFirstNamed(RequireManagementDialog(root), names, ControlType.Button); + + private static void ConfirmDialogIfPresent(AutomationElement root, params string[] names) { - foreach (var name in new[] { "确定", "删除", "发布" }) - { - var button = root.FindFirstDescendant(cf => cf.ByName(name).And(cf.ByControlType(ControlType.Button))); - if (button is not null) - { - button.Click(); - return; - } - } + var dialogs = ManagementDialogs(root); + if (dialogs.Length == 0) return; + ClickFirstNamed(WechatOperationPolicy.RequireUnique(dialogs, "confirmation dialog"), names, ControlType.Button); } private static WechatNewFriendSnapshot? ParseNewFriend(AutomationElement element) @@ -596,13 +643,6 @@ public static partial class WechatChatClient return null; } - private static int? ParseCount(string? value) - { - if (value is null) return null; - var digits = new string(value.Where(char.IsDigit).ToArray()); - return int.TryParse(digits, out var count) ? count : null; - } - public static Task> GetHistoryMessageAsync( string session, int count, @@ -667,8 +707,8 @@ public static partial class WechatChatClient await Task.Delay(100, cancellationToken).ConfigureAwait(false); ClickNamed(main, "转发"); await SelectPeopleAsync(main, targets, cancellationToken).ConfigureAwait(false); - ClickFirstNamed(main, new[] { "发送", "确定" }, ControlType.Button); - return WechatOperationResult.Ok("message forwarded"); + ClickDialogAction(main, "发送", "确定"); + return await ConfirmUiSubmissionAsync(main, "Message forwarding", cancellationToken).ConfigureAwait(false); }, cancellationToken); public static Task> GetVisibleMessageSenderInfoAsync( @@ -706,8 +746,8 @@ public static partial class WechatChatClient ClickFirstNamed(main, new[] { "添加好友", "添加到通讯录" }); await Task.Delay(150, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(requestMessage)) SetFirstEdit(main, requestMessage); - ClickFirstNamed(main, new[] { "发送", "确定" }, ControlType.Button); - return WechatOperationResult.Ok("friend request submitted"); + ClickDialogAction(main, "发送", "确定"); + return await ConfirmUiSubmissionAsync(main, "Friend request", cancellationToken).ConfigureAwait(false); }, cancellationToken); public static Task DeleteVisibleMessageSenderFriendAsync( @@ -727,11 +767,15 @@ public static partial class WechatChatClient await Task.Delay(100, cancellationToken).ConfigureAwait(false); if (!clearChatHistory) { - var check = main.FindFirstDescendant(cf => cf.ByName("同时删除聊天记录").And(cf.ByControlType(ControlType.CheckBox))); - if (check?.AsCheckBox().IsChecked == true) check.Click(); + var dialog = RequireManagementDialog(main); + var check = WechatOperationPolicy.RequireUnique(dialog.FindAllDescendants(cf => + cf.ByName("同时删除聊天记录").And(cf.ByControlType(ControlType.CheckBox))).Where(IsActionable), "delete-history checkbox").AsCheckBox(); + check.IsChecked = false; + if (check.IsChecked != false) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "History retention was not confirmed; no delete confirmation was attempted."); } - ConfirmDialogIfPresent(main); - return WechatOperationResult.Ok("friend deleted"); + ConfirmDialogIfPresent(main, "确定", "删除"); + return await ConfirmUiSubmissionAsync(main, "Friend deletion", cancellationToken).ConfigureAwait(false); }, cancellationToken); public static Task DownloadVisibleMessageAsync( @@ -742,15 +786,34 @@ public static partial class WechatChatClient CancellationToken cancellationToken = default) => WithConfirmedMainWindowAsync("download message", confirmation, async (main, _) => { - if (string.IsNullOrWhiteSpace(destinationPath)) - throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "destinationPath is required."); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + var fullPath = Path.GetFullPath(destinationPath); + if (File.Exists(fullPath) || Directory.Exists(fullPath) || !Directory.Exists(Path.GetDirectoryName(fullPath))) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Choose a new file path in an existing directory; overwriting is not supported."); await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); FindVisibleMessageElement(main, fingerprint).RightClick(); ClickFirstNamed(main, new[] { "另存为...", "另存为", "保存" }); await Task.Delay(200, cancellationToken).ConfigureAwait(false); - Keyboard.Type(destinationPath); - Keyboard.Press(VirtualKeyShort.ENTER); - return destinationPath; + SetFirstEdit(main, fullPath); + if (File.Exists(fullPath) || Directory.Exists(fullPath)) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Destination appeared before save; no overwrite was attempted."); + ClickDialogAction(main, "保存", "保存(S)"); + var elapsed = System.Diagnostics.Stopwatch.StartNew(); + long? previousLength = null; + while (elapsed.Elapsed < TimeSpan.FromSeconds(15)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + using var file = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read); + // ponytail: size stability + no active writer confirms local save, not remote byte-for-byte integrity. + if (file.Length > 0 && previousLength == file.Length && ManagementDialogs(main).Length == 0) return fullPath; + previousLength = file.Length; + } + catch (IOException) { previousLength = null; } + await Task.Delay(500, cancellationToken).ConfigureAwait(false); + } + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "Saved file was not confirmed; no retry or overwrite was attempted."); }, cancellationToken); public static Task OcrVisibleImageAsync(string session, string fingerprint, CancellationToken cancellationToken = default) => @@ -762,13 +825,26 @@ public static partial class WechatChatClient public static Task SaveVisibleNoteFilesAsync(string session, string fingerprint, string destinationPath, string confirmation, CancellationToken cancellationToken = default) => WithConfirmedMainWindowAsync("save note files", confirmation, async (main, _) => { + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + var fullPath = Path.GetFullPath(destinationPath); + if (!Directory.Exists(fullPath) || Directory.EnumerateFileSystemEntries(fullPath).Any()) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "An existing empty destination directory is required; overwriting is not supported."); await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); FindVisibleMessageElement(main, fingerprint).RightClick(); ClickFirstNamed(main, new[] { "保存附件", "另存为...", "另存为" }); await Task.Delay(200, cancellationToken).ConfigureAwait(false); - Keyboard.Type(destinationPath); - Keyboard.Press(VirtualKeyShort.ENTER); - return WechatOperationResult.Ok("note files saved"); + SetFirstEdit(main, fullPath); + ClickDialogAction(main, "保存", "选择文件夹"); + var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(20); + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + if (ManagementDialogs(main).Length == 0 && Directory.EnumerateFileSystemEntries(fullPath).Any()) + return WechatOperationResult.Ok("Note attachment save accepted and files appeared"); + await Task.Delay(500, cancellationToken).ConfigureAwait(false); + } + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, + "Note attachment save was not confirmed by files appearing in the destination directory; no retry was attempted."); }, cancellationToken); public static Task SelectVisibleMessageOptionAsync(string session, string fingerprint, string option, string? confirmation = null, CancellationToken cancellationToken = default) => @@ -778,8 +854,8 @@ public static partial class WechatChatClient await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); FindVisibleMessageElement(main, fingerprint).RightClick(); ClickNamed(main, option); - ConfirmDialogIfPresent(main); - return WechatOperationResult.Ok("message option selected"); + ConfirmDialogIfPresent(main, "确定", "删除"); + return await ConfirmUiSubmissionAsync(main, "Message option", cancellationToken).ConfigureAwait(false); }, cancellationToken); public static Task InviteGroupMembersAsync(string group, IReadOnlyList members, string confirmation, CancellationToken cancellationToken = default) => @@ -789,26 +865,62 @@ public static partial class WechatChatClient WithMainWindowAsync(async (main, _) => { await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); - FindVisibleMessageElement(main, fingerprint).RightClick(); + var target = FindVisibleMessageElement(main, fingerprint); + var runtimeId = SafeRuntimeId(target); + if (string.IsNullOrWhiteSpace(runtimeId)) + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "A stable message runtime ID is required."); + var originalName = SafeName(target); + var originalLayout = ReadMessageRuntimeIds(main); + var before = ReadDerivedTextNodes(target); + target.RightClick(); ClickFirstNamed(main, actions); - await Task.Delay(300, cancellationToken).ConfigureAwait(false); - return string.Join(Environment.NewLine, main.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) - .Select(SafeName).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal)); + var elapsed = System.Diagnostics.Stopwatch.StartNew(); + string? previous = null; + while (elapsed.Elapsed < TimeSpan.FromSeconds(10)) + { + await Task.Delay(500, cancellationToken).ConfigureAwait(false); + RequireNavigationSession(main, session); + if (!ReadMessageRuntimeIds(main).SequenceEqual(originalLayout, StringComparer.Ordinal)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "Message layout changed during extraction; no unbound result was returned."); + var list = FindByAutomationId(main, WechatLocators.MessageList) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Message list disappeared."); + var fresh = WechatOperationPolicy.RequireUnique(list.FindAllDescendants().Where(element => + SafeAutomationId(element) == WechatLocators.ChatBubbleItem && SafeRuntimeId(element) == runtimeId), "bound message"); + if (!SafeName(fresh).StartsWith(originalName, StringComparison.Ordinal)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The bound message was replaced during extraction."); + var text = WechatDerivedText.AddedText(before, ReadDerivedTextNodes(fresh)); + // ponytail: two stable samples, not a backend completion marker; add version-specific result selectors when verified. + if (!string.IsNullOrWhiteSpace(text) && text == previous) return text; + previous = text; + } + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, + "No stable derived text inside the target message was confirmed; unbound window text was not read."); }, cancellationToken); + private static string[] ReadMessageRuntimeIds(AutomationElement main) + { + var list = FindByAutomationId(main, WechatLocators.MessageList) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Message list was not found."); + return list.FindAllDescendants().Where(element => SafeAutomationId(element) == WechatLocators.ChatBubbleItem) + .Select(element => SafeRuntimeId(element) ?? throw new WxAgentException( + WxAgentErrorCode.ControlNotFound, "Message layout contains an element without a runtime ID.")).ToArray(); + } + + private static string[] ReadDerivedTextNodes(AutomationElement message) => + message.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)).Select(SafeName).ToArray(); + private static AutomationElement FindVisibleMessageElement(AutomationElement main, string fingerprint) { var list = FindByAutomationId(main, WechatLocators.MessageList) ?? throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Message list was not found."); var elements = list.FindAllDescendants() .Where(element => SafeAutomationId(element) == WechatLocators.ChatBubbleItem) - .ToArray(); - var snapshots = ReadVisible(main); - for (var index = 0; index < Math.Min(elements.Length, snapshots.Count); index++) - { - if (snapshots[index].Fingerprint == fingerprint) return elements[index]; - } - throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The visible message fingerprint was not found."); + .Select(element => (Element: element, Name: SafeName(element))) + .Where(item => !string.IsNullOrWhiteSpace(item.Name)).ToArray(); + // Parse the very same capture: blank bubble names are skipped by the parser and must not shift indices. + var snapshots = VisibleMessageParser.Parse(elements.Select(item => item.Name)); + var match = WechatOperationPolicy.RequireUnique(snapshots.Where(item => item.Fingerprint == fingerprint), "visible message fingerprint"); + return elements[match.VisibleIndex].Element; } private static bool IsDestructiveOption(string option) => option.Contains("删除", StringComparison.Ordinal) || option.Contains("不显示", StringComparison.Ordinal); diff --git a/src/WxAgent.Windows/WechatContactDbReader.cs b/src/WxAgent.Windows/WechatContactDbReader.cs new file mode 100644 index 0000000..2174283 --- /dev/null +++ b/src/WxAgent.Windows/WechatContactDbReader.cs @@ -0,0 +1,78 @@ +using WxAgent.Core; + +namespace WxAgent.Windows; + +public static class WechatContactDbReader +{ + public static async Task LoadAccountAsync(string? accountId = null, string? keyFile = null, CancellationToken cancellationToken = default) + { + var accounts = await DatabaseKeyStore.LoadAsync(keyFile, cancellationToken).ConfigureAwait(false); + var matches = accounts.Where(account => accountId is null || + string.Equals(account.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (matches.Length == 0) + throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "No matching account was found in the key store; run db scan first."); + if (matches.Length != 1) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Select one account fingerprint explicitly; multiple cached accounts match."); + return matches[0]; + } + + public static async Task ReadPageAsync(AccountKeySet account, int limit = 200, int offset = 0, + string? contains = null, bool? groupsOnly = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var parameters = WechatContactQuery.Parameters(limit, offset, contains, groupsOnly); + var database = RequireContactDatabase(account); + // The shared reader revalidates page-1 HMAC on every open and enforces read-only mode. + var rows = await SqlCipherDatabaseReader.QueryRowsAsync(ContactDatabasePath(account), database.EncKey, + WechatContactQuery.Sql, parameters, cancellationToken).ConfigureAwait(false); + var contacts = rows.Select(row => new WechatDatabaseContact( + row.GetValueOrDefault("username") as string ?? throw new WxAgentException( + WxAgentErrorCode.DatabaseOpenFailed, "A contact row has no stable username."), + row.GetValueOrDefault("nick_name") as string, row.GetValueOrDefault("remark") as string, + row.GetValueOrDefault("small_head_url") as string)).ToArray(); + return WechatContactQuery.Page(contacts, limit, offset); + } + + public static async Task ReadGroupMembersPageAsync(AccountKeySet account, string group, + int limit = 500, int offset = 0, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentException.ThrowIfNullOrWhiteSpace(group); + var groups = await ReadPageAsync(account, 10000, contains: group, groupsOnly: true, + cancellationToken: cancellationToken).ConfigureAwait(false); + if (groups.HasMore) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Group lookup is truncated; use a stable @chatroom username."); + var exact = groups.Contacts.Where(item => item.Username == group || item.DisplayName == group).ToArray(); + var selected = WechatOperationPolicy.RequireUnique(exact, "database group"); + var parameters = WechatGroupMemberQuery.Parameters(selected.Username, limit, offset); + var database = RequireContactDatabase(account); + var rows = await SqlCipherDatabaseReader.QueryRowsAsync(ContactDatabasePath(account), database.EncKey, + WechatGroupMemberQuery.Sql, parameters, cancellationToken).ConfigureAwait(false); + var members = rows.Select(row => + { + var id = Convert.ToInt64(row.GetValueOrDefault("member_id"), System.Globalization.CultureInfo.InvariantCulture); + var username = row.GetValueOrDefault("username") as string ?? throw new WxAgentException( + WxAgentErrorCode.DatabaseOpenFailed, "A group-member row has no stable username."); + var remark = row.GetValueOrDefault("remark") as string; + var nickname = row.GetValueOrDefault("nick_name") as string; + var owner = Convert.ToString(row.GetValueOrDefault("owner"), System.Globalization.CultureInfo.InvariantCulture); + var displayName = !string.IsNullOrWhiteSpace(remark) ? remark : !string.IsNullOrWhiteSpace(nickname) ? nickname : username; + var isOwner = string.Equals(owner, username, StringComparison.Ordinal) || + long.TryParse(owner, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var ownerId) && ownerId == id; + return new WechatDatabaseGroupMember(id, username, displayName, isOwner); + }).ToArray(); + return WechatGroupMemberQuery.Page(selected.Username, members, limit, offset); + } + + private static DatabaseKeyEvidence RequireContactDatabase(AccountKeySet account) + { + var databases = account.Databases.Where(item => + string.Equals(item.RelativePath.Replace('\\', '/'), "contact/contact.db", StringComparison.OrdinalIgnoreCase)).ToArray(); + if (databases.Length != 1) + throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "Exactly one contact.db key is required for the selected account."); + return databases[0]; + } + + private static string ContactDatabasePath(AccountKeySet account) => + Path.GetFullPath(Path.Combine(account.AccountRootPath, "contact", "contact.db")); +} diff --git a/tests/WxAgent.Core.Tests/WechatContactQueryTests.cs b/tests/WxAgent.Core.Tests/WechatContactQueryTests.cs new file mode 100644 index 0000000..7f9c828 --- /dev/null +++ b/tests/WxAgent.Core.Tests/WechatContactQueryTests.cs @@ -0,0 +1,46 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatContactQueryTests +{ + [Fact] + public void ParametersAreBoundAndFetchOneExtraRow() + { + const string filter = "O'Brien_%'; DROP TABLE contact;--"; + var parameters = WechatContactQuery.Parameters(2, 4, filter, true).ToDictionary(pair => pair.Key, pair => pair.Value); + Assert.Equal(3, parameters["$limit"]); + Assert.Equal(4, parameters["$offset"]); + Assert.Equal(1, parameters["$groups"]); + Assert.Equal(filter, parameters["$contains"]); + Assert.DoesNotContain(filter, WechatContactQuery.Sql); + Assert.Contains("ORDER BY username COLLATE BINARY", WechatContactQuery.Sql); + Assert.Equal(0, WechatContactQuery.Parameters(2, 0, null, false).Last().Value); + Assert.Null(WechatContactQuery.Parameters(2, 0, null, null).Last().Value); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(10001, 0)] + [InlineData(1, -1)] + [InlineData(2, int.MaxValue)] + public void InvalidBoundsAreRejected(int limit, int offset) => + Assert.Equal(WxAgentErrorCode.InvalidArgument, + Assert.Throws(() => WechatContactQuery.Parameters(limit, offset, null, null)).Code); + + [Fact] + public void PageSignalsTruncationWithoutCollapsingDuplicateDisplayNames() + { + WechatDatabaseContact[] contacts = [new("a", "same", null, null), new("b", "same", null, null), new("c", null, "remark", null)]; + var page = WechatContactQuery.Page(contacts, 2, 4); + Assert.True(page.HasMore); + Assert.Equal(6, page.NextOffset); + Assert.Equal(new[] { "a", "b" }, page.Contacts.Select(contact => contact.Username)); + var last = WechatContactQuery.Page(contacts[2..], 2, 6); + Assert.False(last.HasMore); + Assert.Equal("remark", last.Contacts[0].DisplayName); + Assert.False(WechatContactQuery.Page([], 2, 0).HasMore); + Assert.Equal("wxid", new WechatDatabaseContact("wxid", "", " ", null).DisplayName); + } +} diff --git a/tests/WxAgent.Core.Tests/WechatDerivedTextTests.cs b/tests/WxAgent.Core.Tests/WechatDerivedTextTests.cs new file mode 100644 index 0000000..5aca13d --- /dev/null +++ b/tests/WxAgent.Core.Tests/WechatDerivedTextTests.cs @@ -0,0 +1,29 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatDerivedTextTests +{ + [Fact] + public void OnlyAddedTextIsReturnedAndDuplicateLinesArePreserved() + { + Assert.Equal($"result{Environment.NewLine}result", + WechatDerivedText.AddedText(["original", "timestamp"], ["original", "timestamp", "result", "result"])); + Assert.Equal("same", WechatDerivedText.AddedText(["same"], ["same", "same"])); + Assert.Null(WechatDerivedText.AddedText(["original"], ["original"])); + Assert.Null(WechatDerivedText.AddedText(["original"], ["unrelated replacement"])); + Assert.Null(WechatDerivedText.AddedText(["same", "same"], ["same", "new"])); + } + + [Theory] + [InlineData("识别中")] + [InlineData("转换中…")] + [InlineData("语音转换失败")] + [InlineData("识别失败,请重试")] + [InlineData("未识别到文字")] + [InlineData("无法转换")] + [InlineData(" ")] + public void TransientOrFailedResultsAreNotReturned(string status) => + Assert.Null(WechatDerivedText.AddedText(["original"], ["original", status])); +} diff --git a/tests/WxAgent.Core.Tests/WechatGroupMemberQueryTests.cs b/tests/WxAgent.Core.Tests/WechatGroupMemberQueryTests.cs new file mode 100644 index 0000000..9b0889b --- /dev/null +++ b/tests/WxAgent.Core.Tests/WechatGroupMemberQueryTests.cs @@ -0,0 +1,41 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatGroupMemberQueryTests +{ + [Fact] + public void QueryUsesStableIdsAndBoundParameters() + { + var parameters = WechatGroupMemberQuery.Parameters("123@chatroom", 2, 4).ToDictionary(pair => pair.Key, pair => pair.Value); + Assert.Equal("123@chatroom", parameters["$group"]); + Assert.Equal(3, parameters["$limit"]); + Assert.Equal(4, parameters["$offset"]); + Assert.Contains("c.id = m.member_id", WechatGroupMemberQuery.Sql); + Assert.Contains("ORDER BY m.member_id", WechatGroupMemberQuery.Sql); + Assert.DoesNotContain("123@chatroom", WechatGroupMemberQuery.Sql); + } + + [Theory] + [InlineData("group")] + [InlineData("")] + [InlineData(" ")] + public void StableGroupUsernameIsRequired(string group) => + Assert.Equal(WxAgentErrorCode.InvalidArgument, + Assert.Throws(() => WechatGroupMemberQuery.Parameters(group, 1, 0)).Code); + + [Fact] + public void PagePreservesDuplicateDisplayNamesAndSignalsNextOffset() + { + WechatDatabaseGroupMember[] rows = + [ + new(1, "one", "same", true), new(2, "two", "same", false), new(3, "three", "three", false) + ]; + var page = WechatGroupMemberQuery.Page("123@chatroom", rows, 2, 4); + Assert.True(page.HasMore); + Assert.Equal(6, page.NextOffset); + Assert.Equal(new[] { "one", "two" }, page.Members.Select(member => member.Username)); + Assert.True(page.Members[0].IsOwner); + } +} diff --git a/tests/WxAgent.Core.Tests/WechatManagementTests.cs b/tests/WxAgent.Core.Tests/WechatManagementTests.cs index 8481665..52610a5 100644 --- a/tests/WxAgent.Core.Tests/WechatManagementTests.cs +++ b/tests/WxAgent.Core.Tests/WechatManagementTests.cs @@ -26,9 +26,29 @@ public sealed class WechatManagementTests { Assert.Throws(() => WechatOperationPolicy.ValidateNames([], "members")); Assert.Throws(() => WechatOperationPolicy.ValidateNames(["ok", ""], "members")); + Assert.Throws(() => WechatOperationPolicy.ValidateNames(["same", "same"], "members")); WechatOperationPolicy.ValidateNames(["one", "two"], "members"); } + [Fact] + public void RequireUnique_RejectsMissingAndAmbiguousTargets() + { + Assert.Equal("one", WechatOperationPolicy.RequireUnique(new[] { "one" }, "target")); + Assert.Equal(WxAgentErrorCode.ControlNotFound, + Assert.Throws(() => WechatOperationPolicy.RequireUnique(Array.Empty(), "target")).Code); + Assert.Equal(WxAgentErrorCode.ControlNotFound, + Assert.Throws(() => WechatOperationPolicy.RequireUnique(new[] { "one", "two" }, "target")).Code); + } + + [Fact] + public void OperationResult_UnconfirmedIsNotSuccessAndWarnsAgainstRetry() + { + var result = WechatOperationResult.Unconfirmed("Submission"); + Assert.False(result.Success); + Assert.Equal(WxAgentErrorCode.ResultUnconfirmed, result.Code); + Assert.Contains("Do not retry", result.Message); + } + [Fact] public void OperationResult_OkHasNoErrorCode() { diff --git a/tests/check_contact_query.py b/tests/check_contact_query.py new file mode 100644 index 0000000..f7c3fd3 --- /dev/null +++ b/tests/check_contact_query.py @@ -0,0 +1,45 @@ +"""Offline SQLite check for the production contact SQL; no WeChat database is read.""" + +import re +import sqlite3 +from pathlib import Path + +source = ( + Path(__file__).resolve().parents[1] / "src/WxAgent.Core/WechatContactQuery.cs" +).read_text() +match = re.search(r'public const string Sql = """\s*(.*?)\s*""";', source, re.S) +assert match is not None +sql = match.group(1) + +with sqlite3.connect(":memory:") as db: + db.execute( + "CREATE TABLE contact(username TEXT PRIMARY KEY, nick_name TEXT, remark TEXT, small_head_url TEXT)" + ) + db.executemany( + "INSERT INTO contact VALUES (?, ?, ?, NULL)", + [(f"wxid_{i:04}", "same display name", None) for i in range(501)] + + [("room@chatroom", "group", None), ("literal", "O'Brien_%", None)], + ) + + def query(limit=201, offset=0, contains=None, groups=None): + return db.execute( + sql, {"limit": limit, "offset": offset, "contains": contains, "groups": groups} + ).fetchall() + + first = query() + assert len(first) == 201 + second = query(offset=200) + third = query(offset=400) + ids = [row[0] for row in first[:200] + second[:200] + third] + assert len(ids) == 503 and len(set(ids)) == 503 and ids == sorted(ids) + assert [row[0] for row in query(contains="_%")] == ["literal"] + assert query(contains="'; DROP TABLE contact;--") == [] + assert [row[0] for row in query(groups=1)] == ["room@chatroom"] + assert len(query(limit=10000, groups=0)) == 502 + assert len(query(limit=10000, contains="same display name")) == 501 + assert query(offset=503) == [] + assert db.execute("SELECT count(*) FROM contact").fetchone()[0] == 503 + +print( + "contact SQL: pagination, duplicate names, literal filtering and group filtering passed" +)