using System.Collections.Specialized; using System.Runtime.CompilerServices; using System.Threading.Channels; 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 class WechatChatClient { private static readonly SemaphoreSlim CommandQueue = new(1, 1); public static Task> ListVisibleSessionsAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); using var automation = new UIA3Automation(); var window = AttachWindow(automation); var list = FindByAutomationId(window, WechatLocators.SessionList) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.SessionList} was not found."); var current = SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)); var sessions = WechatSessionParser.Parse( list.FindAllDescendants() .Where(element => SafeControlType(element) == ControlType.ListItem) .Select(element => (SafeAutomationId(element), SafeName(element))), current); return Task.FromResult>(sessions); } public static async Task> SearchSessionsAsync( string query, bool exactOnly, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(query); await CommandQueue.WaitAsync(cancellationToken); try { using var automation = new UIA3Automation(); var window = AttachWindow(automation); var search = window.FindAllDescendants().FirstOrDefault(element => SafeControlType(element) == ControlType.Edit && SafeName(element) == WechatLocators.Search) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat search box was not found."); var box = search.AsTextBox(); var previous = box.Text; try { box.Text = query; if (!string.Equals(box.Text, query, StringComparison.Ordinal)) { throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The search box did not contain the exact query."); } await Task.Delay(500, cancellationToken); var results = WechatSessionParser.ParseSearchResults( window.FindAllDescendants() .Where(element => SafeControlType(element) == ControlType.ListItem) .Select(element => (SafeAutomationId(element), SafeName(element))), query); return exactOnly ? results.Where(result => result.IsExactMatch).ToArray() : results; } finally { box.Text = previous; } } finally { CommandQueue.Release(); } } public static Task GetCurrentSessionAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); using var automation = new UIA3Automation(); var window = AttachWindow(automation); var element = FindByAutomationId(window, WechatLocators.CurrentChatName); var name = SafeName(element); return Task.FromResult(string.IsNullOrWhiteSpace(name) ? null : new WechatSessionSnapshot(name, SafeAutomationId(element!), true)); } public static async Task OpenSessionAsync(string name, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(name); await CommandQueue.WaitAsync(cancellationToken); try { using var automation = new UIA3Automation(); var window = AttachWindow(automation); if (SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)) == name) { return new WechatSessionSnapshot(name, SafeAutomationId(FindByAutomationId(window, WechatLocators.CurrentChatName)!), true); } var target = FindByAutomationId(window, "session_item_" + name); if (target is null) { var search = window.FindAllDescendants().FirstOrDefault(element => SafeControlType(element) == ControlType.Edit && SafeName(element) == WechatLocators.Search) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat search box was not found."); search.AsTextBox().Text = name; var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5); while (DateTimeOffset.UtcNow < deadline && target is null) { cancellationToken.ThrowIfCancellationRequested(); target = window.FindAllDescendants().FirstOrDefault(element => SafeAutomationId(element).StartsWith("search_item_", StringComparison.Ordinal) && SessionNameMatches(SafeName(element), name)); if (target is null) await Task.Delay(150, cancellationToken); } } if (target is null) { throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The requested session was not found."); } ClickCenter(target); if (!await WaitForSessionAsync(window, name, TimeSpan.FromSeconds(10), cancellationToken)) { throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested session did not become active."); } return new WechatSessionSnapshot(name, SafeAutomationId(target), true); } finally { CommandQueue.Release(); } } public static async Task SendTextAsync(string text, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(text); if (text.Length > 4000 || text.Contains('\r') || text.Contains('\n')) { throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "M1 text messages must be one line and no longer than 4000 characters."); } await CommandQueue.WaitAsync(cancellationToken); try { using var automation = new UIA3Automation(); var window = AttachWindow(automation); await OpenFileTransferAssistantCoreAsync(window, cancellationToken); await Task.Delay(500, cancellationToken); var input = FindByAutomationId(window, WechatLocators.ChatInput) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found."); var send = window.FindAllDescendants().FirstOrDefault(element => SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found."); var inputBox = input.AsTextBox(); ExecuteInputStep("set-input-value", () => inputBox.Text = text); await Task.Delay(150, cancellationToken); if (!string.Equals(inputBox.Text, text, StringComparison.Ordinal)) { throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The chat input did not contain the exact requested text; send was aborted."); } ExecuteInputStep("click-send", () => ClickCenter(send)); var confirmed = await WaitForMessageAsync(text, TimeSpan.FromSeconds(20), cancellationToken); return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The text was entered but no matching visible message confirmed the send result."); } finally { CommandQueue.Release(); } } public static async Task ReplyToLatestAsync(string text, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(text); if (text.Length > 4000 || text.Contains('\r') || text.Contains('\n')) { throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Reply text must be one line and no longer than 4000 characters."); } await CommandQueue.WaitAsync(cancellationToken); try { using var automation = new UIA3Automation(); var window = AttachWindow(automation); await OpenFileTransferAssistantCoreAsync(window, cancellationToken); var before = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal); var list = FindByAutomationId(window, WechatLocators.MessageList) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found."); var latest = list.FindAllDescendants() .LastOrDefault(element => SafeAutomationId(element) == WechatLocators.ChatBubbleItem) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "No visible message is available to quote."); RightClickMessageBubble(latest); AutomationElement? quote = null; var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(30); while (DateTimeOffset.UtcNow < deadline && quote is null) { cancellationToken.ThrowIfCancellationRequested(); quote = automation.GetDesktop().FindAllDescendants().FirstOrDefault(element => SessionNameMatches(SafeName(element), "引用") && element.BoundingRectangle.Width > 0); if (quote is null) await Task.Delay(100, cancellationToken); } if (quote is null) { throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The quote context-menu item was not found."); } ClickCenter(quote); await Task.Delay(300, cancellationToken); window = WechatDoctor.FindWechatWindow(automation) ?? window; var input = FindByAutomationId(window, WechatLocators.ChatInput) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found."); input.AsTextBox().Text = text; if (!string.Equals(input.AsTextBox().Text, text, StringComparison.Ordinal)) { throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The reply input did not contain the exact requested text; send was aborted."); } var send = window.FindAllDescendants().FirstOrDefault(element => SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found."); ClickCenter(send); var confirmed = await WaitForNewMessageAsync(before, TimeSpan.FromSeconds(30), cancellationToken); return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "No new visible quoted reply confirmed the send result."); } finally { CommandQueue.Release(); } } public static Task SendFileAsync(string path, CancellationToken cancellationToken) => SendAttachmentAsync(path, asImage: false, cancellationToken); public static Task SendImageAsync(string path, CancellationToken cancellationToken) => SendAttachmentAsync(path, asImage: true, cancellationToken); private static async Task SendAttachmentAsync(string path, bool asImage, CancellationToken cancellationToken) { var fullPath = Path.GetFullPath(path); if (!File.Exists(fullPath)) { throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The attachment file does not exist."); } await CommandQueue.WaitAsync(cancellationToken); try { using var automation = new UIA3Automation(); var window = AttachWindow(automation); await OpenFileTransferAssistantCoreAsync(window, cancellationToken); var before = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal); var input = FindByAutomationId(window, WechatLocators.ChatInput) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found."); var inputText = input.AsTextBox().Text?.Trim(); var placeholder = SafeName(input).Trim(); if (!string.IsNullOrEmpty(inputText) && !string.Equals(inputText, placeholder, StringComparison.Ordinal)) { throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, $"The chat input contains an unsent draft ({inputText.Length} characters); attachment send was aborted."); } ClickCenter(input); await Task.Delay(150, cancellationToken); using (new ClipboardFileLease(fullPath, asImage)) { Keyboard.TypeSimultaneously([VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_V]); await Task.Delay(500, cancellationToken); } var refreshedWindow = WechatDoctor.FindWechatWindow(automation) ?? throw new WxAgentException(WxAgentErrorCode.WindowNotFound, "WeChat main window disappeared after attachment paste."); var send = refreshedWindow.FindAllDescendants().FirstOrDefault(element => SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found after attachment paste."); ClickCenter(send); var confirmed = await WaitForNewMessageAsync(before, TimeSpan.FromSeconds(30), cancellationToken); return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The attachment selection completed but no new visible message confirmed the send result."); } finally { CommandQueue.Release(); } } public static async Task> ReadHistoryAsync( int maxMessages, int maxScrolls, CancellationToken cancellationToken) { if (maxMessages is < 1 or > 1000 || maxScrolls is < 0 or > 100) { throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "History limits are maxMessages 1-1000 and maxScrolls 0-100."); } await CommandQueue.WaitAsync(cancellationToken); try { using var automation = new UIA3Automation(); var window = AttachWindow(automation); await OpenFileTransferAssistantCoreAsync(window, cancellationToken); var messages = new List(); var deduper = new BoundedMessageDeduper(Math.Max(2048, maxMessages * 4)); var unchanged = 0; var scrolled = 0; for (var page = 0; page <= maxScrolls; page++) { cancellationToken.ThrowIfCancellationRequested(); window = WechatDoctor.FindWechatWindow(automation) ?? window; var visible = ReadVisible(window); var older = visible.Where(message => deduper.TryAdd(message.Fingerprint)).ToArray(); if (older.Length == 0) unchanged++; else unchanged = 0; if (page == 0) messages.AddRange(older); else messages.InsertRange(0, older); if (messages.Count >= maxMessages || unchanged >= 2 || page == maxScrolls) break; var list = FindByAutomationId(window, WechatLocators.MessageList) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found."); MoveToCenter(list); Mouse.Scroll(5); scrolled++; await Task.Delay(350, cancellationToken); } if (scrolled > 0) { Mouse.Scroll(-5d * scrolled); } return messages.Count <= maxMessages ? messages : messages.TakeLast(maxMessages).ToArray(); } finally { CommandQueue.Release(); } } public static async Task> ReadVisibleAsync(CancellationToken cancellationToken) { await CommandQueue.WaitAsync(cancellationToken); try { using var automation = new UIA3Automation(); var window = AttachWindow(automation); await OpenFileTransferAssistantCoreAsync(window, cancellationToken); return ReadVisible(window); } finally { CommandQueue.Release(); } } public static async IAsyncEnumerable ListenAsync( TimeSpan duration, [EnumeratorCancellation] CancellationToken cancellationToken) { if (duration <= TimeSpan.Zero) { throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Listen duration must be positive."); } using var automation = new UIA3Automation(); AutomationElement window; await CommandQueue.WaitAsync(cancellationToken); try { window = AttachWindow(automation); await OpenFileTransferAssistantCoreAsync(window, cancellationToken); } finally { CommandQueue.Release(); } var list = FindByAutomationId(window, WechatLocators.MessageList) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found."); var signals = Channel.CreateBounded(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite }); using var subscription = list.RegisterStructureChangedEvent( TreeScope.Subtree, (_, _, _) => signals.Writer.TryWrite(true)); var deduper = new BoundedMessageDeduper(); foreach (var message in ReadVisible(window)) { deduper.TryAdd(message.Fingerprint); } var deadline = DateTimeOffset.UtcNow + duration; while (DateTimeOffset.UtcNow < deadline) { cancellationToken.ThrowIfCancellationRequested(); var remaining = deadline - DateTimeOffset.UtcNow; var delay = Task.Delay(remaining < TimeSpan.FromSeconds(2) ? remaining : TimeSpan.FromSeconds(2), cancellationToken); var eventReady = signals.Reader.WaitToReadAsync(cancellationToken).AsTask(); await Task.WhenAny(delay, eventReady); while (signals.Reader.TryRead(out _)) { } foreach (var message in ReadVisible(window)) { if (deduper.TryAdd(message.Fingerprint)) { yield return message; } } } } public static async IAsyncEnumerable ListenEventsAsync( TimeSpan duration, string? checkpointPath, [EnumeratorCancellation] CancellationToken cancellationToken, IReadOnlyList>? callbacks = null) { if (duration <= TimeSpan.Zero) { throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Listen duration must be positive."); } const string session = WechatLocators.FileTransferAssistant; var checkpoint = await ListenerCheckpointStore.LoadAsync(checkpointPath, cancellationToken); if (checkpoint is not null && !string.Equals(checkpoint.Session, session, StringComparison.Ordinal)) { checkpoint = null; } var state = new MessageEventState(checkpoint: checkpoint); var recoveredScan = checkpoint is not null; var signals = Channel.CreateBounded(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite }); using var automation = new UIA3Automation(); AutomationElement? window = null; IDisposable? subscription = null; var connected = false; var announceReconnect = recoveredScan; var elapsed = System.Diagnostics.Stopwatch.StartNew(); try { while (elapsed.Elapsed < duration) { cancellationToken.ThrowIfCancellationRequested(); IReadOnlyList? visible = null; try { window = WechatDoctor.FindWechatWindow(automation); if (window is not null) { if (!connected) { await CommandQueue.WaitAsync(cancellationToken); try { await OpenFileTransferAssistantCoreAsync(window, cancellationToken); } finally { CommandQueue.Release(); } subscription?.Dispose(); var list = FindByAutomationId(window, WechatLocators.MessageList); subscription = list?.RegisterStructureChangedEvent( TreeScope.Subtree, (_, _, _) => signals.Writer.TryWrite(true)); connected = true; } visible = ReadVisible(window); } else { connected = false; } } catch (OperationCanceledException) { throw; } catch { connected = false; subscription?.Dispose(); subscription = null; } if (connected && announceReconnect) { announceReconnect = false; var reconnectEvent = new MessageEvent( "reconnected-" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), MessageEventKind.Reconnected, session, null, DateTimeOffset.UtcNow, Recovered: true); if (callbacks is { Count: > 0 }) { await MessageCallbackDispatcher.DispatchAsync(callbacks, reconnectEvent, cancellationToken); } yield return reconnectEvent; } var changed = false; if (visible is not null) { if (checkpoint is null && !recoveredScan) { foreach (var message in visible) { state.TryCreateMessage(session, message, DateTimeOffset.UtcNow, recovered: false); } changed = true; recoveredScan = false; } else { foreach (var message in visible) { var messageEvent = state.TryCreateMessage(session, message, DateTimeOffset.UtcNow, recoveredScan); if (messageEvent is not null) { changed = true; if (callbacks is { Count: > 0 }) { await MessageCallbackDispatcher.DispatchAsync(callbacks, messageEvent, cancellationToken); } yield return messageEvent; } } recoveredScan = false; } } if (changed) { await ListenerCheckpointStore.SaveAsync( state.CreateCheckpoint(session, DateTimeOffset.UtcNow), checkpointPath, cancellationToken); checkpoint ??= state.CreateCheckpoint(session, DateTimeOffset.UtcNow); } if (!connected) { announceReconnect = true; await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); continue; } var remaining = duration - elapsed.Elapsed; if (remaining <= TimeSpan.Zero) { break; } using var pollCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); pollCancellation.CancelAfter(remaining < TimeSpan.FromSeconds(2) ? remaining : TimeSpan.FromSeconds(2)); try { await signals.Reader.WaitToReadAsync(pollCancellation.Token); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { } while (signals.Reader.TryRead(out _)) { } } } finally { subscription?.Dispose(); } } private static AutomationElement AttachWindow(UIA3Automation automation) { var window = WechatDoctor.FindWechatWindow(automation); if (window is null) { throw new WxAgentException(WxAgentErrorCode.WindowNotFound, "WeChat main window was not found in the current interactive session."); } window.Focus(); return window; } private static async Task OpenFileTransferAssistantCoreAsync(AutomationElement window, CancellationToken cancellationToken) { if (IsFileTransferAssistantOpen(window)) { return; } var sessionItem = FindByAutomationId(window, "session_item_" + WechatLocators.FileTransferAssistant); if (sessionItem is not null) { ClickCenter(sessionItem); if (await WaitForFileTransferAssistantAsync(window, TimeSpan.FromSeconds(5), cancellationToken)) { return; } } var search = window.FindAllDescendants().FirstOrDefault(element => SafeControlType(element) == ControlType.Edit && SafeName(element) == WechatLocators.Search) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat search box was not found."); var searchBox = search.AsTextBox(); ExecuteInputStep("set-search-value", () => searchBox.Text = WechatLocators.FileTransferAssistant); var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5); AutomationElement? searchResult = null; while (DateTimeOffset.UtcNow < deadline && searchResult is null) { cancellationToken.ThrowIfCancellationRequested(); searchResult = FindByAutomationId(window, WechatLocators.FileTransferAssistantSearchResult); if (searchResult is null) { await Task.Delay(150, cancellationToken); } } if (searchResult is null) { throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "File Transfer Assistant search result was not found."); } ClickCenter(searchResult); if (!await WaitForFileTransferAssistantAsync(window, TimeSpan.FromSeconds(10), cancellationToken)) { throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "File Transfer Assistant could not be activated from search results."); } } private static bool IsFileTransferAssistantOpen(AutomationElement window) => SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)) == WechatLocators.FileTransferAssistant; private static Task WaitForFileTransferAssistantAsync( AutomationElement window, TimeSpan timeout, CancellationToken cancellationToken) => WaitForSessionAsync(window, WechatLocators.FileTransferAssistant, timeout, cancellationToken); private static async Task WaitForSessionAsync( AutomationElement window, string name, TimeSpan timeout, CancellationToken cancellationToken) { var deadline = DateTimeOffset.UtcNow + timeout; while (DateTimeOffset.UtcNow < deadline) { cancellationToken.ThrowIfCancellationRequested(); if (SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)) == name) { return true; } await Task.Delay(150, cancellationToken); } return false; } private static async Task WaitForNewMessageAsync( HashSet previous, TimeSpan timeout, CancellationToken cancellationToken) { var deadline = DateTimeOffset.UtcNow + timeout; while (DateTimeOffset.UtcNow < deadline) { cancellationToken.ThrowIfCancellationRequested(); using var automation = new UIA3Automation(); var window = WechatDoctor.FindWechatWindow(automation); var message = window is null ? null : ReadVisible(window).LastOrDefault(item => !previous.Contains(item.Fingerprint)); if (message is not null) return message; await Task.Delay(250, cancellationToken); } return null; } private static async Task WaitForMessageAsync( string text, TimeSpan timeout, CancellationToken cancellationToken) { var deadline = DateTimeOffset.UtcNow + timeout; while (DateTimeOffset.UtcNow < deadline) { cancellationToken.ThrowIfCancellationRequested(); using var freshAutomation = new UIA3Automation(); var window = WechatDoctor.FindWechatWindow(freshAutomation); var match = window is null ? null : ReadVisible(window).LastOrDefault(message => string.Equals(message.Text, text, StringComparison.Ordinal)); if (match is not null) { return match; } await Task.Delay(150, cancellationToken); } return null; } private static IReadOnlyList ReadVisible(AutomationElement window) { var list = FindByAutomationId(window, WechatLocators.MessageList) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found."); return VisibleMessageParser.Parse(list.FindAllDescendants() .Where(element => SafeAutomationId(element) == WechatLocators.ChatBubbleItem) .Select(element => new ChatMessageSource(SafeName(element), SafeRuntimeId(element)))); } private static AutomationElement? FindByAutomationId(AutomationElement root, string automationId) => root.FindFirstDescendant(condition => condition.ByAutomationId(automationId)); private sealed class ClipboardFileLease : IDisposable { private readonly ManualResetEventSlim _ready = new(); private readonly ManualResetEventSlim _restore = new(); private readonly Thread _thread; private Exception? _error; public ClipboardFileLease(string fullPath, bool asImage) { _thread = new Thread(() => Run(fullPath, asImage)) { IsBackground = true }; _thread.SetApartmentState(ApartmentState.STA); _thread.Start(); if (!_ready.Wait(TimeSpan.FromSeconds(5))) { throw new WxAgentException(WxAgentErrorCode.ClipboardUnavailable, "Timed out while preparing the Windows clipboard."); } ThrowIfFailed(); } public void Dispose() { _restore.Set(); if (!_thread.Join(TimeSpan.FromSeconds(5))) { throw new WxAgentException(WxAgentErrorCode.ClipboardUnavailable, "Timed out while restoring the Windows clipboard."); } ThrowIfFailed(); _ready.Dispose(); _restore.Dispose(); } private void Run(string fullPath, bool asImage) { string? text = null; StringCollection? files = null; System.Drawing.Image? image = null; System.Drawing.Image? outgoingImage = null; try { if (Clipboard.ContainsFileDropList()) files = Clipboard.GetFileDropList(); else if (Clipboard.ContainsImage()) image = (System.Drawing.Image?)Clipboard.GetImage()?.Clone(); else if (Clipboard.ContainsText()) text = Clipboard.GetText(); if (asImage) { using var source = System.Drawing.Image.FromFile(fullPath); outgoingImage = new System.Drawing.Bitmap(source); Clipboard.SetImage(outgoingImage); } else { Clipboard.SetFileDropList(new StringCollection { fullPath }); } } catch (Exception exception) { _error = exception; _ready.Set(); return; } _ready.Set(); _restore.Wait(); try { if (files is { Count: > 0 }) Clipboard.SetFileDropList(files); else if (image is not null) Clipboard.SetImage(image); else if (text is not null) Clipboard.SetText(text); else Clipboard.Clear(); } catch (Exception exception) { _error = exception; } finally { image?.Dispose(); outgoingImage?.Dispose(); } } private void ThrowIfFailed() { if (_error is not null) { throw new WxAgentException(WxAgentErrorCode.ClipboardUnavailable, "The Windows clipboard could not be used for attachment sending.", _error); } } } private static void MoveToCenter(AutomationElement element) { var bounds = element.BoundingRectangle; if (bounds.Width <= 0 || bounds.Height <= 0) { throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The target control has no usable bounds."); } Mouse.MoveTo(new System.Drawing.Point(bounds.Left + bounds.Width / 2, bounds.Top + bounds.Height / 2)); } private static void RightClickMessageBubble(AutomationElement element) { var bounds = element.BoundingRectangle; if (bounds.Width <= 160 || bounds.Height <= 0) { throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The target message has no usable bounds."); } // WeChat message rows span the viewport; own-message bubbles are anchored at the right edge. Mouse.RightClick(new System.Drawing.Point(bounds.Right - 80, bounds.Top + bounds.Height / 2)); } private static void ClickCenter(AutomationElement element) { var bounds = element.BoundingRectangle; if (bounds.Width <= 0 || bounds.Height <= 0) { throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The target control has no clickable bounds."); } Mouse.Click(new System.Drawing.Point(bounds.Left + bounds.Width / 2, bounds.Top + bounds.Height / 2), MouseButton.Left); } private static void ExecuteInputStep(string step, Action action) { try { action(); } catch (Exception exception) { throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, $"UI input failed at {step}.", exception); } } private static bool SessionNameMatches(string accessibleName, string requestedName) => accessibleName.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Contains(requestedName, StringComparer.Ordinal); private static string SafeName(AutomationElement? element) { if (element is null) { return string.Empty; } try { return element.Name ?? string.Empty; } catch { return string.Empty; } } private static string SafeAutomationId(AutomationElement element) { try { return element.AutomationId ?? string.Empty; } catch { return string.Empty; } } private static string? SafeRuntimeId(AutomationElement element) { try { return string.Join('.', element.FrameworkAutomationElement.RuntimeId); } catch { return null; } } private static ControlType SafeControlType(AutomationElement element) { try { return element.ControlType; } catch { return ControlType.Custom; } } }