Files
wx-win-agent/src/WxAgent.Windows/WechatChatClient.Management.cs
T

778 lines
40 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
{
private static readonly HashSet<string> ContactHeadings = new(StringComparer.Ordinal)
{
"新的朋友", "群聊", "标签", "公众号", "企业微信联系人"
};
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 OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false);
var item = FindByAutomationId(main, $"session_item_{session}")
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Session was not found.");
item.DoubleClick();
return true;
}, cancellationToken);
public static Task<IReadOnlyList<string>> GetRecentGroupsAsync(CancellationToken cancellationToken = default) =>
WithMainWindowAsync(async (main, _) =>
{
await SwitchToContactAsync(main, cancellationToken).ConfigureAwait(false);
ClickFirstContaining(main, "群聊", ControlType.ListItem);
await Task.Delay(250, cancellationToken).ConfigureAwait(false);
return (IReadOnlyList<string>)main.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem))
.Select(SafeName)
.Where(name => !string.IsNullOrWhiteSpace(name) && !ContactHeadings.Contains(name))
.Distinct(StringComparer.Ordinal)
.ToArray();
}, cancellationToken);
public static Task<WechatAccountSnapshot> GetMyInfoAsync(CancellationToken cancellationToken = default) =>
WithMainWindowAsync(async (main, _) =>
{
var accountButton = main.FindAllDescendants(cf => cf.ByControlType(ControlType.Button))
.FirstOrDefault(button => string.IsNullOrWhiteSpace(SafeAutomationId(button)) && !string.IsNullOrWhiteSpace(SafeName(button)))
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Account button was not found.");
var displayName = SafeName(accountButton);
accountButton.Click();
await Task.Delay(200, cancellationToken).ConfigureAwait(false);
var values = main.FindAllDescendants(cf => cf.ByControlType(ControlType.Text))
.Select(SafeName).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray();
return new WechatAccountSnapshot(displayName, ValueAfter(values, "微信号"), ValueAfter(values, "地区"));
}, cancellationToken);
public static Task<IReadOnlyList<WechatContactSnapshot>> GetFriendsAsync(
int maxCount = 1000,
CancellationToken cancellationToken = default) =>
WithMainWindowAsync(async (main, _) =>
{
if (maxCount <= 0)
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "maxCount must be positive.");
}
await SwitchToContactAsync(main, cancellationToken).ConfigureAwait(false);
var contacts = main.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem))
.Select(element => SafeName(element))
.Where(name => !string.IsNullOrWhiteSpace(name) && !ContactHeadings.Contains(name))
.Distinct(StringComparer.Ordinal)
.Take(maxCount)
.Select(name => new WechatContactSnapshot(name))
.ToArray();
return (IReadOnlyList<WechatContactSnapshot>)contacts;
}, cancellationToken);
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 Task<WechatContactSnapshot> GetFriendDetailsAsync(string displayName, CancellationToken cancellationToken = default) =>
WithMainWindowAsync(async (main, _) =>
{
await OpenContactAsync(main, displayName, cancellationToken).ConfigureAwait(false);
var values = main.FindAllDescendants(cf => cf.ByControlType(ControlType.Text))
.Select(SafeName)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray();
return new WechatContactSnapshot(
displayName,
ValueAfter(values, "微信号"),
ValueAfter(values, "标签") is { } tags ? tags.Split('、', StringSplitOptions.RemoveEmptyEntries) : null,
ValueAfter(values, "个性签名"),
ValueAfter(values, "来源"),
ParseCount(ValueAfter(values, "共同群聊")));
}, cancellationToken);
public static Task<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);
}
ClickFirstNamed(main, new[] { "发送", "确定" }, ControlType.Button);
return WechatOperationResult.Ok("friend request submitted");
}, 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);
}
ClickFirstNamed(main, new[] { "完成", "确定" }, ControlType.Button);
return WechatOperationResult.Ok("friend request accepted");
}, 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);
ClickFirstNamed(main, new[] { "完成", "确定" }, ControlType.Button);
return WechatOperationResult.Ok("friend remark updated");
}, 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);
ClickFirstNamed(main, new[] { "完成", "确定" }, ControlType.Button);
return WechatOperationResult.Ok("group created");
}, 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 Task<IReadOnlyList<WechatGroupMemberSnapshot>> GetGroupMembersAsync(string group, CancellationToken cancellationToken = default) =>
WithMainWindowAsync(async (main, _) =>
{
await OpenGroupInfoAsync(main, group, cancellationToken).ConfigureAwait(false);
var names = main.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem))
.Select(SafeName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.Ordinal)
.Select(name => new WechatGroupMemberSnapshot(name))
.ToArray();
return (IReadOnlyList<WechatGroupMemberSnapshot>)names;
}, cancellationToken);
public static Task<WechatOperationResult> AtAllAsync(string group, string? message, string confirmation, CancellationToken cancellationToken = default) =>
WithConfirmedMainWindowAsync("mention all", confirmation, async (main, automation) =>
{
await OpenNamedSessionAsync(main, group, cancellationToken).ConfigureAwait(false);
var input = FindByAutomationId(main, WechatLocators.ChatInput)
?? throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Chat input was not found.");
input.Click();
Keyboard.TypeSimultaneously([VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_A]);
Keyboard.Press(VirtualKeyShort.BACK);
Keyboard.Type("@");
await Task.Delay(300, cancellationToken).ConfigureAwait(false);
var everyone = automation.GetDesktop().FindAllDescendants(cf => cf.ByName("所有人"))
.FirstOrDefault(element => !element.Properties.IsOffscreen.ValueOrDefault && !element.BoundingRectangle.IsEmpty)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The WeChat '所有人' mention option was not found.");
everyone.Click();
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(message))
{
Keyboard.Type(message);
}
Keyboard.Press(VirtualKeyShort.ENTER);
return WechatOperationResult.Ok("message sent");
}, cancellationToken);
public static Task<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 WechatOperationResult.Ok("session option selected");
}, 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);
ClickFirstNamed(main, new[] { "完成", "确定", "删除" }, ControlType.Button);
return WechatOperationResult.Ok(operation + " completed");
}, 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);
ClickFirstNamed(main, new[] { "完成", "确定", "发布" }, ControlType.Button);
ConfirmDialogIfPresent(main);
return WechatOperationResult.Ok(operation + " completed");
}, cancellationToken);
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, cancellationToken).ConfigureAwait(false);
return;
}
var search = main.FindAllDescendants().FirstOrDefault(element =>
SafeControlType(element) == ControlType.Edit && SafeName(element) == WechatLocators.Search)
?? throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Search box was not found.");
search.AsTextBox().Text = session;
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
ClickNamed(main, session);
await WaitForSessionPageAsync(main, cancellationToken).ConfigureAwait(false);
}
private static async Task WaitForSessionPageAsync(AutomationElement main, CancellationToken cancellationToken)
{
for (var attempt = 0; attempt < 25; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
if (FindByAutomationId(main, WechatLocators.MessageList) is not null) return;
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found.");
}
private static async Task OpenGroupInfoAsync(AutomationElement main, string group, CancellationToken cancellationToken)
{
await OpenNamedSessionAsync(main, group, cancellationToken).ConfigureAwait(false);
ClickFirstNamed(main, new[] { "聊天信息", "更多" }, ControlType.Button);
await Task.Delay(200, cancellationToken).ConfigureAwait(false);
}
private static async Task SelectPeopleAsync(AutomationElement main, IEnumerable<string> names, CancellationToken cancellationToken)
{
foreach (var name in names)
{
cancellationToken.ThrowIfCancellationRequested();
var edit = main.FindAllDescendants(cf => cf.ByControlType(ControlType.Edit))
.FirstOrDefault(candidate => !candidate.Properties.IsOffscreen.ValueOrDefault);
if (edit is not null)
{
edit.AsTextBox().Text = name;
await Task.Delay(150, cancellationToken).ConfigureAwait(false);
}
ClickNamed(main, name);
}
}
private static void SetFirstEdit(AutomationElement main, string value)
{
var edit = main.FindAllDescendants(cf => cf.ByControlType(ControlType.Edit))
.FirstOrDefault(candidate => !candidate.Properties.IsOffscreen.ValueOrDefault)
?? throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Expected text input was not found.");
edit.AsTextBox().Text = value;
}
private static void ClickNamed(AutomationElement root, string name, ControlType? controlType = null)
{
var element = root.FindFirstDescendant(cf => controlType is null
? cf.ByName(name)
: cf.ByName(name).And(cf.ByControlType(controlType.Value)));
if (element is null)
{
throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, $"Expected control '{name}' was not found.");
}
element.Click();
}
private static void ClickFirstContaining(AutomationElement root, string text, ControlType? controlType = null)
{
var element = root.FindAllDescendants()
.FirstOrDefault(candidate => SafeName(candidate).Contains(text, StringComparison.Ordinal)
&& (controlType is null || SafeControlType(candidate) == controlType));
if (element is null)
{
throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, $"Expected control containing '{text}' was not found.");
}
element.Click();
}
private static void ClickFirstNamed(AutomationElement root, IEnumerable<string> names, ControlType? controlType = null)
{
foreach (var name in names)
{
var element = root.FindFirstDescendant(cf => controlType is null
? cf.ByName(name)
: cf.ByName(name).And(cf.ByControlType(controlType.Value)));
if (element is null)
{
continue;
}
element.Click();
return;
}
throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "None of the expected controls were found.");
}
private static void ConfirmDialogIfPresent(AutomationElement root)
{
foreach (var name in new[] { "确定", "删除", "发布" })
{
var button = root.FindFirstDescendant(cf => cf.ByName(name).And(cf.ByControlType(ControlType.Button)));
if (button is not null)
{
button.Click();
return;
}
}
}
private static WechatNewFriendSnapshot? ParseNewFriend(AutomationElement element)
{
var values = element.FindAllDescendants(cf => cf.ByControlType(ControlType.Text))
.Select(SafeName)
.Where(value => !string.IsNullOrWhiteSpace(value))
.ToArray();
return values.Length == 0 ? null : new WechatNewFriendSnapshot(values[0], values.Length > 1 ? values[^1] : "unknown");
}
private static string? ValueAfter(IReadOnlyList<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;
}
private static int? ParseCount(string? value)
{
if (value is null) return null;
var digits = new string(value.Where(char.IsDigit).ToArray());
return int.TryParse(digits, out var count) ? count : null;
}
public static Task<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)
{
var before = messages.Count;
foreach (var message in ReadVisible(main))
{
if (!seen.Add(message.Fingerprint)) continue;
messages.Add(message);
if (callback?.Invoke(message) == false)
{
stopped = true;
break;
}
if (messages.Count >= count) break;
}
if (messages.Count >= count || stopped) break;
unchanged = messages.Count == before ? unchanged + 1 : 0;
var list = FindByAutomationId(main, WechatLocators.MessageList)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Message list was not found.");
MoveToCenter(list);
Mouse.Scroll(5);
scrolls++;
await Task.Delay(200, cancellationToken).ConfigureAwait(false);
}
if (returnToLatest && scrolls > 0)
{
Mouse.Scroll(-5d * scrolls);
}
return (IReadOnlyList<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);
ClickFirstNamed(main, new[] { "发送", "确定" }, ControlType.Button);
return WechatOperationResult.Ok("message forwarded");
}, 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);
ClickFirstNamed(main, new[] { "发送", "确定" }, ControlType.Button);
return WechatOperationResult.Ok("friend request submitted");
}, 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 check = main.FindFirstDescendant(cf => cf.ByName("同时删除聊天记录").And(cf.ByControlType(ControlType.CheckBox)));
if (check?.AsCheckBox().IsChecked == true) check.Click();
}
ConfirmDialogIfPresent(main);
return WechatOperationResult.Ok("friend deleted");
}, cancellationToken);
public static Task<string> DownloadVisibleMessageAsync(
string session,
string fingerprint,
string destinationPath,
string confirmation,
CancellationToken cancellationToken = default) =>
WithConfirmedMainWindowAsync("download message", confirmation, async (main, _) =>
{
if (string.IsNullOrWhiteSpace(destinationPath))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "destinationPath is required.");
await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false);
FindVisibleMessageElement(main, fingerprint).RightClick();
ClickFirstNamed(main, new[] { "另存为...", "另存为", "保存" });
await Task.Delay(200, cancellationToken).ConfigureAwait(false);
Keyboard.Type(destinationPath);
Keyboard.Press(VirtualKeyShort.ENTER);
return destinationPath;
}, cancellationToken);
public static Task<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, _) =>
{
await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false);
FindVisibleMessageElement(main, fingerprint).RightClick();
ClickFirstNamed(main, new[] { "保存附件", "另存为...", "另存为" });
await Task.Delay(200, cancellationToken).ConfigureAwait(false);
Keyboard.Type(destinationPath);
Keyboard.Press(VirtualKeyShort.ENTER);
return WechatOperationResult.Ok("note files saved");
}, cancellationToken);
public static Task<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 WechatOperationResult.Ok("message option selected");
}, 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);
FindVisibleMessageElement(main, fingerprint).RightClick();
ClickFirstNamed(main, actions);
await Task.Delay(300, cancellationToken).ConfigureAwait(false);
return string.Join(Environment.NewLine, main.FindAllDescendants(cf => cf.ByControlType(ControlType.Text))
.Select(SafeName).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal));
}, cancellationToken);
private static AutomationElement FindVisibleMessageElement(AutomationElement main, string fingerprint)
{
var list = FindByAutomationId(main, WechatLocators.MessageList)
?? throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Message list was not found.");
var elements = list.FindAllDescendants()
.Where(element => SafeAutomationId(element) == WechatLocators.ChatBubbleItem)
.ToArray();
var snapshots = ReadVisible(main);
for (var index = 0; index < Math.Min(elements.Length, snapshots.Count); index++)
{
if (snapshots[index].Fingerprint == fingerprint) return elements[index];
}
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The visible message fingerprint was not found.");
}
private static bool IsDestructiveOption(string option) => option.Contains("删除", StringComparison.Ordinal) || option.Contains("不显示", StringComparison.Ordinal);
private static string ClassifyWindow(string title) =>
title.Contains("图片", StringComparison.Ordinal) ? "image" :
title.Contains("视频", StringComparison.Ordinal) ? "video" :
title.Contains("文件", StringComparison.Ordinal) ? "file" :
title.Contains("聊天记录", StringComparison.Ordinal) ? "history" :
"window";
}