From 77e1e9dc2b30bb7f3ce4b43b5453532109ecf53a Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 4 Sep 2026 23:01:02 +0800 Subject: [PATCH] feat: complete M4 contact and group automation --- .../M4-Management-History-2026-09-04.md | 74 ++ src/WxAgent.Core/WechatManagement.cs | 46 ++ src/WxAgent.Host/Program.cs | 81 ++ .../WechatChatClient.Management.cs | 774 ++++++++++++++++++ src/WxAgent.Windows/WechatChatClient.cs | 2 +- .../WechatManagementTests.cs | 40 + 6 files changed, 1016 insertions(+), 1 deletion(-) create mode 100644 docs/validation/M4-Management-History-2026-09-04.md create mode 100644 src/WxAgent.Core/WechatManagement.cs create mode 100644 src/WxAgent.Windows/WechatChatClient.Management.cs create mode 100644 tests/WxAgent.Core.Tests/WechatManagementTests.cs diff --git a/docs/validation/M4-Management-History-2026-09-04.md b/docs/validation/M4-Management-History-2026-09-04.md new file mode 100644 index 0000000..0808091 --- /dev/null +++ b/docs/validation/M4-Management-History-2026-09-04.md @@ -0,0 +1,74 @@ +# M4 联系人、群聊、子窗口与消息能力验证记录(2026-09-04) + +## 范围 + +本次 M4 增加以下 C# 公共能力: + +- 子窗口:枚举、按标题查找、双击会话打开、关闭。 +- 联系人:切换通讯录、新朋友列表、接受/添加好友、好友列表与详情、修改备注、本人信息。 +- 群聊:最近群聊、创建群、增删/邀请成员、成员列表、群名、群备注、群公告、群昵称、`@所有人`。 +- 历史与消息动作:任意会话历史消息、转发、发送者资料、从消息添加/删除好友、下载、笔记附件保存、图片 OCR、语音转文字、消息右键菜单。 +- 会话动作:删除、隐藏及通用右键菜单。 + +公开行为参考 wxautox4 官方 `WeChat`、`Chat`、`Message` 文档;C# API 不复制 Python 类型,统一使用强类型快照、`WechatOperationResult` 和 `WxAgentException`。 + +## 安全边界 + +- 所有发送、添加、接受、修改、删除、转发、下载/保存等有副作用操作均要求精确确认令牌 `CONFIRM`。 +- 删除好友保留 `clearChatHistory` 显式参数;删除会话仍视为不可恢复操作。 +- 真机检查只使用“文件传输助手”和只读页面,不操作真实联系人和群聊。 +- CLI 的 `m4-smoke` 只输出数量、布尔状态、窗口类型和进程号,不输出联系人、群名、账号标识或消息正文。 + +## Linux 验证 + +```text +~/.dotnet/dotnet test tests/WxAgent.Core.Tests -c Release --no-restore +Passed: 19, Failed: 0 + +~/.dotnet/dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true --no-restore +Build succeeded. 0 Warning(s), 0 Error(s) + +~/.dotnet/dotnet publish src/WxAgent.Host -c Release -r win-x64 --self-contained true \ + -p:EnableWindowsTargeting=true -p:PublishSingleFile=true -p:PublishTrimmed=false --no-restore +Publish succeeded. +``` + +新增 Core 测试覆盖:确认令牌严格匹配、缺失/错误令牌拒绝、成员名单输入校验、成功结果模型。 + +## Windows 真机验证 + +主机:`DESKTOP-EGI7QCK`(微信登录用户会话,非 Session 0) + +按发布要求执行: + +```text +WxAgent.Host doctor --timeout 30 exit 0 +WxAgent.Host inspect-ui --output artifacts-ui-tree.json --timeout 30 exit 0 +WxAgent.Host smoke --output artifacts-smoke-ui.json --timeout 30 exit 0 +WxAgent.Host m4-smoke --timeout 60 exit 0 +``` + +`m4-smoke` 结果: + +```json +{ + "subWindowOpened": true, + "historyCount": 3, + "contactCount": 14, + "newFriendCount": 0, + "recentGroupCount": 16, + "accountDetected": true, + "confirmationGuarded": true, + "privacy": "names and account identifiers omitted" +} +``` + +验证后已关闭双击打开的“文件传输助手”子窗口,并切回微信聊天页;桌面仅保留微信主窗口。 + +本地脱敏证据位于 `artifacts/m4-2026-09-04/`:doctor、inspect、smoke、M4 smoke 的输出和退出码,以及脱敏 UI 树。 + +## 结论与限制 + +- M4 公共 API、取消/超时链路、明确错误码、命令串行化、确认保护、Linux 测试/构建/发布和 Windows 只读真机 smoke 均已完成。 +- 涉及真实联系人或群聊的副作用路径未在现有个人账号执行;这是项目安全规则要求,不是缺失的默认行为。它们已通过编译和统一确认门禁检查,后续客户端版本回归应在专用测试账号/测试群中执行。 +- 客户端菜单文字、权限、群人数限制和确认弹窗由当前微信版本决定;找不到节点时统一返回 `ControlNotFound` / `UiStructureChanged`,不进行盲目重试。 diff --git a/src/WxAgent.Core/WechatManagement.cs b/src/WxAgent.Core/WechatManagement.cs new file mode 100644 index 0000000..f644c1f --- /dev/null +++ b/src/WxAgent.Core/WechatManagement.cs @@ -0,0 +1,46 @@ +namespace WxAgent.Core; + +public sealed record WechatContactSnapshot( + string DisplayName, + string? WechatId = null, + IReadOnlyList? Tags = null, + string? Signature = null, + string? Source = null, + int? CommonGroupCount = null); + +public sealed record WechatNewFriendSnapshot(string DisplayName, string Status); + +public sealed record WechatGroupMemberSnapshot(string DisplayName, string? GroupNickname = null, bool IsOwner = false); + +public sealed record WechatSubWindowSnapshot(string Title, string Kind, int ProcessId); + +public sealed record WechatAccountSnapshot(string DisplayName, string? WechatId = null, string? Region = null); + +public sealed record WechatOperationResult(bool Success, WxAgentErrorCode? Code, string Message) +{ + public static WechatOperationResult Ok(string message = "ok") => new(true, null, message); +} + +public static class WechatOperationPolicy +{ + public const string Confirmation = "CONFIRM"; + + public static void RequireConfirmation(string? confirmation, string operation) + { + if (!string.Equals(confirmation, Confirmation, StringComparison.Ordinal)) + { + throw new WxAgentException( + WxAgentErrorCode.InvalidArgument, + $"{operation} requires explicit confirmation '{Confirmation}'."); + } + } + + public static void ValidateNames(IEnumerable names, string parameterName) + { + ArgumentNullException.ThrowIfNull(names); + if (!names.Any() || names.Any(string.IsNullOrWhiteSpace)) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"{parameterName} must contain at least one non-empty name."); + } + } +} diff --git a/src/WxAgent.Host/Program.cs b/src/WxAgent.Host/Program.cs index 7fd3f6d..45ebedd 100644 --- a/src/WxAgent.Host/Program.cs +++ b/src/WxAgent.Host/Program.cs @@ -62,6 +62,65 @@ try }); return report.Errors.Count == 0 ? 0 : 2; } + case "m4-smoke": + { + var windows = await WechatChatClient.GetSubWindowsAsync(cancellationToken); + var history = await WechatChatClient.GetHistoryMessageAsync("文件传输助手", 3, cancellationToken: cancellationToken); + await WechatChatClient.OpenSessionInSubWindowAsync("文件传输助手", cancellationToken); + await Task.Delay(500, cancellationToken); + var windowsAfterDoubleClick = await WechatChatClient.GetSubWindowsAsync(cancellationToken); + await WechatChatClient.CloseSubWindowAsync("文件传输助手", cancellationToken); + var contacts = await WechatChatClient.GetFriendsAsync(5000, cancellationToken); + var newFriends = await WechatChatClient.GetNewFriendsAsync(true, cancellationToken); + var groups = await WechatChatClient.GetRecentGroupsAsync(cancellationToken); + var myInfo = await WechatChatClient.GetMyInfoAsync(cancellationToken); + await WechatChatClient.SwitchToChatsAsync(cancellationToken); + var confirmationGuarded = false; + try + { + WechatOperationPolicy.RequireConfirmation(null, "m4 smoke destructive operation"); + } + catch (WxAgentException exception) when (exception.Code == WxAgentErrorCode.InvalidArgument) + { + confirmationGuarded = true; + } + WriteJson(new + { + scope = "M4", + subWindows = windowsAfterDoubleClick.Select(window => new { window.Kind, window.ProcessId }), + subWindowOpened = windowsAfterDoubleClick.Count > windows.Count, + historyCount = history.Count, + contactCount = contacts.Count, + newFriendCount = newFriends.Count, + recentGroupCount = groups.Count, + accountDetected = !string.IsNullOrWhiteSpace(myInfo.DisplayName), + confirmationGuarded, + privacy = "names and account identifiers omitted" + }); + return confirmationGuarded + && !string.IsNullOrWhiteSpace(myInfo.DisplayName) + && history.Count > 0 + && windowsAfterDoubleClick.Count > windows.Count ? 0 : 2; + } + case "group" when args[1] == "at-all": + { + var group = GetRequiredOption(args, "--group"); + var message = GetRequiredOption(args, "--message"); + var confirmation = GetRequiredOption(args, "--confirm"); + var result = await WechatChatClient.AtAllAsync(group, message, confirmation, cancellationToken); + WriteJson(result); + return result.Success ? 0 : 2; + } + case "group" when args[1] == "verify-at-all": + { + var group = GetRequiredOption(args, "--group"); + var message = GetRequiredOption(args, "--message"); + var history = await WechatChatClient.GetHistoryMessageAsync(group, 10, cancellationToken: cancellationToken); + var found = history.Any(item => item.Text.Contains("@所有人", StringComparison.Ordinal) + && item.Text.Contains(message, StringComparison.Ordinal)); + WriteJson(new { found, checkedMessages = history.Count, privacy = "message text omitted" }); + return found ? 0 : 2; + } case "chat" when args[1] == "send": { var message = await WechatChatClient.SendTextAsync(GetRequiredOption(args, "--text"), cancellationToken); @@ -274,6 +333,24 @@ static int ValidateCommandLine(string[] values) return 30; } + if (values[0] == "m4-smoke") + { + ValidateOptions(values, 1, ["--timeout"], []); + return 60; + } + + if (values[0] == "group" && values.Length >= 2 && values[1] == "at-all") + { + ValidateOptions(values, 2, ["--group", "--message", "--confirm", "--timeout"], []); + return 30; + } + + if (values[0] == "group" && values.Length >= 2 && values[1] == "verify-at-all") + { + ValidateOptions(values, 2, ["--group", "--message", "--timeout"], []); + return 30; + } + if (values[0] == "chat" && values.Length >= 2) { switch (values[1]) @@ -448,6 +525,9 @@ WxAgent.Host commands: doctor [--timeout 30] inspect-ui --output [--timeout 30] smoke [--output ] [--timeout 30] + m4-smoke [--timeout 60] + group at-all --group --message --confirm CONFIRM [--timeout 30] + group verify-at-all --group --message [--timeout 30] chat send --text [--timeout 30] chat reply-latest --text [--timeout 60] chat send-file --path [--timeout 60] @@ -465,5 +545,6 @@ WxAgent.Host commands: db query --account --database [--key-file ] [--timeout 30] Chat commands are restricted to File Transfer Assistant. Message content is omitted unless --include-content is explicit. +M4 modifying operations are library APIs and require the exact confirmation token CONFIRM. Database keys are never printed. db scan only saves verified keys when --save is present. """); diff --git a/src/WxAgent.Windows/WechatChatClient.Management.cs b/src/WxAgent.Windows/WechatChatClient.Management.cs new file mode 100644 index 0000000..bf9280a --- /dev/null +++ b/src/WxAgent.Windows/WechatChatClient.Management.cs @@ -0,0 +1,774 @@ +using System.Diagnostics; +using FlaUI.Core; +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; +using FlaUI.Core.Input; +using FlaUI.Core.Tools; +using FlaUI.Core.WindowsAPI; +using FlaUI.UIA3; +using WxAgent.Core; + +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(); + var processIds = Process.GetProcessesByName("Weixin").Select(process => process.Id).ToHashSet(); + using var automation = new UIA3Automation(); + var windows = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window)) + .Where(window => processIds.Contains(window.Properties.ProcessId.ValueOrDefault) + && !window.Properties.IsOffscreen.ValueOrDefault + && !window.BoundingRectangle.IsEmpty) + .Select(window => new WechatSubWindowSnapshot( + SafeName(window), + ClassifyWindow(SafeName(window)), + window.Properties.ProcessId.ValueOrDefault)) + .ToArray(); + return Task.FromResult>(windows); + } + + public static async Task GetSubWindowAsync(string title, CancellationToken cancellationToken = default) => + (await GetSubWindowsAsync(cancellationToken).ConfigureAwait(false)) + .FirstOrDefault(window => string.Equals(window.Title, title, StringComparison.Ordinal)); + + public static Task CloseSubWindowAsync(string title, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var processIds = Process.GetProcessesByName("Weixin").Select(process => process.Id).ToHashSet(); + using var automation = new UIA3Automation(); + var window = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window)) + .FirstOrDefault(candidate => processIds.Contains(candidate.Properties.ProcessId.ValueOrDefault) + && !candidate.Properties.IsOffscreen.ValueOrDefault + && !candidate.BoundingRectangle.IsEmpty + && string.Equals(SafeName(candidate), title, StringComparison.Ordinal)); + window?.AsWindow().Close(); + return Task.CompletedTask; + } + + public static Task SwitchToContactsAsync(CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, _) => + { + await SwitchToContactAsync(main, cancellationToken).ConfigureAwait(false); + return true; + }, cancellationToken); + + public static Task SwitchToChatsAsync(CancellationToken cancellationToken = default) => + WithMainWindowAsync((main, _) => + { + ClickNamed(main, "微信", ControlType.Button); + return Task.FromResult(true); + }, cancellationToken); + + public static Task OpenSessionInSubWindowAsync(string session, CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, _) => + { + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + var item = FindByAutomationId(main, $"session_item_{session}") + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Session was not found."); + item.DoubleClick(); + return true; + }, cancellationToken); + + 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); + + public static Task GetMyInfoAsync(CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, _) => + { + var accountButton = main.FindAllDescendants(cf => cf.ByControlType(ControlType.Button)) + .FirstOrDefault(button => string.IsNullOrWhiteSpace(SafeAutomationId(button)) && !string.IsNullOrWhiteSpace(SafeName(button))) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Account button was not found."); + var displayName = SafeName(accountButton); + accountButton.Click(); + await Task.Delay(200, 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 WechatAccountSnapshot(displayName, ValueAfter(values, "微信号"), ValueAfter(values, "地区")); + }, cancellationToken); + + public static 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); + + public static Task> GetNewFriendsAsync( + bool includeAccepted = false, + CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, _) => + { + await SwitchToContactAsync(main, cancellationToken).ConfigureAwait(false); + ClickNamed(main, "新的朋友", ControlType.ListItem); + await Task.Delay(300, cancellationToken).ConfigureAwait(false); + var entries = main.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem)) + .Select(ParseNewFriend) + .Where(entry => entry is not null) + .Cast(); + if (!includeAccepted) + { + entries = entries.Where(entry => !entry.Status.Contains("已添加", StringComparison.Ordinal)); + } + + 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 Task AddNewFriendAsync( + string keywords, + string? requestMessage, + string confirmation, + CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("add friend", confirmation, async (main, _) => + { + if (string.IsNullOrWhiteSpace(keywords)) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "keywords is required."); + } + + ClickNamed(main, "通讯录", ControlType.Button); + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + ClickNamed(main, "新的朋友", ControlType.ListItem); + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + ClickFirstNamed(main, new[] { "添加朋友", "添加好友" }, ControlType.Button); + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + SetFirstEdit(main, keywords); + Keyboard.Press(VirtualKeyShort.ENTER); + await Task.Delay(500, cancellationToken).ConfigureAwait(false); + ClickFirstNamed(main, new[] { "添加到通讯录", "添加朋友" }, ControlType.Button); + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(requestMessage)) + { + SetFirstEdit(main, requestMessage); + } + ClickFirstNamed(main, new[] { "发送", "确定" }, ControlType.Button); + return WechatOperationResult.Ok("friend request submitted"); + }, cancellationToken); + + public static Task AcceptNewFriendAsync( + string displayName, + string? remark, + string confirmation, + CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("accept friend", confirmation, async (main, _) => + { + await SwitchToContactAsync(main, cancellationToken).ConfigureAwait(false); + ClickNamed(main, "新的朋友", ControlType.ListItem); + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + ClickNamed(main, displayName, ControlType.ListItem); + ClickFirstNamed(main, new[] { "接受", "通过验证" }, ControlType.Button); + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(remark)) + { + SetFirstEdit(main, remark); + } + ClickFirstNamed(main, new[] { "完成", "确定" }, ControlType.Button); + return WechatOperationResult.Ok("friend request accepted"); + }, cancellationToken); + + public static Task EditFriendRemarkAsync( + string displayName, + string remark, + string confirmation, + CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("edit friend remark", confirmation, async (main, _) => + { + await OpenContactAsync(main, displayName, cancellationToken).ConfigureAwait(false); + 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"); + }, cancellationToken); + + public static Task CreateGroupAsync( + IReadOnlyList contacts, + string confirmation, + CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("create group", confirmation, async (main, _) => + { + 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"); + }, cancellationToken); + + public static Task AddGroupMembersAsync(string group, IReadOnlyList members, string confirmation, CancellationToken cancellationToken = default) => + ChangeGroupMembersAsync(group, members, new[] { "添加成员", "+" }, "add group members", confirmation, cancellationToken); + + public static Task RemoveGroupMembersAsync(string group, IReadOnlyList members, string confirmation, CancellationToken cancellationToken = default) => + ChangeGroupMembersAsync(group, members, new[] { "移出群聊", "删除成员", "-" }, "remove group members", confirmation, cancellationToken); + + public static Task SetGroupNameAsync(string group, string value, string confirmation, CancellationToken cancellationToken = default) => + SetGroupTextAsync(group, new[] { "群聊名称", "群名称" }, value, "set group name", confirmation, cancellationToken); + + public static Task SetGroupRemarkAsync(string group, string value, string confirmation, CancellationToken cancellationToken = default) => + SetGroupTextAsync(group, new[] { "备注", "群聊备注" }, value, "set group remark", confirmation, cancellationToken); + + public static Task SetGroupAnnouncementAsync(string group, string value, string confirmation, CancellationToken cancellationToken = default) => + SetGroupTextAsync(group, new[] { "群公告" }, value, "set group announcement", confirmation, cancellationToken); + + 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 Task AtAllAsync(string group, string? message, string confirmation, CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("mention all", confirmation, async (main, automation) => + { + await OpenNamedSessionAsync(main, group, cancellationToken).ConfigureAwait(false); + var input = FindByAutomationId(main, WechatLocators.ChatInput) + ?? throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Chat input was not found."); + input.Click(); + Keyboard.TypeSimultaneously([VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_A]); + Keyboard.Press(VirtualKeyShort.BACK); + Keyboard.Type("@"); + await Task.Delay(300, cancellationToken).ConfigureAwait(false); + var everyone = automation.GetDesktop().FindAllDescendants(cf => cf.ByName("所有人")) + .FirstOrDefault(element => !element.Properties.IsOffscreen.ValueOrDefault && !element.BoundingRectangle.IsEmpty) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The WeChat '所有人' mention option was not found."); + everyone.Click(); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(message)) + { + Keyboard.Type(message); + } + Keyboard.Press(VirtualKeyShort.ENTER); + return WechatOperationResult.Ok("message sent"); + }, cancellationToken); + + public static Task SelectSessionOptionAsync(string session, string option, string? confirmation = null, CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, _) => + { + if (IsDestructiveOption(option)) + { + WechatOperationPolicy.RequireConfirmation(confirmation, $"session option '{option}'"); + } + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + var item = FindByAutomationId(main, $"session_item_{session}") + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Session was not found."); + item.RightClick(); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + ClickNamed(main, option); + ConfirmDialogIfPresent(main); + return WechatOperationResult.Ok("session option selected"); + }, cancellationToken); + + public static Task DeleteSessionAsync(string session, string confirmation, CancellationToken cancellationToken = default) => + SelectSessionOptionAsync(session, "删除聊天", confirmation, cancellationToken); + + public static Task HideSessionAsync(string session, string confirmation, CancellationToken cancellationToken = default) => + SelectSessionOptionAsync(session, "不显示聊天", confirmation, cancellationToken); + + private static Task ChangeGroupMembersAsync(string group, IReadOnlyList members, string[] actions, string operation, string confirmation, CancellationToken cancellationToken) => + WithConfirmedMainWindowAsync(operation, confirmation, async (main, _) => + { + WechatOperationPolicy.ValidateNames(members, nameof(members)); + 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"); + }, cancellationToken); + + private static Task SetGroupTextAsync(string group, string[] labels, string value, string operation, string confirmation, CancellationToken cancellationToken) => + WithConfirmedMainWindowAsync(operation, confirmation, async (main, _) => + { + await OpenGroupInfoAsync(main, group, cancellationToken).ConfigureAwait(false); + 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"); + }, cancellationToken); + + private static async Task WithConfirmedMainWindowAsync(string operation, string confirmation, Func> action, CancellationToken cancellationToken) + { + WechatOperationPolicy.RequireConfirmation(confirmation, operation); + return await WithMainWindowAsync(action, cancellationToken).ConfigureAwait(false); + } + + private static async Task WithMainWindowAsync(Func> action, CancellationToken cancellationToken) + { + await CommandQueue.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + using var automation = new UIA3Automation(); + var main = AttachWindow(automation); + return await action(main, automation).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (WxAgentException) + { + throw; + } + catch (Exception exception) + { + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "WeChat UI operation failed.", exception); + } + finally + { + CommandQueue.Release(); + } + } + + private static async Task SwitchToContactAsync(AutomationElement main, CancellationToken cancellationToken) + { + ClickNamed(main, "微信", ControlType.Button); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + ClickNamed(main, "通讯录", ControlType.Button); + await Task.Delay(250, cancellationToken).ConfigureAwait(false); + } + + private static async Task OpenContactAsync(AutomationElement main, string displayName, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(displayName)) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "displayName is required."); + } + await SwitchToContactAsync(main, cancellationToken).ConfigureAwait(false); + ClickNamed(main, displayName, ControlType.ListItem); + await Task.Delay(250, cancellationToken).ConfigureAwait(false); + } + + private static async Task OpenNamedSessionAsync(AutomationElement main, string session, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(session)) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "session is required."); + } + if (main.FindAllDescendants().Any(element => + SafeAutomationId(element).EndsWith("current_chat_name_label", StringComparison.Ordinal) + && string.Equals(SafeName(element), session, StringComparison.Ordinal)) + && FindByAutomationId(main, WechatLocators.MessageList) is not null) + { + return; + } + if (string.Equals(session, WechatLocators.FileTransferAssistant, StringComparison.Ordinal)) + { + await OpenFileTransferAssistantCoreAsync(main, cancellationToken).ConfigureAwait(false); + return; + } + var direct = FindByAutomationId(main, $"session_item_{session}"); + if (direct is not null) + { + direct.Click(); + await WaitForSessionPageAsync(main, cancellationToken).ConfigureAwait(false); + return; + } + var search = main.FindAllDescendants().FirstOrDefault(element => + SafeControlType(element) == ControlType.Edit && SafeName(element) == WechatLocators.Search) + ?? throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Search box was not found."); + search.AsTextBox().Text = session; + await Task.Delay(500, cancellationToken).ConfigureAwait(false); + ClickNamed(main, session); + await WaitForSessionPageAsync(main, cancellationToken).ConfigureAwait(false); + } + + private static async Task WaitForSessionPageAsync(AutomationElement main, CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 25; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + if (FindByAutomationId(main, WechatLocators.MessageList) is not null) return; + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found."); + } + + private static async Task OpenGroupInfoAsync(AutomationElement main, string group, CancellationToken cancellationToken) + { + await OpenNamedSessionAsync(main, group, cancellationToken).ConfigureAwait(false); + ClickFirstNamed(main, new[] { "聊天信息", "更多" }, ControlType.Button); + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + } + + private static async Task SelectPeopleAsync(AutomationElement main, IEnumerable names, CancellationToken cancellationToken) + { + 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) + { + edit.AsTextBox().Text = name; + await Task.Delay(150, cancellationToken).ConfigureAwait(false); + } + ClickNamed(main, name); + } + } + + 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; + } + + 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 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."); + } + + private static void ConfirmDialogIfPresent(AutomationElement root) + { + 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; + } + } + } + + private static WechatNewFriendSnapshot? ParseNewFriend(AutomationElement element) + { + var values = element.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) + .Select(SafeName) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .ToArray(); + return values.Length == 0 ? null : new WechatNewFriendSnapshot(values[0], values.Length > 1 ? values[^1] : "unknown"); + } + + private static string? ValueAfter(IReadOnlyList values, string label) + { + for (var index = 0; index < values.Count - 1; index++) + { + if (values[index].StartsWith(label, StringComparison.Ordinal)) + { + var inline = values[index][label.Length..].Trim(' ', ':', ':'); + return inline.Length > 0 ? inline : values[index + 1]; + } + } + 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, + Func? callback = null, + bool returnToLatest = true, + CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, _) => + { + if (count is < 1 or > 1000) + { + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "count must be between 1 and 1000."); + } + + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + var messages = new List(); + var seen = new HashSet(StringComparer.Ordinal); + var unchanged = 0; + var stopped = false; + var scrolls = 0; + while (messages.Count < count && unchanged < 3 && !stopped) + { + var before = messages.Count; + foreach (var message in ReadVisible(main)) + { + if (!seen.Add(message.Fingerprint)) continue; + messages.Add(message); + if (callback?.Invoke(message) == false) + { + stopped = true; + break; + } + if (messages.Count >= count) break; + } + if (messages.Count >= count || stopped) break; + unchanged = messages.Count == before ? unchanged + 1 : 0; + var list = FindByAutomationId(main, WechatLocators.MessageList) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Message list was not found."); + MoveToCenter(list); + Mouse.Scroll(5); + scrolls++; + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + } + + if (returnToLatest && scrolls > 0) + { + Mouse.Scroll(-5d * scrolls); + } + return (IReadOnlyList)messages.Take(count).ToArray(); + }, cancellationToken); + + public static Task ForwardVisibleMessageAsync( + string session, + string fingerprint, + IReadOnlyList targets, + string confirmation, + CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("forward message", confirmation, async (main, _) => + { + WechatOperationPolicy.ValidateNames(targets, nameof(targets)); + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + var element = FindVisibleMessageElement(main, fingerprint); + element.RightClick(); + 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"); + }, cancellationToken); + + public static Task> GetVisibleMessageSenderInfoAsync( + string session, + string fingerprint, + CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, _) => + { + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + var element = FindVisibleMessageElement(main, fingerprint); + element.RightClick(); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + ClickFirstNamed(main, new[] { "查看资料", "发送者信息" }); + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + var values = main.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) + .Select(SafeName).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray(); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var label in new[] { "昵称", "微信号", "地区", "来源" }) + { + if (ValueAfter(values, label) is { } value) result[label] = value; + } + return (IReadOnlyDictionary)result; + }, cancellationToken); + + public static Task AddVisibleMessageSenderAsFriendAsync( + string session, + string fingerprint, + string? requestMessage, + string confirmation, + CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("add message sender as friend", confirmation, async (main, _) => + { + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + FindVisibleMessageElement(main, fingerprint).RightClick(); + 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"); + }, cancellationToken); + + public static Task DeleteVisibleMessageSenderFriendAsync( + string session, + string fingerprint, + bool clearChatHistory, + string confirmation, + CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("delete friend", confirmation, async (main, _) => + { + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + FindVisibleMessageElement(main, fingerprint).RightClick(); + ClickFirstNamed(main, new[] { "查看资料", "发送者信息" }); + await Task.Delay(150, cancellationToken).ConfigureAwait(false); + ClickFirstNamed(main, new[] { "更多", "更多信息" }, ControlType.Button); + ClickNamed(main, "删除"); + 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(); + } + ConfirmDialogIfPresent(main); + return WechatOperationResult.Ok("friend deleted"); + }, cancellationToken); + + public static Task DownloadVisibleMessageAsync( + string session, + string fingerprint, + string destinationPath, + string confirmation, + CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("download message", confirmation, async (main, _) => + { + if (string.IsNullOrWhiteSpace(destinationPath)) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "destinationPath is required."); + 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; + }, cancellationToken); + + public static Task OcrVisibleImageAsync(string session, string fingerprint, CancellationToken cancellationToken = default) => + ReadMessageDerivedTextAsync(session, fingerprint, new[] { "提取文字", "识别文字" }, cancellationToken); + + public static Task VoiceToTextAsync(string session, string fingerprint, CancellationToken cancellationToken = default) => + ReadMessageDerivedTextAsync(session, fingerprint, new[] { "语音转文字", "转文字" }, cancellationToken); + + public static Task SaveVisibleNoteFilesAsync(string session, string fingerprint, string destinationPath, string confirmation, CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("save note files", confirmation, async (main, _) => + { + 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"); + }, cancellationToken); + + public static Task SelectVisibleMessageOptionAsync(string session, string fingerprint, string option, string? confirmation = null, CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, _) => + { + if (IsDestructiveOption(option)) WechatOperationPolicy.RequireConfirmation(confirmation, $"message option '{option}'"); + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + FindVisibleMessageElement(main, fingerprint).RightClick(); + ClickNamed(main, option); + ConfirmDialogIfPresent(main); + return WechatOperationResult.Ok("message option selected"); + }, cancellationToken); + + public static Task InviteGroupMembersAsync(string group, IReadOnlyList members, string confirmation, CancellationToken cancellationToken = default) => + AddGroupMembersAsync(group, members, confirmation, cancellationToken); + + private static Task ReadMessageDerivedTextAsync(string session, string fingerprint, string[] actions, CancellationToken cancellationToken) => + WithMainWindowAsync(async (main, _) => + { + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + FindVisibleMessageElement(main, fingerprint).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)); + }, cancellationToken); + + 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."); + } + + private static bool IsDestructiveOption(string option) => option.Contains("删除", StringComparison.Ordinal) || option.Contains("不显示", StringComparison.Ordinal); + + private static string ClassifyWindow(string title) => + title.Contains("图片", StringComparison.Ordinal) ? "image" : + title.Contains("视频", StringComparison.Ordinal) ? "video" : + title.Contains("文件", StringComparison.Ordinal) ? "file" : + title.Contains("聊天记录", StringComparison.Ordinal) ? "history" : + "window"; +} diff --git a/src/WxAgent.Windows/WechatChatClient.cs b/src/WxAgent.Windows/WechatChatClient.cs index 1fdf650..a5435c0 100644 --- a/src/WxAgent.Windows/WechatChatClient.cs +++ b/src/WxAgent.Windows/WechatChatClient.cs @@ -11,7 +11,7 @@ using WxAgent.Core; namespace WxAgent.Windows; -public static class WechatChatClient +public static partial class WechatChatClient { private static readonly SemaphoreSlim CommandQueue = new(1, 1); diff --git a/tests/WxAgent.Core.Tests/WechatManagementTests.cs b/tests/WxAgent.Core.Tests/WechatManagementTests.cs new file mode 100644 index 0000000..8481665 --- /dev/null +++ b/tests/WxAgent.Core.Tests/WechatManagementTests.cs @@ -0,0 +1,40 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatManagementTests +{ + [Fact] + public void RequireConfirmation_RejectsMissingOrWrongToken() + { + var missing = Assert.Throws(() => WechatOperationPolicy.RequireConfirmation(null, "delete session")); + var wrong = Assert.Throws(() => WechatOperationPolicy.RequireConfirmation("confirm", "delete session")); + + Assert.Equal(WxAgentErrorCode.InvalidArgument, missing.Code); + Assert.Equal(WxAgentErrorCode.InvalidArgument, wrong.Code); + } + + [Fact] + public void RequireConfirmation_AcceptsExactToken() + { + WechatOperationPolicy.RequireConfirmation(WechatOperationPolicy.Confirmation, "delete session"); + } + + [Fact] + public void ValidateNames_RequiresAtLeastOneUsableName() + { + Assert.Throws(() => WechatOperationPolicy.ValidateNames([], "members")); + Assert.Throws(() => WechatOperationPolicy.ValidateNames(["ok", ""], "members")); + WechatOperationPolicy.ValidateNames(["one", "two"], "members"); + } + + [Fact] + public void OperationResult_OkHasNoErrorCode() + { + var result = WechatOperationResult.Ok("done"); + Assert.True(result.Success); + Assert.Null(result.Code); + Assert.Equal("done", result.Message); + } +}