feat: listen to named conversations in dedicated chat windows
This commit is contained in:
@@ -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') }
|
||||
)
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user