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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user