527 lines
30 KiB
C#
527 lines
30 KiB
C#
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);
|
|
var composer = RequireManagementDialog(moments);
|
|
var input = WechatOperationPolicy.RequireUnique(composer.FindAllDescendants(cf =>
|
|
cf.ByControlType(ControlType.Edit)).Where(IsActionable), "moment composer input").AsTextBox();
|
|
if (!string.IsNullOrEmpty(input.Text))
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The moment composer already contains a draft; it was not overwritten.");
|
|
input.Text = text ?? string.Empty;
|
|
input.Focus();
|
|
foreach (var path in paths)
|
|
{
|
|
using (new ClipboardLease(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);
|
|
ClickDialogAction(moments, "完成", "确定");
|
|
}
|
|
ClickDialogAction(moments, "发表", "发布");
|
|
return await ConfirmUiSubmissionAsync(moments, "Moment publication", cancellationToken).ConfigureAwait(false);
|
|
}, 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 await ConfirmUiSubmissionAsync(moments, like ? "Moment like" : "Moment unlike", cancellationToken).ConfigureAwait(false);
|
|
}, 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);
|
|
var input = WechatOperationPolicy.RequireUnique(FindMomentElement(moments, fingerprint)
|
|
.FindAllDescendants(cf => cf.ByControlType(ControlType.Edit)).Where(IsActionable), "target moment comment input").AsTextBox();
|
|
if (!string.IsNullOrEmpty(input.Text))
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The comment input already contains a draft; it was not overwritten.");
|
|
input.Text = comment;
|
|
input.Focus();
|
|
Keyboard.Press(VirtualKeyShort.ENTER);
|
|
return await ConfirmUiSubmissionAsync(moments, "Moment comment", cancellationToken).ConfigureAwait(false);
|
|
}, cancellationToken);
|
|
|
|
public static async Task<WechatOperationResult> SendUrlCardAsync(
|
|
Uri url,
|
|
IReadOnlyList<string> recipients,
|
|
string? message,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
ArgumentNullException.ThrowIfNull(url);
|
|
if (!url.IsAbsoluteUri || url.Scheme is not ("http" or "https"))
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Only absolute HTTP and HTTPS URLs are supported.");
|
|
WechatOperationPolicy.ValidateNames(recipients, nameof(recipients));
|
|
if (message is not null && message.Length > 512)
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "URL-card message must be 512 characters or fewer.");
|
|
|
|
// WeChat exposes native link-card sharing through the embedded browser's Forward action.
|
|
// The URL is staged in File Transfer Assistant because WeChat exposes native link-card sharing
|
|
// through the embedded browser's Forward action. No confirmation token is required for this operation.
|
|
return await WithMainWindowAsync(async (main, _) =>
|
|
{
|
|
await OpenNamedSessionAsync(main, WechatLocators.FileTransferAssistant, cancellationToken).ConfigureAwait(false);
|
|
var before = ReadVisible(main).Select(item => item.Fingerprint).ToHashSet(StringComparer.Ordinal);
|
|
var staged = ReadVisible(main).LastOrDefault(item =>
|
|
item.Text.Contains(url.AbsoluteUri, StringComparison.OrdinalIgnoreCase) ||
|
|
WechatMessageContentParser.GetUrl(item)?.AbsoluteUri == url.AbsoluteUri);
|
|
if (staged is null)
|
|
{
|
|
var input = main.FindAllDescendants(cf => cf.ByAutomationId(WechatLocators.ChatInput))
|
|
.Where(IsActionable).ToArray();
|
|
var inputBox = WechatOperationPolicy.RequireUnique(input, "visible staging chat input").AsTextBox();
|
|
if (!string.IsNullOrEmpty(inputBox.Text))
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The staging conversation contains a draft; it was not overwritten.");
|
|
ExecuteInputStep("set-url-card-staging-url", () => inputBox.Text = url.AbsoluteUri);
|
|
var send = WechatOperationPolicy.RequireUnique(main.FindAllDescendants().Where(element =>
|
|
IsActionable(element) && SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send), "send button");
|
|
ExecuteInputStep("send-url-card-staging-url", () => ClickCenter(send));
|
|
staged = await WaitForStagedUrlAsync(main, url, before, cancellationToken).ConfigureAwait(false)
|
|
?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The staging URL was not sent; no card operation was attempted.");
|
|
}
|
|
|
|
var stagedElement = FindVisibleMessageElement(main, staged.Fingerprint);
|
|
// WeChat versions differ: some expose Forward on the URL message, others expose it
|
|
// from the embedded browser toolbar. Prefer the directly observed message command.
|
|
stagedElement.RightClick();
|
|
await Task.Delay(150, cancellationToken).ConfigureAwait(false);
|
|
var forwarded = TryClickMenuItem(main, ["转发…", "转发...", "转发"]);
|
|
if (!forwarded)
|
|
{
|
|
stagedElement.DoubleClick();
|
|
var browser = await WaitForBrowserDocumentAsync(main, url, cancellationToken).ConfigureAwait(false);
|
|
var more = WechatOperationPolicy.RequireUnique(main.FindAllDescendants(cf =>
|
|
cf.ByName("更多").And(cf.ByControlType(ControlType.Button))).Where(element =>
|
|
IsActionable(element) && element.BoundingRectangle.Left >= browser.BoundingRectangle.Left), "browser more button");
|
|
ClickCenter(more);
|
|
await Task.Delay(250, cancellationToken).ConfigureAwait(false);
|
|
forwarded = TryClickMenuItem(main, ["转发…", "转发...", "转发"]);
|
|
}
|
|
if (!forwarded)
|
|
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat did not expose a URL forward command.");
|
|
await SelectPeopleAsync(main, recipients, cancellationToken).ConfigureAwait(false);
|
|
if (!string.IsNullOrWhiteSpace(message)) SetForwardMessage(main, message);
|
|
ClickDialogAction(main, "发送", "确定");
|
|
return await ConfirmUiSubmissionAsync(main, "URL-card forwarding", cancellationToken).ConfigureAwait(false);
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task<ChatMessageSnapshot?> WaitForStagedUrlAsync(AutomationElement main, Uri url,
|
|
IReadOnlySet<string> baseline, CancellationToken cancellationToken)
|
|
{
|
|
var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(20);
|
|
while (DateTimeOffset.UtcNow < deadline)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var message = ReadVisible(main).LastOrDefault(item => !baseline.Contains(item.Fingerprint) &&
|
|
(item.Text.Contains(url.AbsoluteUri, StringComparison.OrdinalIgnoreCase) ||
|
|
WechatMessageContentParser.GetUrl(item)?.AbsoluteUri == url.AbsoluteUri));
|
|
if (message is not null) return message;
|
|
await Task.Delay(150, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static async Task<AutomationElement> WaitForBrowserDocumentAsync(AutomationElement main, Uri url, CancellationToken cancellationToken)
|
|
{
|
|
var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(15);
|
|
while (DateTimeOffset.UtcNow < deadline)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var document = main.FindAllDescendants(cf => cf.ByControlType(ControlType.Document))
|
|
.Where(IsActionable).FirstOrDefault(candidate =>
|
|
SafeName(candidate).Contains(url.Host, StringComparison.OrdinalIgnoreCase)
|
|
|| SafeName(candidate).Contains("Example Domain", StringComparison.Ordinal));
|
|
if (document is not null) return document;
|
|
await Task.Delay(250, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The URL did not open in WeChat's embedded browser.");
|
|
}
|
|
|
|
private static bool TryClickMenuItem(AutomationElement root, IReadOnlyList<string> names)
|
|
{
|
|
var accepted = names.ToHashSet(StringComparer.Ordinal);
|
|
var candidates = root.FindAllDescendants().Where(element =>
|
|
accepted.Contains(SafeName(element)) &&
|
|
SafeControlType(element) is ControlType.MenuItem or ControlType.ListItem or ControlType.Button).ToArray();
|
|
if (candidates.Length != 1) return false;
|
|
ClickCenter(candidates[0]);
|
|
return true;
|
|
}
|
|
|
|
private static void SetForwardMessage(AutomationElement root, string message)
|
|
{
|
|
var dialog = RequireManagementDialog(root);
|
|
var candidates = dialog.FindAllDescendants(cf => cf.ByAutomationId("leave_message_view.chat_input_field"))
|
|
.Where(candidate => !candidate.Properties.IsOffscreen.ValueOrDefault).ToArray();
|
|
if (candidates.Length != 1)
|
|
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The URL-card forwarding message input was not uniquely exposed.");
|
|
var input = candidates[0].AsTextBox();
|
|
if (!string.IsNullOrEmpty(input.Text))
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The forwarding message input contains a draft; it was not overwritten.");
|
|
input.Text = message;
|
|
}
|
|
|
|
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 await ConfirmUiSubmissionAsync(main, "Tickle", cancellationToken).ConfigureAwait(false);
|
|
}, 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.");
|
|
var processId = main.Properties.ProcessId.ValueOrDefault;
|
|
var previousHandles = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window))
|
|
.Select(window => window.Properties.NativeWindowHandle.ValueOrDefault.ToInt64()).ToHashSet();
|
|
FindVisibleMessageElement(main, fingerprint).DoubleClick();
|
|
var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5);
|
|
while (DateTimeOffset.UtcNow < deadline)
|
|
{
|
|
await Task.Delay(150, cancellationToken).ConfigureAwait(false);
|
|
var windows = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window))
|
|
.Where(window => window.Properties.ProcessId.ValueOrDefault == processId).ToArray();
|
|
var handle = WechatNoteWindow.FindOpened(windows.Select(window => new WechatNoteWindowCandidate(
|
|
window.Properties.NativeWindowHandle.ValueOrDefault.ToInt64(), window.Properties.ProcessId.ValueOrDefault,
|
|
SafeName(window), !window.Properties.IsOffscreen.ValueOrDefault,
|
|
FindByAutomationId(window, WechatLocators.MessageList) is not null)), processId, previousHandles);
|
|
if (handle is null) continue;
|
|
var noteWindow = windows.Single(window => window.Properties.NativeWindowHandle.ValueOrDefault.ToInt64() == handle);
|
|
var parts = noteWindow.FindAllDescendants(cf => cf.ByControlType(ControlType.Text))
|
|
.Select(SafeName)
|
|
.Where(text => !string.IsNullOrWhiteSpace(text))
|
|
.Select(text => new WechatNoteContentPart("text", text))
|
|
.ToArray();
|
|
if (parts.Length == 0) continue;
|
|
return new WechatNoteContent(parts);
|
|
}
|
|
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed,
|
|
"A unique new note window with readable content was not verified. The main chat and existing windows were not exported.");
|
|
}, 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;
|
|
}
|
|
}
|