778 lines
40 KiB
C#
778 lines
40 KiB
C#
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";
|
||
}
|