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 async Task CloseSubWindowAsync(string title, CancellationToken cancellationToken = default) { await CommandQueue.WaitAsync(cancellationToken).ConfigureAwait(false); try { 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(); } finally { CommandQueue.Release(); } } 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, cancellationToken); 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"; }