feat: complete M4 contact and group automation

This commit is contained in:
2026-09-04 23:01:02 +08:00
parent 5e3aa97644
commit 77e1e9dc2b
6 changed files with 1016 additions and 1 deletions
@@ -0,0 +1,74 @@
# M4 联系人、群聊、子窗口与消息能力验证记录(2026-09-04)
## 范围
本次 M4 增加以下 C# 公共能力:
- 子窗口:枚举、按标题查找、双击会话打开、关闭。
- 联系人:切换通讯录、新朋友列表、接受/添加好友、好友列表与详情、修改备注、本人信息。
- 群聊:最近群聊、创建群、增删/邀请成员、成员列表、群名、群备注、群公告、群昵称、`@所有人`
- 历史与消息动作:任意会话历史消息、转发、发送者资料、从消息添加/删除好友、下载、笔记附件保存、图片 OCR、语音转文字、消息右键菜单。
- 会话动作:删除、隐藏及通用右键菜单。
公开行为参考 wxautox4 官方 `WeChat``Chat``Message` 文档;C# API 不复制 Python 类型,统一使用强类型快照、`WechatOperationResult``WxAgentException`
## 安全边界
- 所有发送、添加、接受、修改、删除、转发、下载/保存等有副作用操作均要求精确确认令牌 `CONFIRM`
- 删除好友保留 `clearChatHistory` 显式参数;删除会话仍视为不可恢复操作。
- 真机检查只使用“文件传输助手”和只读页面,不操作真实联系人和群聊。
- CLI 的 `m4-smoke` 只输出数量、布尔状态、窗口类型和进程号,不输出联系人、群名、账号标识或消息正文。
## Linux 验证
```text
~/.dotnet/dotnet test tests/WxAgent.Core.Tests -c Release --no-restore
Passed: 19, Failed: 0
~/.dotnet/dotnet build WxAgent.sln -c Release -p:EnableWindowsTargeting=true --no-restore
Build succeeded. 0 Warning(s), 0 Error(s)
~/.dotnet/dotnet publish src/WxAgent.Host -c Release -r win-x64 --self-contained true \
-p:EnableWindowsTargeting=true -p:PublishSingleFile=true -p:PublishTrimmed=false --no-restore
Publish succeeded.
```
新增 Core 测试覆盖:确认令牌严格匹配、缺失/错误令牌拒绝、成员名单输入校验、成功结果模型。
## Windows 真机验证
主机:`DESKTOP-EGI7QCK`(微信登录用户会话,非 Session 0)
按发布要求执行:
```text
WxAgent.Host doctor --timeout 30 exit 0
WxAgent.Host inspect-ui --output artifacts-ui-tree.json --timeout 30 exit 0
WxAgent.Host smoke --output artifacts-smoke-ui.json --timeout 30 exit 0
WxAgent.Host m4-smoke --timeout 60 exit 0
```
`m4-smoke` 结果:
```json
{
"subWindowOpened": true,
"historyCount": 3,
"contactCount": 14,
"newFriendCount": 0,
"recentGroupCount": 16,
"accountDetected": true,
"confirmationGuarded": true,
"privacy": "names and account identifiers omitted"
}
```
验证后已关闭双击打开的“文件传输助手”子窗口,并切回微信聊天页;桌面仅保留微信主窗口。
本地脱敏证据位于 `artifacts/m4-2026-09-04/`doctor、inspect、smoke、M4 smoke 的输出和退出码,以及脱敏 UI 树。
## 结论与限制
- M4 公共 API、取消/超时链路、明确错误码、命令串行化、确认保护、Linux 测试/构建/发布和 Windows 只读真机 smoke 均已完成。
- 涉及真实联系人或群聊的副作用路径未在现有个人账号执行;这是项目安全规则要求,不是缺失的默认行为。它们已通过编译和统一确认门禁检查,后续客户端版本回归应在专用测试账号/测试群中执行。
- 客户端菜单文字、权限、群人数限制和确认弹窗由当前微信版本决定;找不到节点时统一返回 `ControlNotFound` / `UiStructureChanged`,不进行盲目重试。
+46
View File
@@ -0,0 +1,46 @@
namespace WxAgent.Core;
public sealed record WechatContactSnapshot(
string DisplayName,
string? WechatId = null,
IReadOnlyList<string>? Tags = null,
string? Signature = null,
string? Source = null,
int? CommonGroupCount = null);
public sealed record WechatNewFriendSnapshot(string DisplayName, string Status);
public sealed record WechatGroupMemberSnapshot(string DisplayName, string? GroupNickname = null, bool IsOwner = false);
public sealed record WechatSubWindowSnapshot(string Title, string Kind, int ProcessId);
public sealed record WechatAccountSnapshot(string DisplayName, string? WechatId = null, string? Region = null);
public sealed record WechatOperationResult(bool Success, WxAgentErrorCode? Code, string Message)
{
public static WechatOperationResult Ok(string message = "ok") => new(true, null, message);
}
public static class WechatOperationPolicy
{
public const string Confirmation = "CONFIRM";
public static void RequireConfirmation(string? confirmation, string operation)
{
if (!string.Equals(confirmation, Confirmation, StringComparison.Ordinal))
{
throw new WxAgentException(
WxAgentErrorCode.InvalidArgument,
$"{operation} requires explicit confirmation '{Confirmation}'.");
}
}
public static void ValidateNames(IEnumerable<string> names, string parameterName)
{
ArgumentNullException.ThrowIfNull(names);
if (!names.Any() || names.Any(string.IsNullOrWhiteSpace))
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"{parameterName} must contain at least one non-empty name.");
}
}
}
+81
View File
@@ -62,6 +62,65 @@ try
});
return report.Errors.Count == 0 ? 0 : 2;
}
case "m4-smoke":
{
var windows = await WechatChatClient.GetSubWindowsAsync(cancellationToken);
var history = await WechatChatClient.GetHistoryMessageAsync("文件传输助手", 3, cancellationToken: cancellationToken);
await WechatChatClient.OpenSessionInSubWindowAsync("文件传输助手", cancellationToken);
await Task.Delay(500, cancellationToken);
var windowsAfterDoubleClick = await WechatChatClient.GetSubWindowsAsync(cancellationToken);
await WechatChatClient.CloseSubWindowAsync("文件传输助手", cancellationToken);
var contacts = await WechatChatClient.GetFriendsAsync(5000, cancellationToken);
var newFriends = await WechatChatClient.GetNewFriendsAsync(true, cancellationToken);
var groups = await WechatChatClient.GetRecentGroupsAsync(cancellationToken);
var myInfo = await WechatChatClient.GetMyInfoAsync(cancellationToken);
await WechatChatClient.SwitchToChatsAsync(cancellationToken);
var confirmationGuarded = false;
try
{
WechatOperationPolicy.RequireConfirmation(null, "m4 smoke destructive operation");
}
catch (WxAgentException exception) when (exception.Code == WxAgentErrorCode.InvalidArgument)
{
confirmationGuarded = true;
}
WriteJson(new
{
scope = "M4",
subWindows = windowsAfterDoubleClick.Select(window => new { window.Kind, window.ProcessId }),
subWindowOpened = windowsAfterDoubleClick.Count > windows.Count,
historyCount = history.Count,
contactCount = contacts.Count,
newFriendCount = newFriends.Count,
recentGroupCount = groups.Count,
accountDetected = !string.IsNullOrWhiteSpace(myInfo.DisplayName),
confirmationGuarded,
privacy = "names and account identifiers omitted"
});
return confirmationGuarded
&& !string.IsNullOrWhiteSpace(myInfo.DisplayName)
&& history.Count > 0
&& windowsAfterDoubleClick.Count > windows.Count ? 0 : 2;
}
case "group" when args[1] == "at-all":
{
var group = GetRequiredOption(args, "--group");
var message = GetRequiredOption(args, "--message");
var confirmation = GetRequiredOption(args, "--confirm");
var result = await WechatChatClient.AtAllAsync(group, message, confirmation, cancellationToken);
WriteJson(result);
return result.Success ? 0 : 2;
}
case "group" when args[1] == "verify-at-all":
{
var group = GetRequiredOption(args, "--group");
var message = GetRequiredOption(args, "--message");
var history = await WechatChatClient.GetHistoryMessageAsync(group, 10, cancellationToken: cancellationToken);
var found = history.Any(item => item.Text.Contains("@所有人", StringComparison.Ordinal)
&& item.Text.Contains(message, StringComparison.Ordinal));
WriteJson(new { found, checkedMessages = history.Count, privacy = "message text omitted" });
return found ? 0 : 2;
}
case "chat" when args[1] == "send":
{
var message = await WechatChatClient.SendTextAsync(GetRequiredOption(args, "--text"), cancellationToken);
@@ -274,6 +333,24 @@ static int ValidateCommandLine(string[] values)
return 30;
}
if (values[0] == "m4-smoke")
{
ValidateOptions(values, 1, ["--timeout"], []);
return 60;
}
if (values[0] == "group" && values.Length >= 2 && values[1] == "at-all")
{
ValidateOptions(values, 2, ["--group", "--message", "--confirm", "--timeout"], []);
return 30;
}
if (values[0] == "group" && values.Length >= 2 && values[1] == "verify-at-all")
{
ValidateOptions(values, 2, ["--group", "--message", "--timeout"], []);
return 30;
}
if (values[0] == "chat" && values.Length >= 2)
{
switch (values[1])
@@ -448,6 +525,9 @@ WxAgent.Host commands:
doctor [--timeout 30]
inspect-ui --output <path> [--timeout 30]
smoke [--output <path>] [--timeout 30]
m4-smoke [--timeout 60]
group at-all --group <name> --message <text> --confirm CONFIRM [--timeout 30]
group verify-at-all --group <name> --message <text> [--timeout 30]
chat send --text <one-line-text> [--timeout 30]
chat reply-latest --text <one-line-text> [--timeout 60]
chat send-file --path <file> [--timeout 60]
@@ -465,5 +545,6 @@ WxAgent.Host commands:
db query --account <fingerprint> --database <relative-path> [--key-file <path>] [--timeout 30]
Chat commands are restricted to File Transfer Assistant. Message content is omitted unless --include-content is explicit.
M4 modifying operations are library APIs and require the exact confirmation token CONFIRM.
Database keys are never printed. db scan only saves verified keys when --save is present.
""");
@@ -0,0 +1,774 @@
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 Task CloseSubWindowAsync(string title, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
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();
return Task.CompletedTask;
}
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);
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";
}
+1 -1
View File
@@ -11,7 +11,7 @@ using WxAgent.Core;
namespace WxAgent.Windows;
public static class WechatChatClient
public static partial class WechatChatClient
{
private static readonly SemaphoreSlim CommandQueue = new(1, 1);
@@ -0,0 +1,40 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatManagementTests
{
[Fact]
public void RequireConfirmation_RejectsMissingOrWrongToken()
{
var missing = Assert.Throws<WxAgentException>(() => WechatOperationPolicy.RequireConfirmation(null, "delete session"));
var wrong = Assert.Throws<WxAgentException>(() => WechatOperationPolicy.RequireConfirmation("confirm", "delete session"));
Assert.Equal(WxAgentErrorCode.InvalidArgument, missing.Code);
Assert.Equal(WxAgentErrorCode.InvalidArgument, wrong.Code);
}
[Fact]
public void RequireConfirmation_AcceptsExactToken()
{
WechatOperationPolicy.RequireConfirmation(WechatOperationPolicy.Confirmation, "delete session");
}
[Fact]
public void ValidateNames_RequiresAtLeastOneUsableName()
{
Assert.Throws<WxAgentException>(() => WechatOperationPolicy.ValidateNames([], "members"));
Assert.Throws<WxAgentException>(() => WechatOperationPolicy.ValidateNames(["ok", ""], "members"));
WechatOperationPolicy.ValidateNames(["one", "two"], "members");
}
[Fact]
public void OperationResult_OkHasNoErrorCode()
{
var result = WechatOperationResult.Ok("done");
Assert.True(result.Success);
Assert.Null(result.Code);
Assert.Equal("done", result.Message);
}
}