Files
wx-win-agent/node-agent/WxAgent.Windows/WechatChatClient.Management.cs
T
rogee c7c0ab273f
Build web service image / build (push) Successful in 1m9s
feat: validate single-client broadcast operations
2026-09-19 14:33:26 +08:00

1003 lines
59 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<IReadOnlyList<WechatSubWindowSnapshot>> 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<IReadOnlyList<WechatSubWindowSnapshot>>(windows);
}
public static async Task<WechatSubWindowSnapshot?> 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();
}
/// <summary>Compatibility name: returns database-known groups, not a recency-ordered session list.</summary>
public static async Task<IReadOnlyList<string>> 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<WechatContactPage> 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<WechatAccountSnapshot> GetMyInfoAsync(CancellationToken cancellationToken = default) =>
WithMainWindowAsync(async (main, automation) =>
{
var desktop = automation.GetDesktop();
var settings = FindPreferenceWindow(desktop);
var closeSettings = settings is null;
settings ??= await OpenPreferenceWindowAsync(main, automation, cancellationToken).ConfigureAwait(false);
try
{
var accountLabel = settings.FindAllDescendants(cf => cf.ByControlType(ControlType.Text))
.FirstOrDefault(element => SafeName(element) == "账号");
if (accountLabel is null)
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Account section was not found in WeChat settings.");
var labelBounds = accountLabel.BoundingRectangle;
var values = settings.FindAllDescendants(cf => cf.ByControlType(ControlType.Text))
.Select(element => (Name: SafeName(element), Bounds: element.BoundingRectangle))
.Where(value => !string.IsNullOrWhiteSpace(value.Name)
&& value.Bounds.Top >= labelBounds.Bottom
&& value.Bounds.Bottom <= labelBounds.Bottom + 90
&& value.Bounds.Left >= labelBounds.Left
&& value.Bounds.Right <= labelBounds.Right - 80)
.OrderBy(value => value.Bounds.Top)
.Select(value => value.Name)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (values.Length < 2)
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Account identity was not found in WeChat settings.");
return new WechatAccountSnapshot(values[0], values[1]);
}
finally
{
if (closeSettings)
{
try { settings.AsWindow().Close(); } catch { }
}
}
}, cancellationToken);
/// <summary>Reads non-group contact rows, up to maxCount. Use the paged API for completeness and account selection.</summary>
public static async Task<IReadOnlyList<WechatContactSnapshot>> 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<IReadOnlyList<WechatNewFriendSnapshot>> 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<WechatNewFriendSnapshot>();
if (!includeAccepted)
{
entries = entries.Where(entry => !entry.Status.Contains("已添加", StringComparison.Ordinal));
}
return (IReadOnlyList<WechatNewFriendSnapshot>)entries.ToArray();
}, cancellationToken);
public static async Task<WechatContactSnapshot> 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<WechatOperationResult> 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<WechatOperationResult> 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<WechatOperationResult> 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<WechatOperationResult> CreateGroupAsync(
IReadOnlyList<string> 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<WechatOperationResult> AddGroupMembersAsync(string group, IReadOnlyList<string> members, string confirmation, CancellationToken cancellationToken = default) =>
ChangeGroupMembersAsync(group, members, new[] { "添加成员", "+" }, "add group members", confirmation, cancellationToken);
public static Task<WechatOperationResult> RemoveGroupMembersAsync(string group, IReadOnlyList<string> members, string confirmation, CancellationToken cancellationToken = default) =>
ChangeGroupMembersAsync(group, members, new[] { "移出群聊", "删除成员", "-" }, "remove group members", confirmation, cancellationToken);
public static Task<WechatOperationResult> SetGroupNameAsync(string group, string value, string confirmation, CancellationToken cancellationToken = default) =>
SetGroupTextAsync(group, new[] { "群聊名称", "群名称" }, value, "set group name", confirmation, cancellationToken);
public static Task<WechatOperationResult> SetGroupRemarkAsync(string group, string value, string confirmation, CancellationToken cancellationToken = default) =>
SetGroupTextAsync(group, new[] { "备注", "群聊备注" }, value, "set group remark", confirmation, cancellationToken);
public static Task<WechatOperationResult> SetGroupAnnouncementAsync(string group, string value, string confirmation, CancellationToken cancellationToken = default) =>
SetGroupTextAsync(group, new[] { "群公告" }, value, "set group announcement", confirmation, cancellationToken);
public static Task<WechatOperationResult> SetMyNicknameInGroupAsync(string group, string value, string confirmation, CancellationToken cancellationToken = default) =>
SetGroupTextAsync(group, new[] { "我在本群的昵称", "群昵称" }, value, "set group nickname", confirmation, cancellationToken);
public static async Task<IReadOnlyList<WechatGroupMemberSnapshot>> 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<WechatGroupMemberPage> 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<WechatOperationResult> AtAllAsync(string group, string? message, string confirmation, CancellationToken cancellationToken = default) =>
MentionMemberAsync(group, "所有人", message, confirmation, cancellationToken);
public static Task<WechatOperationResult> 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<WechatOperationResult> 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<WechatOperationResult> DeleteSessionAsync(string session, string confirmation, CancellationToken cancellationToken = default) =>
SelectSessionOptionAsync(session, "删除聊天", confirmation, cancellationToken);
public static Task<WechatOperationResult> HideSessionAsync(string session, string confirmation, CancellationToken cancellationToken = default) =>
SelectSessionOptionAsync(session, "不显示聊天", confirmation, cancellationToken);
private static Task<WechatOperationResult> ChangeGroupMembersAsync(string group, IReadOnlyList<string> 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<WechatOperationResult> 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<WechatOperationResult> 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<T> WithConfirmedMainWindowAsync<T>(string operation, string confirmation, Func<AutomationElement, UIA3Automation, Task<T>> action, CancellationToken cancellationToken)
{
WechatOperationPolicy.RequireConfirmation(confirmation, operation);
return await WithMainWindowAsync(action, cancellationToken).ConfigureAwait(false);
}
private static async Task<T> WithMainWindowAsync<T>(Func<AutomationElement, UIA3Automation, Task<T>> 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<string> 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<AutomationElement> WaitForPersonCheckboxAsync(AutomationElement main, string name, CancellationToken cancellationToken)
{
var lastScroll = -1d;
// ponytail: bounded visible-list scan; replace with a verified recipient search endpoint if WeChat exposes one.
for (var attempt = 0; attempt < 100; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
var matches = FindPersonCheckboxes(main, name);
if (matches.Length == 1) return matches[0];
if (matches.Length > 1)
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Multiple person checkboxes named '{name}' were exposed.");
var list = FindByAutomationId(RequireManagementDialog(main), "sp_to_select_contact_list");
if (list is null)
{
await Task.Delay(150, cancellationToken).ConfigureAwait(false);
continue;
}
if (!list.Patterns.Scroll.IsSupported) break;
var scroll = list.Patterns.Scroll.Pattern;
var current = scroll.VerticalScrollPercent.Value;
if (current >= 99.9 || Math.Abs(current - lastScroll) < 0.01) break;
lastScroll = current;
var step = Math.Max(1, scroll.VerticalViewSize.Value * 0.8);
ExecuteInputStep("scroll-recipient-list", () => scroll.SetScrollPercent(-1, Math.Min(100, current + step)));
await Task.Delay(250, cancellationToken).ConfigureAwait(false);
}
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Person checkbox '{name}' was not exposed.");
}
private static async Task<AutomationElement> OpenPreferenceWindowAsync(
AutomationElement main, UIA3Automation automation, CancellationToken cancellationToken)
{
var desktop = automation.GetDesktop();
var menu = FindSettingsMenu(desktop);
if (menu is null)
{
var settingsButton = FindByAutomationId(main, WechatLocators.SettingsMenuButton)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat settings button was not found.");
ExecuteInputStep("open-settings-menu", () => settingsButton.Click());
}
var clickedSettings = false;
for (var attempt = 0; attempt < 40; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
var settings = FindPreferenceWindow(desktop);
if (settings is not null) return settings;
menu ??= FindSettingsMenu(desktop);
if (menu is not null && !clickedSettings)
{
ClickNamed(menu, "设置", ControlType.Button);
clickedSettings = true;
}
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat settings window was not found.");
}
private static AutomationElement? FindPreferenceWindow(AutomationElement desktop) =>
desktop.FindAllChildren(cf => cf.ByControlType(ControlType.Window))
.FirstOrDefault(window => IsActionable(window)
&& (SafeAutomationId(window) == WechatLocators.PreferenceWindow
|| SafeName(window) == "设置"));
private static AutomationElement? FindSettingsMenu(AutomationElement desktop) =>
desktop.FindAllChildren(cf => cf.ByControlType(ControlType.Window))
.FirstOrDefault(window => IsActionable(window)
&& window.FindAllDescendants(cf => cf.ByControlType(ControlType.Button))
.Any(button => IsActionable(button) && SafeName(button) == "设置"));
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<string> 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<string> 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<IReadOnlyList<ChatMessageSnapshot>> GetHistoryMessageAsync(
string session,
int count,
Func<ChatMessageSnapshot, bool>? 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<ChatMessageSnapshot>();
var seen = new HashSet<string>(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<ChatMessageSnapshot>)messages.Take(count).ToArray();
}, cancellationToken);
public static Task<WechatOperationResult> ForwardVisibleMessageAsync(
string session,
string fingerprint,
IReadOnlyList<string> 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<IReadOnlyDictionary<string, string>> 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<string, string>(StringComparer.Ordinal);
foreach (var label in new[] { "昵称", "微信号", "地区", "来源" })
{
if (ValueAfter(values, label) is { } value) result[label] = value;
}
return (IReadOnlyDictionary<string, string>)result;
}, cancellationToken);
public static Task<WechatOperationResult> 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<WechatOperationResult> 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<string> 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<string> OcrVisibleImageAsync(string session, string fingerprint, CancellationToken cancellationToken = default) =>
ReadMessageDerivedTextAsync(session, fingerprint, new[] { "提取文字", "识别文字" }, cancellationToken);
public static Task<string> VoiceToTextAsync(string session, string fingerprint, CancellationToken cancellationToken = default) =>
ReadMessageDerivedTextAsync(session, fingerprint, new[] { "语音转文字", "转文字" }, cancellationToken);
public static Task<WechatOperationResult> 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<WechatOperationResult> 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<WechatOperationResult> InviteGroupMembersAsync(string group, IReadOnlyList<string> members, string confirmation, CancellationToken cancellationToken = default) =>
AddGroupMembersAsync(group, members, confirmation, cancellationToken);
private static Task<string> 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";
}