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 partial class WechatChatClient { public static IDisposable UseWindowTarget(int processId, long windowHandle) => WechatDoctor.UseWindow(processId, windowHandle); private static readonly InterprocessCommandGate CommandQueue = CreateCommandGate(); private static InterprocessCommandGate CreateCommandGate() { using var process = System.Diagnostics.Process.GetCurrentProcess(); return new InterprocessCommandGate(Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WxAgent", $"ui-session-{process.SessionId}.lock")); } public static Task> ListVisibleSessionsAsync(CancellationToken cancellationToken) => WithMainWindowAsync((window, _) => { 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); }, cancellationToken); public static async Task> SearchSessionsAsync( string query, bool exactOnly, CancellationToken cancellationToken, string? uiOutput = null) { ArgumentException.ThrowIfNullOrWhiteSpace(query); await CommandQueue.WaitAsync(cancellationToken); try { using var automation = new UIA3Automation(); var window = AttachWindow(automation, cancellationToken); 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 { var results = await SearchLocalSessionsCoreAsync(window, query, cancellationToken).ConfigureAwait(false); if (uiOutput is not null) await WechatUiInspector.CaptureAsync(uiOutput, cancellationToken).ConfigureAwait(false); return exactOnly ? results.Where(result => result.IsExactMatch).ToArray() : results; } finally { box.Text = previous; } } finally { CommandQueue.Release(); } } public static Task GetCurrentSessionAsync(CancellationToken cancellationToken) => WithMainWindowAsync((window, _) => { var element = FindByAutomationId(window, WechatLocators.CurrentChatName); var name = SafeName(element); return Task.FromResult(string.IsNullOrWhiteSpace(name) ? null : new WechatSessionSnapshot(name, SafeAutomationId(element!), true)); }, cancellationToken); public static async Task OpenSessionAsync(string name, CancellationToken cancellationToken, string? query = null) { ArgumentException.ThrowIfNullOrWhiteSpace(name); if (query is not null) ArgumentException.ThrowIfNullOrWhiteSpace(query); await CommandQueue.WaitAsync(cancellationToken); try { using var automation = new UIA3Automation(); var window = AttachWindow(automation, cancellationToken); if (query is not null) { var selected = await OpenLocalSearchSessionAsync(window, name, cancellationToken, query).ConfigureAwait(false); return new WechatSessionSnapshot(name, selected.AutomationId, true); } await OpenNamedSessionAsync(window, name, cancellationToken).ConfigureAwait(false); var current = FindByAutomationId(window, WechatLocators.CurrentChatName); var currentAutomationId = current is null ? null : SafeAutomationId(current); return new WechatSessionSnapshot(name, currentAutomationId ?? "session_item_" + name, true); } finally { CommandQueue.Release(); } } public static async Task SendTextAsync(string text, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) { text = WechatTextInput.Prepare(text); ArgumentException.ThrowIfNullOrWhiteSpace(session); await CommandQueue.WaitAsync(cancellationToken); try { using var automation = new UIA3Automation(); var window = AttachWindow(automation, cancellationToken); await OpenNamedSessionAsync(window, session, cancellationToken).ConfigureAwait(false); await Task.Delay(500, cancellationToken); if (!IsListeningSession(window, session, independent: false)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested conversation changed before input."); var baseline = 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 inputBox = input.AsTextBox(); if (!string.IsNullOrEmpty(inputBox.Text)) throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The conversation contains an existing draft; it was not overwritten."); ExecuteInputStep("set-input-value", () => inputBox.Text = text); await Task.Delay(150, cancellationToken); if (!IsListeningSession(window, session, independent: false) || !string.Equals(inputBox.Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal)) { throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The chat input did not contain the exact requested text; send was aborted."); } // Setting input text can recreate the send control; acquire it only after the input mutation. var send = window.FindAllDescendants().FirstOrDefault(element => SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found after input."); ExecuteInputStep("click-send", () => ClickCenter(send)); var confirmed = await WaitForMessageAsync(text, session, baseline, 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, string session = WechatLocators.FileTransferAssistant) { text = WechatTextInput.Prepare(text); ArgumentException.ThrowIfNullOrWhiteSpace(session); await CommandQueue.WaitAsync(cancellationToken); try { using var automation = new UIA3Automation(); var window = AttachWindow(automation, cancellationToken); await OpenNamedSessionAsync(window, session, cancellationToken).ConfigureAwait(false); await Task.Delay(500, cancellationToken); if (!IsListeningSession(window, session, independent: false)) { throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested conversation changed before quoting."); } 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 draft = FindByAutomationId(window, WechatLocators.ChatInput) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Chat input was not found."); if (!string.IsNullOrEmpty(draft.AsTextBox().Text)) throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The conversation contains an existing draft; it was not overwritten."); 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."); if (!IsListeningSession(window, session, independent: false)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before reply input."); if (!string.IsNullOrEmpty(input.AsTextBox().Text)) throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The reply input is not empty; it was not overwritten."); ExecuteInputStep("set-reply-value", () => input.AsTextBox().Text = text); if (!IsListeningSession(window, session, independent: false) || !string.Equals(input.AsTextBox().Text.ReplaceLineEndings("\n"), 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, session, ChatMessageType.Quote, text, 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, string session = WechatLocators.FileTransferAssistant) => SendAttachmentAsync(path, asImage: false, session, cancellationToken); public static Task SendImageAsync(string path, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) => SendAttachmentAsync(path, asImage: true, session, cancellationToken); private static async Task SendAttachmentAsync(string path, bool asImage, string session, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(session); 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, cancellationToken); await OpenNamedSessionAsync(window, session, cancellationToken).ConfigureAwait(false); await Task.Delay(500, cancellationToken); if (!IsListeningSession(window, session, independent: false)) { throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested conversation changed before attachment input."); } 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; if (!string.IsNullOrEmpty(inputText)) { 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 ClipboardLease(fullPath, asImage)) { if (!IsListeningSession(window, session, independent: false)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before attachment paste."); ExecuteInputStep("paste-attachment", () => 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."); if (!IsListeningSession(refreshedWindow, session, independent: false)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before attachment send."); 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, session, asImage ? ChatMessageType.Image : ChatMessageType.File, asImage ? null : Path.GetFileName(fullPath), TimeSpan.FromSeconds(30), cancellationToken); return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The attachment selection completed but no matching new visible message confirmed the send result."); } finally { CommandQueue.Release(); } } public static async Task> ReadHistoryAsync( int maxMessages, int maxScrolls, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) { ArgumentException.ThrowIfNullOrWhiteSpace(session); 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, cancellationToken); await OpenNamedSessionAsync(window, session, cancellationToken).ConfigureAwait(false); 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(); RequireNavigationSession(window, session); 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; ScrollNavigationList(window, WechatLocators.MessageList, WechatScrollDirection.Up); scrolled++; await Task.Delay(350, cancellationToken); } if (scrolled > 0) { await GoToLatestCoreAsync(window, session, 100, cancellationToken).ConfigureAwait(false); } 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, cancellationToken); await OpenFileTransferAssistantCoreAsync(window, cancellationToken); return ReadVisible(window); } finally { CommandQueue.Release(); } } public static async IAsyncEnumerable ListenAsync( TimeSpan duration, [EnumeratorCancellation] CancellationToken cancellationToken) { await foreach (var item in ListenEventsAsync(duration, checkpointPath: null, cancellationToken)) { if (item.Kind == MessageEventKind.MessageReceived && item.Message is { } message) yield return message; } } public static async IAsyncEnumerable ListenEventsAsync( TimeSpan duration, string? checkpointPath, [EnumeratorCancellation] CancellationToken cancellationToken, IReadOnlyList>? callbacks = null, Action? snapshotObserved = null, string session = WechatLocators.FileTransferAssistant, bool independentWindow = false) { if (duration <= TimeSpan.Zero) { throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Listen duration must be positive."); } WechatOperationPolicy.ValidateNames([session], nameof(session)); WechatDesktop.EnsureInputAvailable(); 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; nint subscribedWindow = 0; var bindingAttempted = false; var observedSnapshot = false; Exception? lastReadError = null; var announceReconnect = recoveredScan; var elapsed = System.Diagnostics.Stopwatch.StartNew(); using var readDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); readDeadline.CancelAfter(duration); try { while (elapsed.Elapsed < duration) { cancellationToken.ThrowIfCancellationRequested(); IReadOnlyList? visible = null; try { await CommandQueue.WaitAsync(readDeadline.Token); try { bindingAttempted = true; window = await BindListeningWindowAsync(automation, session, independentWindow, readDeadline.Token).ConfigureAwait(false); var handle = window.Properties.NativeWindowHandle.Value; if (subscribedWindow != 0 && handle != subscribedWindow) { connected = false; announceReconnect = true; } if (!connected) { subscription?.Dispose(); var list = FindByAutomationId(window, WechatLocators.MessageList) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Listener message list was not found."); subscription = list.RegisterStructureChangedEvent( TreeScope.Subtree, (_, _, _) => signals.Writer.TryWrite(true)); subscribedWindow = handle; connected = true; } if (!IsListeningSession(window, session, independentWindow)) throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener session changed before snapshot."); var snapshot = ReadVisible(window); if (!IsListeningSession(window, session, independentWindow)) throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener session changed during snapshot."); visible = snapshot; observedSnapshot = true; snapshotObserved?.Invoke(); } finally { CommandQueue.Release(); } } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && readDeadline.IsCancellationRequested) { break; } catch (OperationCanceledException) { throw; } catch (Exception exception) { lastReadError = exception; visible = null; 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(); } if (bindingAttempted && !observedSnapshot) throw new WxAgentException(lastReadError is WxAgentException error ? error.Code : WxAgentErrorCode.ControlNotFound, $"Listener never obtained a valid snapshot (stage: {lastReadError?.TargetSite?.Name ?? "initial binding"}).", lastReadError); } private static AutomationElement AttachWindow(UIA3Automation automation, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); WechatDesktop.EnsureInputAvailable(); var window = WechatDoctor.FindWechatWindow(automation); if (window is null || FindByAutomationId(window, WechatLocators.MainView) is null) { cancellationToken.ThrowIfCancellationRequested(); if (WechatDoctor.TargetProcessId is null && !WechatTray.TryActivateUia(automation)) WechatTray.Inspect(true, cancellationToken); for (var attempt = 0; attempt < 50; attempt++) { if (cancellationToken.WaitHandle.WaitOne(100)) cancellationToken.ThrowIfCancellationRequested(); window = WechatDoctor.FindWechatWindow(automation); if (window is not null && FindByAutomationId(window, WechatLocators.MainView) is not null) break; } } if (window is null || FindByAutomationId(window, WechatLocators.MainView) is null) throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat tray activation did not restore an accessible main view; no new process was started."); cancellationToken.ThrowIfCancellationRequested(); 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( IReadOnlySet previous, string session, ChatMessageType type, string? expectedText, TimeSpan timeout, CancellationToken cancellationToken, string? mentionMember = null) { var deadline = DateTimeOffset.UtcNow + timeout; while (DateTimeOffset.UtcNow < deadline) { cancellationToken.ThrowIfCancellationRequested(); using var automation = new UIA3Automation(); var window = WechatDoctor.FindWechatWindow(automation); if (window is not null && !IsListeningSession(window, session, independent: false)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed while confirming the send; no retry was attempted."); var message = window is null ? null : ReadVisible(window).LastOrDefault(item => mentionMember is null ? WechatSendConfirmation.Matches(item, previous, type, expectedText) : WechatSendConfirmation.MatchesMention(item, previous, mentionMember, expectedText)); if (message is not null) return message; await Task.Delay(250, cancellationToken); } return null; } private static async Task WaitForMessageAsync( string text, string session, IReadOnlySet baseline, 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); if (window is not null && !IsListeningSession(window, session, independent: false)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed while confirming the send; no retry was attempted."); var match = window is null ? null : ReadVisible(window).LastOrDefault(message => string.Equals(message.Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal) && !baseline.Contains(message.Fingerprint)); 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 ClipboardLease : IDisposable { private readonly ManualResetEventSlim _ready = new(); private readonly ManualResetEventSlim _restore = new(); private readonly Thread _thread; private Exception? _error; public ClipboardLease(string content, bool asImage = false, bool asText = false) { _thread = new Thread(() => Run(content, asImage, asText)) { 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 content, bool asImage, bool asText) { 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 (asText) { Clipboard.SetText(content); } else if (asImage) { using var source = System.Drawing.Image.FromFile(content); outgoingImage = new System.Drawing.Bitmap(source); Clipboard.SetImage(outgoingImage); } else { Clipboard.SetFileDropList(new StringCollection { content }); } } 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 input.", _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) { WechatDesktop.EnsureInputAvailable(); 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) { WechatDesktop.EnsureInputAvailable(); try { action(); } catch (WxAgentException) { throw; } catch (Exception exception) { throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, $"UI input failed at {step} ({exception.GetType().Name}, HRESULT 0x{exception.HResult:X8}).", 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; } } }