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); 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 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 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 SendUrlCardAsync( Uri url, IReadOnlyList 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 WaitForStagedUrlAsync(AutomationElement main, Uri url, IReadOnlySet 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 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 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 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 await ConfirmUiSubmissionAsync(main, "Tickle", cancellationToken).ConfigureAwait(false); }, 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."); 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 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; } }