feat: generalize session sending with multiline text and structured mentions

Add named-session file/image/reply sending, preserve multiline text, and share safe member/all mention handling. Protect existing drafts, bind popup selection to the current window, validate structured mention tokens, and require fresh matching visible messages without automatic resend. Core tests: 121 passed. Windows 4.1.13.63 final validation: 9/9 passed. Record explicit coverage limits and sanitized evidence.
This commit is contained in:
2026-09-06 17:10:53 +08:00
parent ad22a89185
commit c49f6a10b2
23 changed files with 531 additions and 69 deletions
+71 -41
View File
@@ -125,12 +125,8 @@ public static partial class WechatChatClient
public static async Task<ChatMessageSnapshot> SendTextAsync(string text, CancellationToken cancellationToken,
string session = WechatLocators.FileTransferAssistant)
{
ArgumentException.ThrowIfNullOrWhiteSpace(text);
text = WechatTextInput.Prepare(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.");
}
await CommandQueue.WaitAsync(cancellationToken);
try
@@ -150,7 +146,7 @@ public static partial class WechatChatClient
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 (!IsListeningSession(window, session, independent: false) || !string.Equals(inputBox.Text, text, StringComparison.Ordinal))
if (!IsListeningSession(window, session, independent: false) || !string.Equals(inputBox.Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal))
{
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The chat input did not contain the exact requested text; send was aborted.");
}
@@ -170,23 +166,29 @@ public static partial class WechatChatClient
}
}
public static async Task<ChatMessageSnapshot> ReplyToLatestAsync(string text, CancellationToken cancellationToken)
public static async Task<ChatMessageSnapshot> ReplyToLatestAsync(string text, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant)
{
ArgumentException.ThrowIfNullOrWhiteSpace(text);
if (text.Length > 4000 || text.Contains('\r') || text.Contains('\n'))
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Reply text must be one line and no longer than 4000 characters.");
}
text = WechatTextInput.Prepare(text);
ArgumentException.ThrowIfNullOrWhiteSpace(session);
await CommandQueue.WaitAsync(cancellationToken);
try
{
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 quoting.");
}
var before = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal);
var list = FindByAutomationId(window, WechatLocators.MessageList)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found.");
var draft = FindByAutomationId(window, WechatLocators.ChatInput)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Chat input was not found.");
if (!string.IsNullOrEmpty(draft.AsTextBox().Text))
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The conversation contains an existing draft; it was not overwritten.");
var latest = list.FindAllDescendants()
.LastOrDefault(element => SafeAutomationId(element) == WechatLocators.ChatBubbleItem)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "No visible message is available to quote.");
@@ -211,8 +213,12 @@ public static partial class WechatChatClient
window = WechatDoctor.FindWechatWindow(automation) ?? window;
var input = FindByAutomationId(window, WechatLocators.ChatInput)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found.");
input.AsTextBox().Text = text;
if (!string.Equals(input.AsTextBox().Text, text, StringComparison.Ordinal))
if (!IsListeningSession(window, session, independent: false))
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before reply input.");
if (!string.IsNullOrEmpty(input.AsTextBox().Text))
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The reply input is not empty; it was not overwritten.");
ExecuteInputStep("set-reply-value", () => input.AsTextBox().Text = text);
if (!IsListeningSession(window, session, independent: false) || !string.Equals(input.AsTextBox().Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal))
{
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The reply input did not contain the exact requested text; send was aborted.");
}
@@ -221,7 +227,7 @@ public static partial class WechatChatClient
SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found.");
ClickCenter(send);
var confirmed = await WaitForNewMessageAsync(before, TimeSpan.FromSeconds(30), cancellationToken);
var confirmed = await WaitForNewMessageAsync(before, session, ChatMessageType.Quote, text, TimeSpan.FromSeconds(30), cancellationToken);
return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "No new visible quoted reply confirmed the send result.");
}
finally
@@ -230,14 +236,15 @@ public static partial class WechatChatClient
}
}
public static Task<ChatMessageSnapshot> SendFileAsync(string path, CancellationToken cancellationToken) =>
SendAttachmentAsync(path, asImage: false, cancellationToken);
public static Task<ChatMessageSnapshot> SendFileAsync(string path, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) =>
SendAttachmentAsync(path, asImage: false, session, cancellationToken);
public static Task<ChatMessageSnapshot> SendImageAsync(string path, CancellationToken cancellationToken) =>
SendAttachmentAsync(path, asImage: true, cancellationToken);
public static Task<ChatMessageSnapshot> SendImageAsync(string path, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) =>
SendAttachmentAsync(path, asImage: true, session, cancellationToken);
private static async Task<ChatMessageSnapshot> SendAttachmentAsync(string path, bool asImage, CancellationToken cancellationToken)
private static async Task<ChatMessageSnapshot> SendAttachmentAsync(string path, bool asImage, string session, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(session);
var fullPath = Path.GetFullPath(path);
if (!File.Exists(fullPath))
{
@@ -249,33 +256,43 @@ 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 attachment input.");
}
var before = 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 inputText = input.AsTextBox().Text?.Trim();
var placeholder = SafeName(input).Trim();
if (!string.IsNullOrEmpty(inputText) && !string.Equals(inputText, placeholder, StringComparison.Ordinal))
var inputText = input.AsTextBox().Text;
if (!string.IsNullOrEmpty(inputText))
{
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, $"The chat input contains an unsent draft ({inputText.Length} characters); attachment send was aborted.");
}
ClickCenter(input);
await Task.Delay(150, cancellationToken);
using (new ClipboardFileLease(fullPath, asImage))
using (new ClipboardLease(fullPath, asImage))
{
Keyboard.TypeSimultaneously([VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_V]);
if (!IsListeningSession(window, session, independent: false))
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before attachment paste.");
ExecuteInputStep("paste-attachment", () => Keyboard.TypeSimultaneously([VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_V]));
await Task.Delay(500, cancellationToken);
}
var refreshedWindow = WechatDoctor.FindWechatWindow(automation)
?? throw new WxAgentException(WxAgentErrorCode.WindowNotFound, "WeChat main window disappeared after attachment paste.");
if (!IsListeningSession(refreshedWindow, session, independent: false))
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before attachment send.");
var send = refreshedWindow.FindAllDescendants().FirstOrDefault(element =>
SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found after attachment paste.");
ClickCenter(send);
var confirmed = await WaitForNewMessageAsync(before, TimeSpan.FromSeconds(30), cancellationToken);
return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The attachment selection completed but no new visible message confirmed the send result.");
var confirmed = await WaitForNewMessageAsync(before, session,
asImage ? ChatMessageType.Image : ChatMessageType.File, asImage ? null : Path.GetFileName(fullPath),
TimeSpan.FromSeconds(30), cancellationToken);
return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The attachment selection completed but no matching new visible message confirmed the send result.");
}
finally
{
@@ -654,9 +671,13 @@ public static partial class WechatChatClient
}
private static async Task<ChatMessageSnapshot?> WaitForNewMessageAsync(
HashSet<string> previous,
IReadOnlySet<string> previous,
string session,
ChatMessageType type,
string? expectedText,
TimeSpan timeout,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
string? mentionMember = null)
{
var deadline = DateTimeOffset.UtcNow + timeout;
while (DateTimeOffset.UtcNow < deadline)
@@ -664,7 +685,12 @@ public static partial class WechatChatClient
cancellationToken.ThrowIfCancellationRequested();
using var automation = new UIA3Automation();
var window = WechatDoctor.FindWechatWindow(automation);
var message = window is null ? null : ReadVisible(window).LastOrDefault(item => !previous.Contains(item.Fingerprint));
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 message = window is null ? null : ReadVisible(window).LastOrDefault(item =>
mentionMember is null
? WechatSendConfirmation.Matches(item, previous, type, expectedText)
: WechatSendConfirmation.MatchesMention(item, previous, mentionMember, expectedText));
if (message is not null) return message;
await Task.Delay(250, cancellationToken);
}
@@ -690,7 +716,7 @@ public static partial class WechatChatClient
var match = window is null
? null
: ReadVisible(window).LastOrDefault(message =>
string.Equals(message.Text, text, StringComparison.Ordinal) && !baseline.Contains(message.Fingerprint));
string.Equals(message.Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal) && !baseline.Contains(message.Fingerprint));
if (match is not null)
{
return match;
@@ -714,16 +740,16 @@ public static partial class WechatChatClient
private static AutomationElement? FindByAutomationId(AutomationElement root, string automationId) =>
root.FindFirstDescendant(condition => condition.ByAutomationId(automationId));
private sealed class ClipboardFileLease : IDisposable
private sealed class ClipboardLease : IDisposable
{
private readonly ManualResetEventSlim _ready = new();
private readonly ManualResetEventSlim _restore = new();
private readonly Thread _thread;
private Exception? _error;
public ClipboardFileLease(string fullPath, bool asImage)
public ClipboardLease(string content, bool asImage = false, bool asText = false)
{
_thread = new Thread(() => Run(fullPath, asImage)) { IsBackground = true };
_thread = new Thread(() => Run(content, asImage, asText)) { IsBackground = true };
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
if (!_ready.Wait(TimeSpan.FromSeconds(5)))
@@ -745,7 +771,7 @@ public static partial class WechatChatClient
_restore.Dispose();
}
private void Run(string fullPath, bool asImage)
private void Run(string content, bool asImage, bool asText)
{
string? text = null;
StringCollection? files = null;
@@ -756,15 +782,19 @@ public static partial class WechatChatClient
if (Clipboard.ContainsFileDropList()) files = Clipboard.GetFileDropList();
else if (Clipboard.ContainsImage()) image = (System.Drawing.Image?)Clipboard.GetImage()?.Clone();
else if (Clipboard.ContainsText()) text = Clipboard.GetText();
if (asImage)
if (asText)
{
using var source = System.Drawing.Image.FromFile(fullPath);
Clipboard.SetText(content);
}
else if (asImage)
{
using var source = System.Drawing.Image.FromFile(content);
outgoingImage = new System.Drawing.Bitmap(source);
Clipboard.SetImage(outgoingImage);
}
else
{
Clipboard.SetFileDropList(new StringCollection { fullPath });
Clipboard.SetFileDropList(new StringCollection { content });
}
}
catch (Exception exception)
@@ -798,7 +828,7 @@ public static partial class WechatChatClient
{
if (_error is not null)
{
throw new WxAgentException(WxAgentErrorCode.ClipboardUnavailable, "The Windows clipboard could not be used for attachment sending.", _error);
throw new WxAgentException(WxAgentErrorCode.ClipboardUnavailable, "The Windows clipboard could not be used for input.", _error);
}
}
}