feat: implement M5 moments and rich messages

This commit is contained in:
2026-09-04 23:48:28 +08:00
parent 77e1e9dc2b
commit 1bd0344e8b
7 changed files with 806 additions and 0 deletions
+54
View File
@@ -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 验收项
- CoreURL 提取、内容模型、Markdown、朋友圈指纹、特殊消息分类、发布参数校验。
- LinuxCore 测试、全量 Release 构建、Windows x64 self-contained 发布。
- Windows`doctor``inspect-ui``smoke``m5-smoke`
- 真机写操作只允许文件传输助手或专用测试对象;朋友圈发布、点赞、评论不得在真实联系人内容上试验。
## 官方行为基线
- <https://docs.wxauto.org/docs/class/WeChat.html>
- <https://docs.wxauto.org/docs/class/Chat.html>
- <https://docs.wxauto.org/docs/class/Message.html>
- <https://docs.wxauto.org/docs/class/Moment.html>
- <https://docs.wxauto.org/docs/cli/automation.html>
- <https://docs.wxauto.org/update/wxautox4.html>
@@ -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,不复制官方默认自动重试。
- 朋友圈发布公开文档没有稳定返回合同;本项目不因无异常而盲目重试。
+12
View File
@@ -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)
{
+97
View File
@@ -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<WechatNoteContentPart> 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<string>? 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;
}
}
+66
View File
@@ -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 <path> [--timeout 30]
smoke [--output <path>] [--timeout 30]
m4-smoke [--timeout 60]
m5-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-url-card --to <name> --url <http-url> [--message <text>] --confirm CONFIRM [--timeout 60]
chat send-audio --to <name> --path <audio-file> --confirm CONFIRM [--timeout 60]
chat send --text <one-line-text> [--timeout 30]
chat reply-latest --text <one-line-text> [--timeout 60]
chat send-file --path <file> [--timeout 60]
+428
View File
@@ -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<WechatSubWindowSnapshot> 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<IReadOnlyList<WechatMomentSnapshot>> 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<WechatMomentSnapshot>)moments.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem))
.Select(ParseMoment)
.Where(moment => moment is not null)
.Cast<WechatMomentSnapshot>()
.DistinctBy(moment => moment.Fingerprint)
.Take(maxCount)
.ToArray();
}, cancellationToken);
public static Task<WechatOperationResult> 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<WechatOperationResult> PublishMomentAsync(
string? text,
IReadOnlyList<string>? 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<WechatOperationResult> 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<WechatOperationResult> 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<WechatOperationResult> SendUrlCardAsync(
Uri url,
IReadOnlyList<string> 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<WechatOperationResult> 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<WechatOperationResult> 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<Uri?> 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<WechatMessageContent> 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<string> 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<WechatNoteContent> 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<string> 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<AutomationElement> 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;
}
}
+76
View File
@@ -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<WxAgentException>(() => 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);
}