feat: implement M5 moments and rich messages
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 => $"",
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user