refactor: split node agent and Go control plane

This commit is contained in:
2026-09-11 16:57:31 +08:00
parent 078c22c73b
commit c86ba9c4d7
129 changed files with 654 additions and 77 deletions
@@ -0,0 +1,960 @@
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) =>
{
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);
/// <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)
{
direct.Click();
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 < 25; 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.");
}
// ponytail: WeChat exposes the avatar as a nameless custom widget; use the first rail slot until it exposes a stable UIA locator.
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<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";
}