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 { 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 OpenSessionInSubWindowCoreAsync(main, session, cancellationToken).ConfigureAwait(false); return true; }, cancellationToken); private static async Task OpenSessionInSubWindowCoreAsync(AutomationElement main, string session, CancellationToken cancellationToken) { 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(); } /// 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, automation) => { ClickProfileAvatar(main); var desktop = automation.GetDesktop(); AutomationElement? profile = null; for (var attempt = 0; attempt < 30 && profile is null; attempt++) { profile = desktop.FindAllChildren(cf => cf.ByControlType(ControlType.Window)) .FirstOrDefault(window => IsActionable(window) && string.Equals(SafeName(window), WechatLocators.ProfileWindowTitle, StringComparison.Ordinal) && FindByAutomationId(window, WechatLocators.ProfileDisplayName) is not null); if (profile is null) await Task.Delay(100, cancellationToken).ConfigureAwait(false); } if (profile is null) throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Account profile window was not found."); try { var displayName = SafeName(FindByAutomationId(profile, WechatLocators.ProfileDisplayName)); var wechatId = SafeName(FindByAutomationId(profile, WechatLocators.ProfileWechatId)); if (string.IsNullOrWhiteSpace(displayName) && string.IsNullOrWhiteSpace(wechatId)) throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Account identity was not found in the profile window."); return new WechatAccountSnapshot( string.IsNullOrWhiteSpace(displayName) ? wechatId : displayName, string.IsNullOrWhiteSpace(wechatId) ? null : wechatId); } finally { profile.AsWindow().Close(); } }, cancellationToken); /// 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) { 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, 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 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, 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); } ClickDialogAction(main, "发送", "确定"); return WechatOperationResult.Unconfirmed("Friend request"); }, 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); } ClickDialogAction(main, "完成", "确定"); return await ConfirmUiSubmissionAsync(main, "Friend acceptance", cancellationToken).ConfigureAwait(false); }, 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); ClickDialogAction(main, "完成", "确定"); return await ConfirmUiSubmissionAsync(main, "Friend remark update", cancellationToken).ConfigureAwait(false); }, 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); 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) => 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 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); public static Task MentionMemberAsync(string session, string memberName, string? message, string confirmation, CancellationToken cancellationToken = default) => WithConfirmedMainWindowAsync("mention member", confirmation, async (main, automation) => { ArgumentException.ThrowIfNullOrWhiteSpace(session); ArgumentException.ThrowIfNullOrWhiteSpace(memberName); if (memberName.Any(char.IsControl) || memberName.Length > 100 || message is not null && (message.Any(char.IsControl) || message.Length > 4000)) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Mention names and text must be single-line, with no control characters."); await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); var input = FindByAutomationId(main, WechatLocators.ChatInput) ?? throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Chat input was not found."); if (!IsListeningSession(main, session, independent: false)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before mention input."); if (!string.IsNullOrEmpty(input.AsTextBox().Text)) throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The conversation contains an existing draft; it was not overwritten."); var baseline = ReadVisible(main).Select(item => item.Fingerprint).ToHashSet(StringComparer.Ordinal); if (main.FindAllDescendants(cf => cf.ByAutomationId(WechatLocators.MentionPopover)) .Any(element => !element.Properties.IsOffscreen.ValueOrDefault)) throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "An existing mention popup must be dismissed before starting a new operation."); ClickCenter(input); ExecuteInputStep("open-mention", () => Keyboard.Type("@")); await Task.Delay(400, cancellationToken).ConfigureAwait(false); // Observed on 4.1.13.63: the popover is a child of the bound main window. var popovers = main.FindAllDescendants(cf => cf.ByAutomationId(WechatLocators.MentionPopover)) .Where(element => !element.Properties.IsOffscreen.ValueOrDefault && !element.BoundingRectangle.IsEmpty).ToArray(); if (popovers.Length != 1) throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "A unique mention popup was not found; no send was attempted."); var lists = popovers[0].FindAllDescendants(cf => cf.ByAutomationId(WechatLocators.MentionList)); if (lists.Length != 1) throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "A unique mention list was not found; no send was attempted."); var options = lists[0].FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem).And(cf.ByName(memberName))) .Where(element => !element.Properties.IsOffscreen.ValueOrDefault && !element.BoundingRectangle.IsEmpty) .ToArray(); if (options.Length != 1) throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "A unique mention option in a fresh WeChat popup was not found; no send was attempted."); if (!IsListeningSession(main, session, independent: false)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before mention selection."); ClickCenter(options[0]); await Task.Delay(100, cancellationToken).ConfigureAwait(false); input = FindByAutomationId(main, WechatLocators.ChatInput) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Chat input disappeared after mention selection."); var mentionToken = input.AsTextBox().Text; if (!WechatSendConfirmation.IsMentionToken(mentionToken)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The selected mention was not confirmed in the input; no send was attempted."); if (!string.IsNullOrEmpty(message)) { if (!IsListeningSession(main, session, independent: false)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before mention text input."); // Paste preserves the selected mention token and avoids IME conversion of typed Latin text. using var clipboard = new ClipboardLease(" " + message, asText: true); ExecuteInputStep("mention-text", () => Keyboard.TypeSimultaneously([VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_V])); await Task.Delay(300, cancellationToken).ConfigureAwait(false); } var text = input.AsTextBox().Text; if (!IsListeningSession(main, session, independent: false) || !string.Equals(text, mentionToken + (string.IsNullOrEmpty(message) ? "" : " " + message), StringComparison.Ordinal)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The mention input or conversation changed; no send was attempted."); var send = main.FindAllDescendants().FirstOrDefault(element => SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found."); ClickCenter(send); var confirmed = await WaitForNewMessageAsync(baseline, session, ChatMessageType.Text, message, TimeSpan.FromSeconds(20), cancellationToken, memberName); return confirmed is not null ? WechatOperationResult.Ok("visible message confirmed") : throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "No matching visible mention confirmed the send; no retry was attempted."); }, 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 await ConfirmUiSubmissionAsync(main, "Session option", cancellationToken).ConfigureAwait(false); }, 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); 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) => 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); 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); 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) { // Use the same visible-center click path as the rest of the UI executor; // FlaUI's Click() can report success without changing WeChat's virtualized chat pane. ClickCenter(direct); await WaitForSessionPageAsync(main, session, cancellationToken).ConfigureAwait(false); return; } await OpenLocalSearchSessionAsync(main, session, cancellationToken).ConfigureAwait(false); } private static async Task WaitForSessionPageAsync(AutomationElement main, string session, CancellationToken cancellationToken) { for (var attempt = 0; attempt < 100; attempt++) { cancellationToken.ThrowIfCancellationRequested(); if (IsListeningSession(main, session, independent: false) && 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(); // Prefer the unfiltered list. This avoids an IME/clipboard round-trip for visible names. var direct = FindPersonCheckboxes(main, name); if (direct.Length == 1) { 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; } 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."); } // WeChat exposes the avatar as a nameless custom widget; use the stable main-tab bar bounds. private static void ClickProfileAvatar(AutomationElement main) { var tabBar = FindByAutomationId(main, WechatLocators.MainTabBar) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat navigation bar was not found."); var bounds = tabBar.BoundingRectangle; if (bounds.Width <= 0 || bounds.Height <= bounds.Width) throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat navigation bar has no usable bounds."); ExecuteInputStep("open-profile", () => Mouse.Click( new System.Drawing.Point(bounds.Left + bounds.Width / 2, bounds.Top + bounds.Width), MouseButton.Left)); } 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 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) => ClickFirstNamed(root, new[] { name }, controlType); private static void ClickFirstNamed(AutomationElement root, IEnumerable names, ControlType? controlType = null) { 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 ClickDialogAction(AutomationElement root, params string[] names) => ClickFirstNamed(RequireManagementDialog(root), names, ControlType.Button); private static void ConfirmDialogIfPresent(AutomationElement root, params string[] names) { var dialogs = ManagementDialogs(root); if (dialogs.Length == 0) return; ClickFirstNamed(WechatOperationPolicy.RequireUnique(dialogs, "confirmation dialog"), names, ControlType.Button); } 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; } 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 && scrolls < 100) { cancellationToken.ThrowIfCancellationRequested(); RequireNavigationSession(main, session); 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; ScrollNavigationList(main, WechatLocators.MessageList, WechatScrollDirection.Up); scrolls++; await Task.Delay(200, cancellationToken).ConfigureAwait(false); } if (returnToLatest && scrolls > 0) { await GoToLatestCoreAsync(main, session, 100, cancellationToken).ConfigureAwait(false); } 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); ClickDialogAction(main, "发送", "确定"); return await ConfirmUiSubmissionAsync(main, "Message forwarding", cancellationToken).ConfigureAwait(false); }, 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); ClickDialogAction(main, "发送", "确定"); return await ConfirmUiSubmissionAsync(main, "Friend request", cancellationToken).ConfigureAwait(false); }, 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 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 await ConfirmUiSubmissionAsync(main, "Friend deletion", cancellationToken).ConfigureAwait(false); }, cancellationToken); public static Task DownloadVisibleMessageAsync( string session, string fingerprint, string destinationPath, string confirmation, CancellationToken cancellationToken = default) => WithConfirmedMainWindowAsync("download message", confirmation, async (main, _) => { 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); 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) => 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, _) => { 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); 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) => 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 await ConfirmUiSubmissionAsync(main, "Message option", cancellationToken).ConfigureAwait(false); }, 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); 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); 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) .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); 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"; }