feat: send text to named sessions with draft protection and listener isolation
- chat send and listener-smoke accept --session; defaults remain File Transfer Assistant. - Reject existing drafts, reacquire the send control after input changes, and require a fresh message fingerprint for confirmation without retrying failed sends. - Test-NamedTextSend sends the same text twice and verifies distinct confirmed fingerprints; Test-NamedListeners -SendMarkers verifies per-window own events exactly once with zero cross-session events. Note export now requires a unique fresh owned note window and keeps repeated paragraphs. 97 Core tests pass; real-machine validated.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$Executable,
|
||||
[Parameter(Mandatory=$true)][string]$OutputDirectory
|
||||
[Parameter(Mandatory=$true)][string]$OutputDirectory,
|
||||
[switch]$SendMarkers
|
||||
)
|
||||
$ErrorActionPreference='Stop'
|
||||
$exe=(Resolve-Path $Executable).Path
|
||||
@@ -9,29 +10,56 @@ if (Test-Path $directory) { throw 'Use a new evidence directory.' }
|
||||
[IO.Directory]::CreateDirectory($directory) | Out-Null
|
||||
$targets=@('文件传输助手','消息测试专用群组')
|
||||
$processes=@()
|
||||
$sent=@()
|
||||
$seconds=if($SendMarkers){45}else{8}
|
||||
try {
|
||||
for($i=0;$i -lt $targets.Count;$i++) {
|
||||
$arguments=@('chat','monitor','--independent','--session',('"'+$targets[$i]+'"'),
|
||||
'--seconds','8','--state-file',('"'+$directory+'\state-'+$i+'.json"'),'--timeout','20')
|
||||
'--seconds',$seconds,'--state-file',('"'+$directory+'\state-'+$i+'.json"'),'--timeout','60')
|
||||
$process=Start-Process $exe -ArgumentList $arguments -PassThru -NoNewWindow `
|
||||
-RedirectStandardOutput "$directory\events-$i.jsonl" -RedirectStandardError "$directory\stderr-$i.txt"
|
||||
$null=$process.Handle # Retain the process handle so PowerShell 5 can retrieve ExitCode after exit.
|
||||
$processes+=$process
|
||||
}
|
||||
if($SendMarkers){
|
||||
$readyDeadline=[DateTime]::UtcNow.AddSeconds(20)
|
||||
while(!(Test-Path "$directory\state-0.json") -or !(Test-Path "$directory\state-1.json")){
|
||||
if([DateTime]::UtcNow -ge $readyDeadline){throw 'Listeners did not establish both baselines; no messages sent.'}
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
foreach($target in $targets){
|
||||
$marker='wx-agent-isolation-'+[Guid]::NewGuid().ToString('N')
|
||||
$message=(& $exe chat send --session $target --text $marker --timeout 30 | Out-String) | ConvertFrom-Json
|
||||
$code=$LASTEXITCODE
|
||||
$sent+=@{ExitCode=$code;Fingerprint=$message.Fingerprint}
|
||||
if($code -ne 0){break} # Never retry a failed send.
|
||||
}
|
||||
}
|
||||
$checks=@()
|
||||
for($i=0;$i -lt $processes.Count;$i++) {
|
||||
$process=$processes[$i]
|
||||
if (!$process.WaitForExit(30000)) { throw 'Named listener exceeded its deadline.' }
|
||||
if (!$process.WaitForExit(60000)) { throw 'Named listener exceeded its deadline.' }
|
||||
$exitCode=$process.ExitCode
|
||||
$statePath="$directory\state-$i.json"
|
||||
$state=if(Test-Path $statePath){ Get-Content $statePath -Raw | ConvertFrom-Json }else{$null}
|
||||
& $exe inspect-ui --window-title $targets[$i] --output "$directory\ui-$i.json" --timeout 20 | Out-Null
|
||||
$inspectCode=$LASTEXITCODE
|
||||
$checks+=@{Index=$i;ExitCode=$exitCode;CheckpointSaved=($null -ne $state);
|
||||
$check=@{Index=$i;ExitCode=$exitCode;CheckpointSaved=($null -ne $state);
|
||||
SessionMatches=($state.session -eq $targets[$i]);FingerprintCount=if($null -ne $state){@($state.fingerprints).Count}else{0};InspectExit=$inspectCode}
|
||||
if($SendMarkers -and $sent.Count -eq 2){
|
||||
$events=@(Get-Content "$directory\events-$i.jsonl" | Where-Object {$_} | ForEach-Object {$_ | ConvertFrom-Json})
|
||||
$check.OwnEvents=@($events | Where-Object {$_.message.Fingerprint -eq $sent[$i].Fingerprint -and $_.Session -ceq $targets[$i]}).Count
|
||||
$check.OtherSessionEvents=@($events | Where-Object {$_.message.Fingerprint -eq $sent[1-$i].Fingerprint}).Count
|
||||
}
|
||||
$checks+=$check
|
||||
}
|
||||
$passed=@($checks | Where-Object {$_.ExitCode -ne 0 -or !$_.CheckpointSaved -or !$_.SessionMatches -or $_.FingerprintCount -lt 1 -or $_.InspectExit -ne 0}).Count -eq 0
|
||||
$result=@{Checks=$checks;Passed=$passed;MessagesSent=0;Scope='Parallel named-window baseline only; not incoming-message delivery or endurance.'}
|
||||
if($SendMarkers){
|
||||
$passed=$passed -and $sent.Count -eq 2 -and @($sent | Where-Object {$_.ExitCode -ne 0}).Count -eq 0 -and
|
||||
@($checks | Where-Object {$_.OwnEvents -ne 1 -or $_.OtherSessionEvents -ne 0}).Count -eq 0
|
||||
}
|
||||
$result=@{Checks=$checks;Passed=$passed;AttemptedSends=$sent.Count;MessagesSent=@($sent | Where-Object {$_.ExitCode -eq 0}).Count;
|
||||
Scope=if($SendMarkers){'Own sent-message events across two independent windows; not peer incoming delivery or endurance.'}else{'Parallel named-window baseline only; not incoming-message delivery or endurance.'}}
|
||||
[IO.File]::WriteAllText("$directory\summary.json",($result | ConvertTo-Json -Depth 5),(New-Object Text.UTF8Encoding($false)))
|
||||
if(!$passed){exit 2}
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$Executable,
|
||||
[Parameter(Mandatory=$true)][string]$OutputDirectory,
|
||||
[string]$Session='消息测试专用群组'
|
||||
)
|
||||
$ErrorActionPreference='Stop'
|
||||
$exe=(Resolve-Path $Executable).Path
|
||||
if(Test-Path $OutputDirectory){throw 'Use a fresh evidence directory.'}
|
||||
$directory=(New-Item -ItemType Directory $OutputDirectory).FullName
|
||||
$marker='wx-agent-repeat-'+[Guid]::NewGuid().ToString('N')
|
||||
$results=@()
|
||||
for($i=0;$i -lt 2;$i++){
|
||||
$result=(& $exe chat send --session $Session --text $marker --timeout 30 | Out-String) | ConvertFrom-Json
|
||||
$exitCode=$LASTEXITCODE
|
||||
$results+=@{ExitCode=$exitCode;Fingerprint=$result.Fingerprint;Error=$result.error;Message=$result.message}
|
||||
if($exitCode -ne 0){break} # Never retry a failed send.
|
||||
}
|
||||
$success=($results.Count -eq 2 -and $results[0].ExitCode -eq 0 -and $results[1].ExitCode -eq 0 -and
|
||||
$null -ne $results[0].Fingerprint -and $null -ne $results[1].Fingerprint -and
|
||||
$results[0].Fingerprint -ne $results[1].Fingerprint)
|
||||
@{Success=$success;Results=$results;AttemptedSends=$results.Count;BinarySha256=(Get-FileHash $exe -Algorithm SHA256).Hash} |
|
||||
ConvertTo-Json -Depth 5 | Set-Content "$directory\summary.json" -Encoding UTF8
|
||||
if(!$success){exit 1}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public sealed record WechatNoteWindowCandidate(long Handle, int ProcessId, string Title, bool IsVisible, bool HasChatSurface);
|
||||
|
||||
public static class WechatNoteWindow
|
||||
{
|
||||
public static long? FindOpened(IEnumerable<WechatNoteWindowCandidate> windows, int processId, IReadOnlySet<long> previousHandles)
|
||||
{
|
||||
var candidates = windows.Where(window => window.Handle != 0 && window.ProcessId == processId
|
||||
&& !previousHandles.Contains(window.Handle) && window.IsVisible && !window.HasChatSurface
|
||||
&& window.Title.Contains("笔记", StringComparison.Ordinal)).ToArray();
|
||||
if (candidates.Length > 1)
|
||||
throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Multiple new note windows appeared; refusing ambiguous content.");
|
||||
return candidates.Length == 1 ? candidates[0].Handle : null;
|
||||
}
|
||||
}
|
||||
@@ -309,21 +309,32 @@ public static partial class WechatChatClient
|
||||
var message = FindVisibleMessage(main, fingerprint);
|
||||
if (message.Type != ChatMessageType.Note)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The selected message is not a note.");
|
||||
var processId = main.Properties.ProcessId.ValueOrDefault;
|
||||
var previousHandles = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window))
|
||||
.Select(window => window.Properties.NativeWindowHandle.ValueOrDefault.ToInt64()).ToHashSet();
|
||||
FindVisibleMessageElement(main, fingerprint).DoubleClick();
|
||||
await Task.Delay(400, cancellationToken).ConfigureAwait(false);
|
||||
var processIds = Process.GetProcessesByName("Weixin").Select(process => process.Id).ToHashSet();
|
||||
var noteWindow = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window))
|
||||
.FirstOrDefault(window => processIds.Contains(window.Properties.ProcessId.ValueOrDefault)
|
||||
&& !window.Properties.IsOffscreen.ValueOrDefault
|
||||
&& SafeName(window).Contains("笔记", StringComparison.Ordinal));
|
||||
var root = noteWindow ?? main;
|
||||
var parts = root.FindAllDescendants(cf => cf.ByControlType(ControlType.Text))
|
||||
.Select(SafeName)
|
||||
.Where(text => !string.IsNullOrWhiteSpace(text))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Select(text => new WechatNoteContentPart("text", text))
|
||||
.ToArray();
|
||||
return new WechatNoteContent(parts);
|
||||
var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(150, cancellationToken).ConfigureAwait(false);
|
||||
var windows = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window))
|
||||
.Where(window => window.Properties.ProcessId.ValueOrDefault == processId).ToArray();
|
||||
var handle = WechatNoteWindow.FindOpened(windows.Select(window => new WechatNoteWindowCandidate(
|
||||
window.Properties.NativeWindowHandle.ValueOrDefault.ToInt64(), window.Properties.ProcessId.ValueOrDefault,
|
||||
SafeName(window), !window.Properties.IsOffscreen.ValueOrDefault,
|
||||
FindByAutomationId(window, WechatLocators.MessageList) is not null)), processId, previousHandles);
|
||||
if (handle is null) continue;
|
||||
var noteWindow = windows.Single(window => window.Properties.NativeWindowHandle.ValueOrDefault.ToInt64() == handle);
|
||||
var parts = noteWindow.FindAllDescendants(cf => cf.ByControlType(ControlType.Text))
|
||||
.Select(SafeName)
|
||||
.Where(text => !string.IsNullOrWhiteSpace(text))
|
||||
.Select(text => new WechatNoteContentPart("text", text))
|
||||
.ToArray();
|
||||
if (parts.Length == 0) continue;
|
||||
return new WechatNoteContent(parts);
|
||||
}
|
||||
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed,
|
||||
"A unique new note window with readable content was not verified. The main chat and existing windows were not exported.");
|
||||
}, cancellationToken);
|
||||
|
||||
public static async Task<string> VisibleNoteToMarkdownFileAsync(
|
||||
|
||||
@@ -122,9 +122,11 @@ public static partial class WechatChatClient
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<ChatMessageSnapshot> SendTextAsync(string text, CancellationToken cancellationToken)
|
||||
public static async Task<ChatMessageSnapshot> SendTextAsync(string text, CancellationToken cancellationToken,
|
||||
string session = WechatLocators.FileTransferAssistant)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(text);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(session);
|
||||
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.");
|
||||
@@ -135,26 +137,31 @@ public static partial class WechatChatClient
|
||||
{
|
||||
using var automation = new UIA3Automation();
|
||||
var window = AttachWindow(automation, cancellationToken);
|
||||
await OpenFileTransferAssistantCoreAsync(window, 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 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();
|
||||
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 (!string.Equals(inputBox.Text, text, StringComparison.Ordinal))
|
||||
if (!IsListeningSession(window, session, independent: false) || !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.");
|
||||
}
|
||||
|
||||
// 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, TimeSpan.FromSeconds(20), cancellationToken);
|
||||
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
|
||||
@@ -667,6 +674,8 @@ public static partial class WechatChatClient
|
||||
|
||||
private static async Task<ChatMessageSnapshot?> WaitForMessageAsync(
|
||||
string text,
|
||||
string session,
|
||||
IReadOnlySet<string> baseline,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -676,9 +685,12 @@ public static partial class WechatChatClient
|
||||
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, text, StringComparison.Ordinal));
|
||||
: ReadVisible(window).LastOrDefault(message =>
|
||||
string.Equals(message.Text, text, StringComparison.Ordinal) && !baseline.Contains(message.Fingerprint));
|
||||
if (match is not null)
|
||||
{
|
||||
return match;
|
||||
@@ -833,9 +845,14 @@ public static partial class WechatChatClient
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (WxAgentException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, $"UI input failed at {step}.", exception);
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState,
|
||||
$"UI input failed at {step} ({exception.GetType().Name}, HRESULT 0x{exception.HResult:X8}).", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ public sealed record WechatListenerSmokeResult(bool Sent, int MatchingEvents, in
|
||||
|
||||
public static partial class WechatChatClient
|
||||
{
|
||||
public static async Task<WechatListenerSmokeResult> ListenerSmokeAsync(string outputDirectory, CancellationToken cancellationToken, bool independentWindow = false)
|
||||
public static async Task<WechatListenerSmokeResult> ListenerSmokeAsync(string outputDirectory, CancellationToken cancellationToken, bool independentWindow = false,
|
||||
string session = WechatLocators.FileTransferAssistant)
|
||||
{
|
||||
var directory = Path.GetFullPath(outputDirectory);
|
||||
Directory.CreateDirectory(directory);
|
||||
@@ -27,7 +28,7 @@ public static partial class WechatChatClient
|
||||
{
|
||||
await foreach (var item in ListenEventsAsync(TimeSpan.FromSeconds(30), checkpoint, listening.Token,
|
||||
snapshotObserved: () => { Interlocked.Increment(ref snapshots); ready.TrySetResult(); },
|
||||
independentWindow: independentWindow))
|
||||
session: session, independentWindow: independentWindow))
|
||||
{
|
||||
if (item.Message?.Text != marker) continue;
|
||||
Interlocked.Increment(ref matches);
|
||||
@@ -39,7 +40,7 @@ public static partial class WechatChatClient
|
||||
try
|
||||
{
|
||||
await ready.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
|
||||
await SendTextAsync(marker, cancellationToken).ConfigureAwait(false); // One send only, through the same command queue.
|
||||
await SendTextAsync(marker, cancellationToken, session).ConfigureAwait(false); // One send only, through the same command queue.
|
||||
await matched.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -50,7 +51,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), independentWindow: independentWindow))
|
||||
snapshotObserved: () => Interlocked.Increment(ref snapshots), session: session, independentWindow: independentWindow))
|
||||
{
|
||||
if (item.Message?.Text == marker) afterRestart++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatNoteWindowTests
|
||||
{
|
||||
[Fact]
|
||||
public void OnlyAFreshVisibleNoteInTheExpectedProcessIsSelected()
|
||||
{
|
||||
WechatNoteWindowCandidate[] windows = [
|
||||
new(1, 10, "笔记", true, false), // Existing note must not be exported instead of the selected message.
|
||||
new(2, 10, "笔记交流群", true, true),
|
||||
new(3, 20, "笔记", true, false),
|
||||
new(4, 10, "笔记", false, false),
|
||||
new(5, 10, "微信", true, false),
|
||||
new(6, 10, "笔记", true, false)];
|
||||
Assert.Equal(6, WechatNoteWindow.FindOpened(windows, 10, new HashSet<long> { 1 }));
|
||||
Assert.Null(WechatNoteWindow.FindOpened(windows[..5], 10, new HashSet<long> { 1 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmbiguousNewNotesAreRejected()
|
||||
{
|
||||
var error = Assert.Throws<WxAgentException>(() => WechatNoteWindow.FindOpened([
|
||||
new(1, 10, "笔记", true, false), new(2, 10, "笔记", true, false)], 10, new HashSet<long>()));
|
||||
Assert.Equal(WxAgentErrorCode.UiStructureChanged, error.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedNoteParagraphsMustRemainRepeated()
|
||||
{
|
||||
var content = new WechatNoteContent([
|
||||
new("text", "重复段落"), new("text", "重复段落")]);
|
||||
Assert.Equal("重复段落\n\n重复段落", content.ToMarkdown().ReplaceLineEndings("\n"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user