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:
2026-09-06 14:35:55 +08:00
parent fb8c25c83c
commit ad22a89185
7 changed files with 166 additions and 33 deletions
+27 -10
View File
@@ -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);
}
}