From 1bd0344e8b4daec7671f95fdf93c4d965302b832 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 4 Sep 2026 23:48:28 +0800 Subject: [PATCH] feat: implement M5 moments and rich messages --- docs/M5-功能清单.md | 54 +++ .../M5-Moments-Rich-Messages-2026-09-04.md | 73 +++ src/WxAgent.Core/ChatMessages.cs | 12 + src/WxAgent.Core/WechatM5.cs | 97 ++++ src/WxAgent.Host/Program.cs | 66 +++ src/WxAgent.Windows/WechatChatClient.M5.cs | 428 ++++++++++++++++++ tests/WxAgent.Core.Tests/WechatM5Tests.cs | 76 ++++ 7 files changed, 806 insertions(+) create mode 100644 docs/M5-功能清单.md create mode 100644 docs/validation/M5-Moments-Rich-Messages-2026-09-04.md create mode 100644 src/WxAgent.Core/WechatM5.cs create mode 100644 src/WxAgent.Windows/WechatChatClient.M5.cs create mode 100644 tests/WxAgent.Core.Tests/WechatM5Tests.cs diff --git a/docs/M5-功能清单.md b/docs/M5-功能清单.md new file mode 100644 index 0000000..baeabb9 --- /dev/null +++ b/docs/M5-功能清单.md @@ -0,0 +1,54 @@ +# M5 功能清单 + +依据 `docs/WxAgent-CSharp-开发计划.md` 与 wxautox4 当前官方公开文档,M5 共包含以下能力。 + +## 1. 多聊天子窗口 + +- 双击会话弹出独立聊天窗口。 +- 按聊天名称查找已存在的子窗口。 +- 枚举全部已打开子窗口。 +- 关闭子窗口。 +- 所有窗口的 UI 写操作仍进入同一命令队列,不并行操作微信 UI。 + +## 2. 朋友圈 + +- 打开朋友圈窗口 `Moments`。 +- 读取当前页朋友圈 `GetMoments`,返回短生命周期快照。 +- 刷新朋友圈 `Refresh`。 +- 发布文字、图片/视频朋友圈 `PublishMoment` / `Publish`。 +- 发布范围:公开、标签白名单、标签黑名单。 +- 点赞/取消赞 `Like`。 +- 评论 `Comment`。 + +朋友圈条目没有稳定公开 ID;读取后应立即操作,刷新后旧指纹不得盲目复用。发布、点赞和评论均要求 `CONFIRM`,且禁止自动重试。 + +## 3. 特殊发送 + +- 发送 URL 卡片 `SendUrlCard`,支持多个目标和可选附带消息。 +- 发送本地音频为微信语音条 `SendAudio`;按官方文档标记为 Beta,单次执行且不盲目重试。 + +## 4. 消息动作与内容读取 + +- 对消息发送者“拍一拍” `tickle`。 +- 获取链接卡片 URL `get_url`。 +- 读取微信笔记内容 `NoteMessage.get_content`。 +- 将微信笔记保存为 Markdown `NoteMessage.to_markdown`。 +- 通用消息内容/Markdown 视图,供 C# 调用方处理普通消息。 + +当前官方文档只为 `NoteMessage` 定义 `get_content()` / `to_markdown()`。合并转发消息在 v3.9 曾有 `get_messages()`,当前 v4 无公开展开合同,因此不伪装成 Note API;现阶段仅保留消息类型识别。 + +## 5. M5 验收项 + +- Core:URL 提取、内容模型、Markdown、朋友圈指纹、特殊消息分类、发布参数校验。 +- Linux:Core 测试、全量 Release 构建、Windows x64 self-contained 发布。 +- Windows:`doctor`、`inspect-ui`、`smoke`、`m5-smoke`。 +- 真机写操作只允许文件传输助手或专用测试对象;朋友圈发布、点赞、评论不得在真实联系人内容上试验。 + +## 官方行为基线 + +- +- +- +- +- +- diff --git a/docs/validation/M5-Moments-Rich-Messages-2026-09-04.md b/docs/validation/M5-Moments-Rich-Messages-2026-09-04.md new file mode 100644 index 0000000..e506bd5 --- /dev/null +++ b/docs/validation/M5-Moments-Rich-Messages-2026-09-04.md @@ -0,0 +1,73 @@ +# M5 朋友圈、特殊发送与富消息验证记录(2026-09-04) + +## 功能范围 + +完整清单见 `docs/M5-功能清单.md`。本阶段实现: + +- 多聊天子窗口:弹出、查找、枚举和关闭(复用并补齐 M4 基础能力)。 +- 朋友圈:打开、当前页/下一页读取、刷新、发布文字和媒体、公开/标签白名单/标签黑名单、点赞/取消赞、评论。 +- 特殊发送:URL 卡片、PCM WAV 音频录制为语音条。 +- 消息:拍一拍、链接 URL、特殊消息分类、微信笔记内容、笔记 Markdown 文件。 +- 当前 v4 官方文档没有合并转发内容展开合同,因此只识别 `merge` 类型,不把 Note API 错套到合并消息。 + +## 安全设计 + +- 发布朋友圈、点赞、评论、URL 卡片、语音和拍一拍均要求精确确认令牌 `CONFIRM`。 +- 所有微信 UI 写操作继续使用统一命令队列;发送失败或结果未确认时不自动重试。 +- 朋友圈指纹只用于同一可见页面内的短生命周期定位;刷新/翻页后重新读取。 +- CLI 和验证产物不输出朋友圈作者、正文、联系人名称或消息正文。 + +## Linux 验证 + +```text +~/.dotnet/dotnet test tests/WxAgent.Core.Tests -c Release --no-restore +Passed: 30, 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. +``` + +新增测试覆盖 URL 提取、链接/引用 Markdown、Note Markdown、特殊消息分类、朋友圈稳定指纹和朋友圈可见范围参数校验。 + +## Windows 真机验证 + +主机:`DESKTOP-EGI7QCK`,微信登录用户会话。 + +```text +WxAgent.Host doctor --timeout 30 exit 0 +WxAgent.Host inspect-ui --output m5-ui-tree.json --timeout 30 exit 0 +WxAgent.Host smoke --output m5-smoke-ui.json --timeout 30 exit 0 +WxAgent.Host m5-smoke --timeout 60 exit 0 +``` + +`m5-smoke` 脱敏结果: + +```json +{ + "scope": "M5", + "momentsWindow": "moments", + "momentCount": 2, + "urlParsed": true, + "markdownRendered": true, + "confirmationGuarded": true, + "privacy": "moment authors and content omitted" +} +``` + +验证覆盖朋友圈打开、刷新、只读快照、关闭并返回聊天页。URL 卡片在“文件传输助手”单次发送后读回为链接消息;后续一次结果确认超时按规则返回 `ResultUnconfirmed`,未自动重试。 + +语音能力已实现为按住微信“发语音”控件并播放 PCM WAV,且失败后不重试。当前 Windows 主机没有可用麦克风输入,用户明确要求跳过语音真机发送验收;保留 Beta 能力和明确诊断,不以文件消息冒充语音消息。 + +朋友圈发布、点赞、评论和拍一拍未对真实联系人内容执行;只验证输入、确认门禁、编译和只读页面定位,符合项目安全规则。 + +脱敏证据位于 `artifacts/m5-2026-09-04/`。 + +## 官方行为差异 + +- `NoteMessage.get_content()` / `to_markdown()` 仅适用于 Note;合并消息当前无公开展开 API。 +- `SendAudio` 官方标为 Beta 且要求额外配置,本项目只支持单个 PCM WAV,不复制官方默认自动重试。 +- 朋友圈发布公开文档没有稳定返回合同;本项目不因无异常而盲目重试。 diff --git a/src/WxAgent.Core/ChatMessages.cs b/src/WxAgent.Core/ChatMessages.cs index e95ae67..7b6ff28 100644 --- a/src/WxAgent.Core/ChatMessages.cs +++ b/src/WxAgent.Core/ChatMessages.cs @@ -11,6 +11,12 @@ public enum ChatMessageType Video, Voice, Link, + Location, + Emotion, + Merge, + PersonalCard, + Note, + Other, Quote, System } @@ -106,6 +112,12 @@ public static class VisibleMessageParser return ChatMessageType.File; } + if (text is "[位置]" or "位置" || text.StartsWith("位置\n", StringComparison.Ordinal)) return ChatMessageType.Location; + if (text is "[动画表情]" or "动画表情" or "表情") return ChatMessageType.Emotion; + if (text.Contains("聊天记录", StringComparison.Ordinal)) return ChatMessageType.Merge; + if (text is "[名片]" or "个人名片" || text.StartsWith("名片\n", StringComparison.Ordinal)) return ChatMessageType.PersonalCard; + if (text is "[笔记]" or "笔记" || text.StartsWith("笔记\n", StringComparison.Ordinal)) return ChatMessageType.Note; + if (!text.Contains(Path.DirectorySeparatorChar) && !text.Contains(Path.AltDirectorySeparatorChar) && Path.GetExtension(text) is { Length: > 1 } extension && extension.Length <= 10) { diff --git a/src/WxAgent.Core/WechatM5.cs b/src/WxAgent.Core/WechatM5.cs new file mode 100644 index 0000000..8571554 --- /dev/null +++ b/src/WxAgent.Core/WechatM5.cs @@ -0,0 +1,97 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace WxAgent.Core; + +public sealed record WechatMomentSnapshot( + string Author, + string Text, + string Fingerprint, + string? Time = null, + int ImageCount = 0, + bool IsLiked = false); + +public sealed record WechatMessageContent( + ChatMessageType Type, + string Text, + Uri? Url = null, + QuotedMessageSnapshot? Quote = null); + +public sealed record WechatNoteContentPart(string Kind, string Value); + +public sealed record WechatNoteContent(IReadOnlyList Parts) +{ + public string ToMarkdown() => string.Join( + Environment.NewLine + Environment.NewLine, + Parts.Select(part => part.Kind == "file" ? $"[{Path.GetFileName(part.Value)}]({part.Value})" : part.Value)); +} + +public enum WechatMomentPrivacy +{ + Public, + TagWhitelist, + TagBlacklist +} + +public sealed record WechatMomentPublishOptions( + WechatMomentPrivacy Privacy = WechatMomentPrivacy.Public, + IReadOnlyList? Tags = null) +{ + public void Validate() + { + if (!Enum.IsDefined(Privacy)) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown moment privacy mode."); + if (Privacy != WechatMomentPrivacy.Public) + WechatOperationPolicy.ValidateNames(Tags ?? [], nameof(Tags)); + } +} + +public static partial class WechatMessageContentParser +{ + [GeneratedRegex(@"https?://[^\s<>\]\[\))]+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex UrlRegex(); + + public static Uri? GetUrl(ChatMessageSnapshot message) + { + ArgumentNullException.ThrowIfNull(message); + var match = UrlRegex().Match(message.Text); + return match.Success && Uri.TryCreate(match.Value.TrimEnd('.', ',', '。', ','), UriKind.Absolute, out var uri) ? uri : null; + } + + public static WechatMessageContent GetContent(ChatMessageSnapshot message) + { + ArgumentNullException.ThrowIfNull(message); + return new WechatMessageContent(message.Type, message.Text, GetUrl(message), message.Quote); + } + + public static string ToMarkdown(ChatMessageSnapshot message) + { + ArgumentNullException.ThrowIfNull(message); + var content = GetContent(message); + var body = content.Type switch + { + ChatMessageType.Image => $"![图片]({content.Url?.AbsoluteUri ?? string.Empty})", + ChatMessageType.Video => $"[视频]({content.Url?.AbsoluteUri ?? string.Empty})", + ChatMessageType.File => $"[文件]({content.Url?.AbsoluteUri ?? string.Empty})", + ChatMessageType.Voice => $"> 语音:{content.Text}", + ChatMessageType.Link when content.Url is not null => $"[{LinkTitle(content.Text, content.Url)}]({content.Url.AbsoluteUri})", + _ => content.Text + }; + if (content.Quote is null) return body; + var quote = string.Join(Environment.NewLine, content.Quote.Text.Split('\n').Select(line => $"> {line}")); + return $"{quote}{Environment.NewLine}{Environment.NewLine}{body}"; + } + + public static string MomentFingerprint(string author, string text, string? time) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{author}\n{text}\n{time}")); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static string LinkTitle(string text, Uri url) + { + var title = UrlRegex().Replace(text, string.Empty, 1).Trim(); + return string.IsNullOrWhiteSpace(title) ? url.Host : title; + } +} diff --git a/src/WxAgent.Host/Program.cs b/src/WxAgent.Host/Program.cs index 45ebedd..6dd00c6 100644 --- a/src/WxAgent.Host/Program.cs +++ b/src/WxAgent.Host/Program.cs @@ -62,6 +62,34 @@ try }); return report.Errors.Count == 0 ? 0 : 2; } + case "m5-smoke": + { + var momentsWindow = await WechatChatClient.OpenMomentsAsync(cancellationToken); + await WechatChatClient.RefreshMomentsAsync(cancellationToken); + var moments = await WechatChatClient.GetMomentsAsync(50, cancellationToken: cancellationToken); + await WechatChatClient.CloseMomentsAsync(cancellationToken); + var sample = new ChatMessageSnapshot("文档 https://example.com", "m5-smoke", 0, ChatMessageType.Link); + var confirmationGuarded = false; + try + { + WechatOperationPolicy.RequireConfirmation(null, "m5 smoke write operation"); + } + catch (WxAgentException exception) when (exception.Code == WxAgentErrorCode.InvalidArgument) + { + confirmationGuarded = true; + } + WriteJson(new + { + scope = "M5", + momentsWindow = momentsWindow.Kind, + momentCount = moments.Count, + urlParsed = WechatMessageContentParser.GetUrl(sample)?.Host == "example.com", + markdownRendered = WechatMessageContentParser.ToMarkdown(sample).Contains("https://example.com", StringComparison.Ordinal), + confirmationGuarded, + privacy = "moment authors and content omitted" + }); + return confirmationGuarded ? 0 : 2; + } case "m4-smoke": { var windows = await WechatChatClient.GetSubWindowsAsync(cancellationToken); @@ -121,6 +149,29 @@ try WriteJson(new { found, checkedMessages = history.Count, privacy = "message text omitted" }); return found ? 0 : 2; } + case "chat" when args[1] == "send-url-card": + { + var recipient = GetRequiredOption(args, "--to"); + var url = new Uri(GetRequiredOption(args, "--url"), UriKind.Absolute); + var result = await WechatChatClient.SendUrlCardAsync( + url, + [recipient], + GetOption(args, "--message"), + GetRequiredOption(args, "--confirm"), + cancellationToken); + WriteJson(result); + return result.Success ? 0 : 2; + } + case "chat" when args[1] == "send-audio": + { + var result = await WechatChatClient.SendAudioAsync( + GetRequiredOption(args, "--to"), + GetRequiredOption(args, "--path"), + GetRequiredOption(args, "--confirm"), + cancellationToken); + WriteJson(result); + return result.Success ? 0 : 2; + } case "chat" when args[1] == "send": { var message = await WechatChatClient.SendTextAsync(GetRequiredOption(args, "--text"), cancellationToken); @@ -333,6 +384,12 @@ static int ValidateCommandLine(string[] values) return 30; } + if (values[0] == "m5-smoke") + { + ValidateOptions(values, 1, ["--timeout"], []); + return 60; + } + if (values[0] == "m4-smoke") { ValidateOptions(values, 1, ["--timeout"], []); @@ -355,6 +412,12 @@ static int ValidateCommandLine(string[] values) { switch (values[1]) { + case "send-url-card": + ValidateOptions(values, 2, ["--to", "--url", "--message", "--confirm", "--timeout"], []); + return 60; + case "send-audio": + ValidateOptions(values, 2, ["--to", "--path", "--confirm", "--timeout"], []); + return 60; case "send": ValidateOptions(values, 2, ["--text", "--timeout"], []); return 30; @@ -526,8 +589,11 @@ WxAgent.Host commands: inspect-ui --output [--timeout 30] smoke [--output ] [--timeout 30] m4-smoke [--timeout 60] + m5-smoke [--timeout 60] group at-all --group --message --confirm CONFIRM [--timeout 30] group verify-at-all --group --message [--timeout 30] + chat send-url-card --to --url [--message ] --confirm CONFIRM [--timeout 60] + chat send-audio --to --path --confirm CONFIRM [--timeout 60] chat send --text [--timeout 30] chat reply-latest --text [--timeout 60] chat send-file --path [--timeout 60] diff --git a/src/WxAgent.Windows/WechatChatClient.M5.cs b/src/WxAgent.Windows/WechatChatClient.M5.cs new file mode 100644 index 0000000..e90f138 --- /dev/null +++ b/src/WxAgent.Windows/WechatChatClient.M5.cs @@ -0,0 +1,428 @@ +using System.Diagnostics; +using System.Media; +using System.Windows.Forms; +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; +using FlaUI.Core.Input; +using FlaUI.Core.WindowsAPI; +using FlaUI.UIA3; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public static partial class WechatChatClient +{ + public static Task OpenMomentsAsync(CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, automation) => + { + var window = await OpenMomentsWindowAsync(main, automation, cancellationToken).ConfigureAwait(false); + return new WechatSubWindowSnapshot(SafeName(window), "moments", window.Properties.ProcessId.ValueOrDefault); + }, cancellationToken); + + public static Task CloseMomentsAsync(CancellationToken cancellationToken = default) => + WithMainWindowAsync((main, automation) => + { + if (FindByAutomationId(main, "sns_list") is not null) + { + ClickNamed(main, "微信", ControlType.Button); + return Task.FromResult(true); + } + var processIds = Process.GetProcessesByName("Weixin").Select(process => process.Id).ToHashSet(); + var window = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window)) + .FirstOrDefault(candidate => processIds.Contains(candidate.Properties.ProcessId.ValueOrDefault) + && SafeName(candidate).Contains("朋友圈", StringComparison.Ordinal)); + window?.AsWindow().Close(); + return Task.FromResult(true); + }, cancellationToken); + + public static Task> GetMomentsAsync( + int maxCount = 50, + bool nextPage = false, + int speed1 = 3, + int speed2 = 1, + CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, automation) => + { + if (maxCount is < 1 or > 500) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "maxCount must be between 1 and 500."); + if (speed1 is < 1 or > 10 || speed2 is < 1 or > 3) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "speed1 must be 1-10 and speed2 must be 1-3."); + var moments = await OpenMomentsWindowAsync(main, automation, cancellationToken).ConfigureAwait(false); + if (nextPage) + { + MoveToCenter(moments); + Mouse.Scroll(-speed1); + await Task.Delay(150, cancellationToken).ConfigureAwait(false); + Mouse.Scroll(-speed2); + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + } + return (IReadOnlyList)moments.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem)) + .Select(ParseMoment) + .Where(moment => moment is not null) + .Cast() + .DistinctBy(moment => moment.Fingerprint) + .Take(maxCount) + .ToArray(); + }, cancellationToken); + + public static Task RefreshMomentsAsync(CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, automation) => + { + var moments = await OpenMomentsWindowAsync(main, automation, cancellationToken).ConfigureAwait(false); + var list = moments.FindAllDescendants(cf => cf.ByControlType(ControlType.List)) + .FirstOrDefault(element => !element.Properties.IsOffscreen.ValueOrDefault); + if (list is not null) + { + MoveToCenter(list); + Mouse.Scroll(20); + await Task.Delay(300, cancellationToken).ConfigureAwait(false); + } + var refresh = moments.FindFirstDescendant(cf => cf.ByName("刷新")); + if (refresh is not null) refresh.Click(); + else Keyboard.Press(VirtualKeyShort.F5); + return WechatOperationResult.Ok("moments refreshed"); + }, cancellationToken); + + public static Task PublishMomentAsync( + string? text, + IReadOnlyList? imagePaths, + WechatMomentPublishOptions? options, + string confirmation, + CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("publish moment", confirmation, async (main, automation) => + { + var paths = imagePaths?.Select(Path.GetFullPath).ToArray() ?? []; + options ??= new WechatMomentPublishOptions(); + options.Validate(); + if (string.IsNullOrWhiteSpace(text) && paths.Length == 0) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Moment text or at least one image is required."); + if (paths.Length > 9) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "A moment supports at most 9 images."); + if (paths.Any(path => !File.Exists(path))) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "One or more moment image files do not exist."); + + var moments = await OpenMomentsWindowAsync(main, automation, cancellationToken).ConfigureAwait(false); + ClickFirstNamed(moments, ["发表", "相机"], ControlType.Button); + await Task.Delay(300, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(text)) SetFirstEdit(moments, text); + foreach (var path in paths) + { + using (new ClipboardFileLease(path, IsImageFile(path))) + { + Keyboard.TypeSimultaneously([VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_V]); + await Task.Delay(250, cancellationToken).ConfigureAwait(false); + } + } + if (options.Privacy != WechatMomentPrivacy.Public) + { + ClickFirstNamed(moments, ["谁可以看", "公开"]); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + ClickFirstNamed(moments, options.Privacy == WechatMomentPrivacy.TagWhitelist + ? ["部分可见", "白名单"] + : ["不给谁看", "黑名单"]); + await SelectPeopleAsync(moments, options.Tags!, cancellationToken).ConfigureAwait(false); + ClickFirstNamed(moments, ["完成", "确定"], ControlType.Button); + } + ClickFirstNamed(moments, ["发表", "发布"], ControlType.Button); + return WechatOperationResult.Ok("moment published"); + }, cancellationToken); + + public static Task LikeMomentAsync( + string fingerprint, + bool like, + string confirmation, + CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync(like ? "like moment" : "unlike moment", confirmation, async (main, automation) => + { + var moments = await OpenMomentsWindowAsync(main, automation, cancellationToken).ConfigureAwait(false); + var moment = FindMomentElement(moments, fingerprint); + ClickFirstNamed(moment, ["评论", "更多"]); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + ClickNamed(moments, like ? "赞" : "取消"); + return WechatOperationResult.Ok(like ? "moment liked" : "moment unliked"); + }, cancellationToken); + + public static Task CommentMomentAsync( + string fingerprint, + string comment, + string confirmation, + CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("comment moment", confirmation, async (main, automation) => + { + if (string.IsNullOrWhiteSpace(comment)) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "comment is required."); + var moments = await OpenMomentsWindowAsync(main, automation, cancellationToken).ConfigureAwait(false); + var moment = FindMomentElement(moments, fingerprint); + ClickFirstNamed(moment, ["评论", "更多"]); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + ClickNamed(moments, "评论"); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + SetFirstEdit(moments, comment); + Keyboard.Press(VirtualKeyShort.ENTER); + return WechatOperationResult.Ok("moment commented"); + }, cancellationToken); + + public static Task SendUrlCardAsync( + Uri url, + IReadOnlyList recipients, + string? message, + string confirmation, + CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("send URL card", confirmation, async (main, _) => + { + ArgumentNullException.ThrowIfNull(url); + if (url.Scheme is not ("http" or "https")) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Only HTTP and HTTPS URLs are supported."); + WechatOperationPolicy.ValidateNames(recipients, nameof(recipients)); + foreach (var recipient in recipients) + { + cancellationToken.ThrowIfCancellationRequested(); + await OpenNamedSessionAsync(main, recipient, cancellationToken).ConfigureAwait(false); + var before = ReadVisible(main).Select(item => item.Fingerprint).ToHashSet(StringComparer.Ordinal); + var input = FindByAutomationId(main, WechatLocators.ChatInput) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Chat input was not found."); + input.Click(); + Keyboard.TypeSimultaneously([VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_A]); + Keyboard.Press(VirtualKeyShort.BACK); + Keyboard.Type(url.AbsoluteUri); + Keyboard.Press(VirtualKeyShort.ENTER); + var confirmed = false; + for (var attempt = 0; attempt < 30; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + confirmed = ReadVisible(main).Any(item => !before.Contains(item.Fingerprint) + && item.Text.Contains(url.AbsoluteUri, StringComparison.OrdinalIgnoreCase)); + if (confirmed) break; + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + if (!confirmed) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "URL card send was not confirmed; the operation was not retried."); + if (!string.IsNullOrWhiteSpace(message)) + { + input.Click(); + Keyboard.Type(message); + Keyboard.Press(VirtualKeyShort.ENTER); + } + } + return WechatOperationResult.Ok("URL card sent"); + }, cancellationToken); + + public static Task SendAudioAsync( + string session, + string filePath, + string confirmation, + CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("send audio", confirmation, async (main, _) => + { + var fullPath = Path.GetFullPath(filePath); + if (!File.Exists(fullPath)) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Audio file does not exist."); + if (!string.Equals(Path.GetExtension(fullPath), ".wav", StringComparison.OrdinalIgnoreCase)) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The first M5 implementation supports PCM WAV audio only."); + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + var before = ReadVisible(main).Select(item => item.Fingerprint).ToHashSet(StringComparer.Ordinal); + var recordButton = main.FindAllDescendants(cf => cf.ByControlType(ControlType.Button)) + .FirstOrDefault(button => SafeName(button).Contains("发语音", StringComparison.Ordinal)) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The WeChat '发语音' button was not found."); + MoveToCenter(recordButton); + using var player = new SoundPlayer(fullPath); + player.Load(); + Mouse.Down(MouseButton.Left); + try + { + await Task.Delay(150, cancellationToken).ConfigureAwait(false); + player.PlaySync(); + } + finally + { + Mouse.Up(MouseButton.Left); + } + for (var attempt = 0; attempt < 50; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + if (ReadVisible(main).Any(item => !before.Contains(item.Fingerprint) && item.Type == ChatMessageType.Voice)) + return WechatOperationResult.Ok("audio sent as voice"); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "Audio send was not confirmed as a voice message; the operation was not retried."); + }, cancellationToken); + + public static Task TickleVisibleMessageAsync( + string session, + string fingerprint, + string confirmation, + CancellationToken cancellationToken = default) => + WithConfirmedMainWindowAsync("tickle message sender", confirmation, async (main, _) => + { + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + FindVisibleMessageElement(main, fingerprint).RightClick(); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + ClickNamed(main, "拍一拍"); + return WechatOperationResult.Ok("tickle sent"); + }, cancellationToken); + + public static Task GetVisibleMessageUrlAsync( + string session, + string fingerprint, + CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, automation) => + { + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + var message = FindVisibleMessage(main, fingerprint); + var parsed = WechatMessageContentParser.GetUrl(message); + if (parsed is not null) return parsed; + FindVisibleMessageElement(main, fingerprint).RightClick(); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + ClickFirstNamed(main, ["复制链接", "复制"]); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + var clipboard = ReadClipboardText(); + return Uri.TryCreate(clipboard, UriKind.Absolute, out var uri) ? uri : null; + }, cancellationToken); + + public static Task GetVisibleMessageContentAsync( + string session, + string fingerprint, + CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, automation) => + { + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + return WechatMessageContentParser.GetContent(FindVisibleMessage(main, fingerprint)); + }, cancellationToken); + + public static Task VisibleMessageToMarkdownAsync( + string session, + string fingerprint, + CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, automation) => + { + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + return WechatMessageContentParser.ToMarkdown(FindVisibleMessage(main, fingerprint)); + }, cancellationToken); + + public static Task GetVisibleNoteContentAsync( + string session, + string fingerprint, + CancellationToken cancellationToken = default) => + WithMainWindowAsync(async (main, automation) => + { + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + var message = FindVisibleMessage(main, fingerprint); + if (message.Type != ChatMessageType.Note) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The selected message is not a note."); + FindVisibleMessageElement(main, fingerprint).DoubleClick(); + await Task.Delay(400, cancellationToken).ConfigureAwait(false); + var processIds = Process.GetProcessesByName("Weixin").Select(process => process.Id).ToHashSet(); + var noteWindow = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window)) + .FirstOrDefault(window => processIds.Contains(window.Properties.ProcessId.ValueOrDefault) + && !window.Properties.IsOffscreen.ValueOrDefault + && SafeName(window).Contains("笔记", StringComparison.Ordinal)); + var root = noteWindow ?? main; + var parts = root.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) + .Select(SafeName) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .Distinct(StringComparer.Ordinal) + .Select(text => new WechatNoteContentPart("text", text)) + .ToArray(); + return new WechatNoteContent(parts); + }, cancellationToken); + + public static async Task VisibleNoteToMarkdownFileAsync( + string session, + string fingerprint, + string destinationPath, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(destinationPath)) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "destinationPath is required."); + var content = await GetVisibleNoteContentAsync(session, fingerprint, cancellationToken).ConfigureAwait(false); + var fullPath = Path.GetFullPath(destinationPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + await File.WriteAllTextAsync(fullPath, content.ToMarkdown(), cancellationToken).ConfigureAwait(false); + return fullPath; + } + + private static async Task OpenMomentsWindowAsync(AutomationElement main, UIA3Automation automation, CancellationToken cancellationToken) + { + var processIds = Process.GetProcessesByName("Weixin").Select(process => process.Id).ToHashSet(); + AutomationElement? FindWindow() => automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window)) + .FirstOrDefault(window => processIds.Contains(window.Properties.ProcessId.ValueOrDefault) + && !window.Properties.IsOffscreen.ValueOrDefault + && SafeName(window).Contains("朋友圈", StringComparison.Ordinal)); + + if (FindByAutomationId(main, "sns_list") is not null) return main; + var existing = FindWindow(); + if (existing is not null) return existing; + ClickNamed(main, "发现", ControlType.Button); + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + ClickFirstNamed(main, ["朋友圈"]); + for (var attempt = 0; attempt < 30; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + if (FindByAutomationId(main, "sns_list") is not null) return main; + var window = FindWindow(); + if (window is not null) return window; + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Moments window was not found. Confirm Moments is enabled on the mobile account."); + } + + private static WechatMomentSnapshot? ParseMoment(AutomationElement element) + { + var values = element.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) + .Select(SafeName) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (values.Length < 2) + { + var itemText = SafeName(element).Trim(); + if (string.IsNullOrWhiteSpace(itemText) || itemText is "评论区" || itemText.StartsWith("余下", StringComparison.Ordinal)) return null; + values = itemText.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (values.Length == 1) values = [values[0].Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty, values[0]]; + } + var author = values[0].Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? values[0]; + var text = string.Join(Environment.NewLine, values.Skip(1)); + var time = values.Length > 2 ? values[^1] : null; + return new WechatMomentSnapshot( + author, + text, + WechatMessageContentParser.MomentFingerprint(author, text, time), + time, + element.FindAllDescendants(cf => cf.ByControlType(ControlType.Image)).Length, + values.Contains("取消", StringComparer.Ordinal)); + } + + private static AutomationElement FindMomentElement(AutomationElement main, string fingerprint) + { + foreach (var element in main.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem))) + { + if (ParseMoment(element)?.Fingerprint == fingerprint) return element; + } + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The visible moment fingerprint was not found."); + } + + private static bool IsImageFile(string path) => + Path.GetExtension(path).ToLowerInvariant() is ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".webp"; + + private static ChatMessageSnapshot FindVisibleMessage(AutomationElement main, string fingerprint) => + ReadVisible(main).FirstOrDefault(message => message.Fingerprint == fingerprint) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The visible message fingerprint was not found."); + + private static string ReadClipboardText() + { + string? result = null; + Exception? error = null; + var thread = new Thread(() => + { + try { result = Clipboard.ContainsText() ? Clipboard.GetText() : null; } + catch (Exception exception) { error = exception; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + if (!thread.Join(TimeSpan.FromSeconds(3))) + throw new WxAgentException(WxAgentErrorCode.ClipboardUnavailable, "Timed out while reading the clipboard."); + if (error is not null) + throw new WxAgentException(WxAgentErrorCode.ClipboardUnavailable, "Could not read the clipboard.", error); + return result ?? string.Empty; + } +} diff --git a/tests/WxAgent.Core.Tests/WechatM5Tests.cs b/tests/WxAgent.Core.Tests/WechatM5Tests.cs new file mode 100644 index 0000000..b8e2f91 --- /dev/null +++ b/tests/WxAgent.Core.Tests/WechatM5Tests.cs @@ -0,0 +1,76 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatM5Tests +{ + [Fact] + public void GetUrl_ExtractsFirstHttpUrlAndTrailingPunctuation() + { + var message = Message("文档:https://example.com/a?q=1。", ChatMessageType.Link); + Assert.Equal("https://example.com/a?q=1", WechatMessageContentParser.GetUrl(message)?.AbsoluteUri); + } + + [Fact] + public void ToMarkdown_FormatsLinkAndQuote() + { + var message = Message( + "项目主页 https://example.com", + ChatMessageType.Link, + new QuotedMessageSnapshot("甲", "原消息")); + + Assert.Equal("> 原消息\n\n[项目主页](https://example.com/)", WechatMessageContentParser.ToMarkdown(message).Replace("\r\n", "\n")); + } + + [Fact] + public void GetContent_PreservesTypedMessageData() + { + var message = Message("[图片]", ChatMessageType.Image); + var content = WechatMessageContentParser.GetContent(message); + Assert.Equal(ChatMessageType.Image, content.Type); + Assert.Equal("[图片]", content.Text); + Assert.Null(content.Url); + } + + [Fact] + public void MomentFingerprint_IsStableAndSensitiveToContent() + { + var first = WechatMessageContentParser.MomentFingerprint("甲", "内容", "昨天"); + Assert.Equal(first, WechatMessageContentParser.MomentFingerprint("甲", "内容", "昨天")); + Assert.NotEqual(first, WechatMessageContentParser.MomentFingerprint("甲", "其他", "昨天")); + } + + [Theory] + [InlineData("[位置]", ChatMessageType.Location)] + [InlineData("[动画表情]", ChatMessageType.Emotion)] + [InlineData("三条聊天记录", ChatMessageType.Merge)] + [InlineData("[名片]", ChatMessageType.PersonalCard)] + [InlineData("[笔记]", ChatMessageType.Note)] + public void Parser_ClassifiesM5SpecialMessages(string text, ChatMessageType expected) + { + var parsed = VisibleMessageParser.Parse([new ChatMessageSource(text, "id")]); + Assert.Equal(expected, Assert.Single(parsed).Type); + } + + [Fact] + public void NoteContent_ToMarkdown_PreservesOrderAndFiles() + { + var content = new WechatNoteContent([ + new WechatNoteContentPart("text", "标题"), + new WechatNoteContentPart("file", "/tmp/a.png") + ]); + Assert.Equal("标题\n\n[a.png](/tmp/a.png)", content.ToMarkdown().Replace("\r\n", "\n")); + } + + [Fact] + public void MomentPrivacy_RequiresTagsForRestrictedVisibility() + { + new WechatMomentPublishOptions().Validate(); + Assert.Throws(() => new WechatMomentPublishOptions(WechatMomentPrivacy.TagWhitelist).Validate()); + new WechatMomentPublishOptions(WechatMomentPrivacy.TagBlacklist, ["测试标签"]).Validate(); + } + + private static ChatMessageSnapshot Message(string text, ChatMessageType type, QuotedMessageSnapshot? quote = null) => + new(text, "fingerprint", 0, type, quote); +}