From 93f4583737315b81ba7cc39594e183663c9cb129 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 6 Sep 2026 00:03:32 +0800 Subject: [PATCH] feat: listen to named conversations in dedicated chat windows --- scripts/windows/Invoke-M6Validation.ps1 | 4 +- src/WxAgent.Host/Program.cs | 20 ++++---- .../WechatChatClient.Listening.cs | 46 +++++++++++++++++++ .../WechatChatClient.Management.cs | 22 +++++---- src/WxAgent.Windows/WechatChatClient.cs | 27 +++++++---- src/WxAgent.Windows/WechatDoctor.cs | 19 ++++++++ src/WxAgent.Windows/WechatListenerSmoke.cs | 7 +-- src/WxAgent.Windows/WechatUiInspector.cs | 5 +- 8 files changed, 119 insertions(+), 31 deletions(-) create mode 100644 src/WxAgent.Windows/WechatChatClient.Listening.cs diff --git a/scripts/windows/Invoke-M6Validation.ps1 b/scripts/windows/Invoke-M6Validation.ps1 index 350bca3..58acde3 100644 --- a/scripts/windows/Invoke-M6Validation.ps1 +++ b/scripts/windows/Invoke-M6Validation.ps1 @@ -1,4 +1,4 @@ -param( +param( [Parameter(Mandatory=$true)][string]$Executable, [Parameter(Mandatory=$true)][string]$OutputDirectory ) @@ -16,6 +16,8 @@ $cases = @( @{ Name='smoke'; Args=@('smoke','--output',"$directory\smoke-ui-tree.json",'--timeout','30') }, @{ Name='recovery'; Args=@('recovery-smoke','--timeout','60') }, @{ Name='listener'; Args=@('listener-smoke','--output',"$directory\listener",'--timeout','60') }, + @{ Name='listener-independent'; Args=@('listener-smoke','--independent','--output',"$directory\listener-independent",'--timeout','60') }, + @{ Name='independent-ui'; Args=@('inspect-ui','--window-title','文件传输助手','--output',"$directory\independent-ui-tree.json",'--timeout','30') }, @{ Name='stability'; Args=@('stability-smoke','--seconds','30','--sample-seconds','5','--output',"$directory\stability",'--timeout','90') }, @{ Name='diagnose'; Args=@('diagnose','--baseline',"$directory\ui-tree.json",'--output',"$directory\diagnostics",'--timeout','60') } ) diff --git a/src/WxAgent.Host/Program.cs b/src/WxAgent.Host/Program.cs index fef780e..4c6002c 100644 --- a/src/WxAgent.Host/Program.cs +++ b/src/WxAgent.Host/Program.cs @@ -65,7 +65,7 @@ try case "inspect-ui": { var output = GetRequiredOption(args, "--output"); - var snapshot = await WechatUiInspector.CaptureAsync(output, cancellationToken); + var snapshot = await WechatUiInspector.CaptureAsync(output, cancellationToken, GetOption(args, "--window-title")); WriteJson(new { output = Path.GetFullPath(output), nodes = CountNodes(snapshot), sanitized = true }); return 0; } @@ -101,7 +101,7 @@ try } case "listener-smoke": { - var result = await WechatChatClient.ListenerSmokeAsync(GetRequiredOption(args, "--output"), cancellationToken); + var result = await WechatChatClient.ListenerSmokeAsync(GetRequiredOption(args, "--output"), cancellationToken, HasOption(args, "--independent")); WriteJson(result); return result.Sent && result.MatchingEvents == 1 && result.EventsAfterRestart == 0 && result.Snapshots >= 2 && result.CheckpointSaved ? 0 : 2; @@ -322,7 +322,9 @@ try await foreach (var messageEvent in WechatChatClient.ListenEventsAsync( TimeSpan.FromSeconds(seconds), stateFile, - cancellationToken)) + cancellationToken, + session: GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant, + independentWindow: HasOption(args, "--independent"))) { Console.WriteLine(JsonSerializer.Serialize(ToEventOutput(messageEvent, includeContent), jsonLineOptions)); } @@ -473,7 +475,7 @@ static int ValidateCommandLine(string[] values) if (values[0] == "listener-smoke") { - ValidateOptions(values, 1, ["--output", "--timeout"], []); + ValidateOptions(values, 1, ["--output", "--timeout"], ["--independent"]); return 60; } @@ -485,7 +487,7 @@ static int ValidateCommandLine(string[] values) if (values[0] == "inspect-ui") { - ValidateOptions(values, 1, ["--output", "--timeout"], []); + ValidateOptions(values, 1, ["--output", "--window-title", "--timeout"], []); return 30; } @@ -564,7 +566,7 @@ static int ValidateCommandLine(string[] values) ValidateOptions(values, 2, ["--limit", "--scrolls", "--timeout"], ["--include-content"]); return 60; case "monitor": - ValidateOptions(values, 2, ["--seconds", "--state-file", "--timeout"], ["--include-content"]); + ValidateOptions(values, 2, ["--seconds", "--state-file", "--session", "--timeout"], ["--include-content", "--independent"]); return GetPositiveIntOption(values, "--seconds", 60, 86400) + 30; case "listen": ValidateOptions(values, 2, ["--seconds", "--timeout"], ["--include-content"]); @@ -716,13 +718,13 @@ static void PrintHelp() => Console.WriteLine(""" WxAgent.Host commands: doctor [--timeout 30] diagnose --output [--baseline ] [--timeout 60] - inspect-ui --output [--timeout 30] + inspect-ui --output [--window-title ] [--timeout 30] smoke [--output <path>] [--timeout 30] m4-smoke [--timeout 60] m5-smoke [--timeout 60] tray-status [--timeout 30] window-status [--timeout 30] - listener-smoke --output <new-dir> [--timeout 60] + listener-smoke --output <new-dir> [--independent] [--timeout 60] recovery-smoke [--timeout 30] recover-ui [--timeout 30] stability-smoke [--seconds 15] [--sample-seconds 5] [--output <dir>] @@ -737,7 +739,7 @@ WxAgent.Host commands: chat send-image --path <image> [--timeout 60] chat read [--limit 20] [--include-content] [--timeout 30] chat history [--limit 100] [--scrolls 10] [--include-content] [--timeout 60] - chat monitor [--seconds 60] [--state-file <path>] [--include-content] [--timeout <seconds>] + chat monitor [--seconds 60] [--session <name>] [--independent] [--state-file <path>] [--include-content] [--timeout <seconds>] chat listen [--seconds 30] [--include-content] [--timeout <seconds>] session list [--timeout 30] session search --query <text> [--exact] [--timeout 30] diff --git a/src/WxAgent.Windows/WechatChatClient.Listening.cs b/src/WxAgent.Windows/WechatChatClient.Listening.cs new file mode 100644 index 0000000..d448956 --- /dev/null +++ b/src/WxAgent.Windows/WechatChatClient.Listening.cs @@ -0,0 +1,46 @@ +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; +using FlaUI.UIA3; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public static partial class WechatChatClient +{ + private static bool IsListeningSession(AutomationElement window, string session, bool independent) => + string.Equals(independent ? SafeName(window) : SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)), + session, StringComparison.Ordinal); + + // Called while holding CommandQueue. A dedicated chat window avoids changing the user's main chat on every poll. + private static async Task<AutomationElement> BindListeningWindowAsync( + UIA3Automation automation, string session, bool independent, CancellationToken cancellationToken) + { + if (!independent) + { + var main = AttachWindow(automation, cancellationToken); + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + return main; + } + + var window = WechatDoctor.FindNamedWechatWindow(automation, session, cancellationToken); + if (window is null) + { + var main = AttachWindow(automation, cancellationToken); + await OpenSessionInSubWindowCoreAsync(main, session, cancellationToken).ConfigureAwait(false); + } + else if (window.IsOffscreen) + { + window.Patterns.Window.Pattern.SetWindowVisualState(WindowVisualState.Normal); + } + + for (var attempt = 0; attempt < 25; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + window = WechatDoctor.FindNamedWechatWindow(automation, session, cancellationToken); + if (window is not null && FindByAutomationId(window, WechatLocators.MessageList) is not null) + return window; + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Dedicated chat window did not expose its message list."); + } +} diff --git a/src/WxAgent.Windows/WechatChatClient.Management.cs b/src/WxAgent.Windows/WechatChatClient.Management.cs index 2c6d8bb..8d74c64 100644 --- a/src/WxAgent.Windows/WechatChatClient.Management.cs +++ b/src/WxAgent.Windows/WechatChatClient.Management.cs @@ -72,13 +72,18 @@ public static partial class WechatChatClient public static Task OpenSessionInSubWindowAsync(string session, CancellationToken cancellationToken = default) => WithMainWindowAsync(async (main, _) => { - await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); - var item = FindByAutomationId(main, $"session_item_{session}") - ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Session was not found."); - item.DoubleClick(); + await OpenSessionInSubWindowCoreAsync(main, session, cancellationToken).ConfigureAwait(false); return true; }, cancellationToken); + private static async Task OpenSessionInSubWindowCoreAsync(AutomationElement main, string session, CancellationToken cancellationToken) + { + await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); + var item = FindByAutomationId(main, $"session_item_{session}") + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Session was not found."); + item.DoubleClick(); + } + public static Task<IReadOnlyList<string>> GetRecentGroupsAsync(CancellationToken cancellationToken = default) => WithMainWindowAsync(async (main, _) => { @@ -420,7 +425,7 @@ public static partial class WechatChatClient if (direct is not null) { direct.Click(); - await WaitForSessionPageAsync(main, cancellationToken).ConfigureAwait(false); + await WaitForSessionPageAsync(main, session, cancellationToken).ConfigureAwait(false); return; } var search = main.FindAllDescendants().FirstOrDefault(element => @@ -429,15 +434,16 @@ public static partial class WechatChatClient search.AsTextBox().Text = session; await Task.Delay(500, cancellationToken).ConfigureAwait(false); ClickNamed(main, session); - await WaitForSessionPageAsync(main, cancellationToken).ConfigureAwait(false); + await WaitForSessionPageAsync(main, session, cancellationToken).ConfigureAwait(false); } - private static async Task WaitForSessionPageAsync(AutomationElement main, CancellationToken cancellationToken) + private static async Task WaitForSessionPageAsync(AutomationElement main, string session, CancellationToken cancellationToken) { for (var attempt = 0; attempt < 25; attempt++) { cancellationToken.ThrowIfCancellationRequested(); - if (FindByAutomationId(main, WechatLocators.MessageList) is not null) return; + if (IsListeningSession(main, session, independent: false) + && FindByAutomationId(main, WechatLocators.MessageList) is not null) return; await Task.Delay(100, cancellationToken).ConfigureAwait(false); } throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found."); diff --git a/src/WxAgent.Windows/WechatChatClient.cs b/src/WxAgent.Windows/WechatChatClient.cs index b65623e..04c1785 100644 --- a/src/WxAgent.Windows/WechatChatClient.cs +++ b/src/WxAgent.Windows/WechatChatClient.cs @@ -376,14 +376,16 @@ public static partial class WechatChatClient string? checkpointPath, [EnumeratorCancellation] CancellationToken cancellationToken, IReadOnlyList<Func<MessageEvent, CancellationToken, Task>>? callbacks = null, - Action? snapshotObserved = 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."); } - const string session = WechatLocators.FileTransferAssistant; + WechatOperationPolicy.ValidateNames([session], nameof(session)); var checkpoint = await ListenerCheckpointStore.LoadAsync(checkpointPath, cancellationToken); if (checkpoint is not null && !string.Equals(checkpoint.Session, session, StringComparison.Ordinal)) { @@ -397,6 +399,7 @@ public static partial class WechatChatClient AutomationElement? window = null; IDisposable? subscription = null; var connected = false; + nint subscribedWindow = 0; var announceReconnect = recoveredScan; var elapsed = System.Diagnostics.Stopwatch.StartNew(); using var readDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -413,23 +416,30 @@ public static partial class WechatChatClient await CommandQueue.WaitAsync(readDeadline.Token); try { - window = AttachWindow(automation, readDeadline.Token); - if (!IsFileTransferAssistantOpen(window)) connected = false; + 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) { - await OpenFileTransferAssistantCoreAsync(window, readDeadline.Token); 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; } - // Never attribute the currently selected contact's messages to File Transfer Assistant. - if (!IsFileTransferAssistantOpen(window)) + 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 = ReadVisible(window); + visible = snapshot; snapshotObserved?.Invoke(); } finally @@ -447,6 +457,7 @@ public static partial class WechatChatClient } catch { + visible = null; connected = false; subscription?.Dispose(); subscription = null; diff --git a/src/WxAgent.Windows/WechatDoctor.cs b/src/WxAgent.Windows/WechatDoctor.cs index afe8dae..2b0453b 100644 --- a/src/WxAgent.Windows/WechatDoctor.cs +++ b/src/WxAgent.Windows/WechatDoctor.cs @@ -136,6 +136,25 @@ public static class WechatDoctor return fallback; } + internal static FlaUI.Core.AutomationElements.AutomationElement? FindNamedWechatWindow( + AutomationBase automation, string title, CancellationToken cancellationToken) + { + var matches = new List<FlaUI.Core.AutomationElements.AutomationElement>(); + foreach (var native in WechatNativeWindow.Enumerate().Where(window => window.Visible)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var candidate = automation.FromHandle((nint)native.Handle); + if (string.Equals(candidate.Name, title, StringComparison.Ordinal)) matches.Add(candidate); + } + catch { /* A window may disappear during enumeration. */ } + } + if (matches.Count > 1) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Multiple WeChat windows have the requested title; refusing an ambiguous target."); + return matches.SingleOrDefault(); + } + private static ProcessDiagnostic CreateProcessDiagnostic(Process process, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/WxAgent.Windows/WechatListenerSmoke.cs b/src/WxAgent.Windows/WechatListenerSmoke.cs index dfb4331..c766482 100644 --- a/src/WxAgent.Windows/WechatListenerSmoke.cs +++ b/src/WxAgent.Windows/WechatListenerSmoke.cs @@ -7,7 +7,7 @@ public sealed record WechatListenerSmokeResult(bool Sent, int MatchingEvents, in public static partial class WechatChatClient { - public static async Task<WechatListenerSmokeResult> ListenerSmokeAsync(string outputDirectory, CancellationToken cancellationToken) + public static async Task<WechatListenerSmokeResult> ListenerSmokeAsync(string outputDirectory, CancellationToken cancellationToken, bool independentWindow = false) { var directory = Path.GetFullPath(outputDirectory); Directory.CreateDirectory(directory); @@ -26,7 +26,8 @@ public static partial class WechatChatClient try { await foreach (var item in ListenEventsAsync(TimeSpan.FromSeconds(30), checkpoint, listening.Token, - snapshotObserved: () => { Interlocked.Increment(ref snapshots); ready.TrySetResult(); })) + snapshotObserved: () => { Interlocked.Increment(ref snapshots); ready.TrySetResult(); }, + independentWindow: independentWindow)) { if (item.Message?.Text != marker) continue; Interlocked.Increment(ref matches); @@ -49,7 +50,7 @@ public static partial class WechatChatClient } var afterRestart = 0; await foreach (var item in ListenEventsAsync(TimeSpan.FromSeconds(4), checkpoint, cancellationToken, - snapshotObserved: () => Interlocked.Increment(ref snapshots))) + snapshotObserved: () => Interlocked.Increment(ref snapshots), independentWindow: independentWindow)) { if (item.Message?.Text == marker) afterRestart++; } diff --git a/src/WxAgent.Windows/WechatUiInspector.cs b/src/WxAgent.Windows/WechatUiInspector.cs index 2631af8..aa44f7d 100644 --- a/src/WxAgent.Windows/WechatUiInspector.cs +++ b/src/WxAgent.Windows/WechatUiInspector.cs @@ -7,13 +7,14 @@ namespace WxAgent.Windows; public static class WechatUiInspector { - public static async Task<UiNodeSnapshot> CaptureAsync(string outputPath, CancellationToken cancellationToken) + public static async Task<UiNodeSnapshot> CaptureAsync(string outputPath, CancellationToken cancellationToken, string? windowTitle = null) { ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); cancellationToken.ThrowIfCancellationRequested(); using var automation = new UIA3Automation(); - var window = WechatDoctor.FindWechatWindow(automation) + var window = (windowTitle is null ? WechatDoctor.FindWechatWindow(automation) + : WechatDoctor.FindNamedWechatWindow(automation, windowTitle, cancellationToken)) ?? throw new WxAgentException(WxAgentErrorCode.WindowNotFound, "WeChat window was not found in the current interactive session."); var guard = new UiTraversalGuard();