feat: listen to named conversations in dedicated chat windows

This commit is contained in:
2026-09-06 00:03:32 +08:00
parent 82ea9bf69f
commit 93f4583737
8 changed files with 119 additions and 31 deletions
+3 -1
View File
@@ -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') }
)
+11 -9
View File
@@ -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 <dir> [--baseline <ui-tree.json>] [--timeout 60]
inspect-ui --output <path> [--timeout 30]
inspect-ui --output <path> [--window-title <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]
@@ -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.");
}
}
@@ -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.");
+19 -8
View File
@@ -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;
+19
View File
@@ -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();
+4 -3
View File
@@ -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++;
}
+3 -2
View File
@@ -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();