feat: support long text and safe attachment batches
Build web service image / build (push) Successful in 1m15s

This commit is contained in:
2026-09-30 09:16:36 +08:00
parent a977902b25
commit edba7d0d38
26 changed files with 720 additions and 82 deletions
+111 -37
View File
@@ -112,7 +112,7 @@ public static partial class WechatChatClient
public static async Task<ChatMessageSnapshot> SendTextAsync(string text, CancellationToken cancellationToken,
string session = WechatLocators.FileTransferAssistant)
{
text = WechatTextInput.Prepare(text);
var segments = WechatTextInput.SplitForSend(text);
ArgumentException.ThrowIfNullOrWhiteSpace(session);
await CommandQueue.WaitAsync(cancellationToken);
@@ -122,30 +122,56 @@ public static partial class WechatChatClient
var window = AttachWindow(automation, 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);
ChatMessageSnapshot? lastConfirmed = null;
var input = FindByAutomationId(window, WechatLocators.ChatInput)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} 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 (!IsListeningSession(window, session, independent: false) || !string.Equals(inputBox.Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal))
for (var index = 0; index < segments.Count; index++)
{
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The chat input did not contain the exact requested text; send was aborted.");
var sendAttempted = false;
try
{
if (index > 0)
window = AttachWindow(automation, cancellationToken);
if (!IsListeningSession(window, session, independent: false))
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, $"The requested conversation changed before text segment {index + 1}/{segments.Count}.");
var segment = segments[index];
var baseline = CountFingerprints(ReadVisible(window));
var input = FindByAutomationId(window, WechatLocators.ChatInput)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} 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 = segment);
await Task.Delay(150, cancellationToken);
if (!IsListeningSession(window, session, independent: false) || !string.Equals(inputBox.Text.ReplaceLineEndings("\n"), segment, StringComparison.Ordinal))
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, $"Text segment {index + 1}/{segments.Count} did not appear exactly in the chat input; 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.");
sendAttempted = true;
ExecuteInputStep("click-send", () => ClickCenter(send));
lastConfirmed = await WaitForMessageAsync(segment, session, baseline, TimeSpan.FromSeconds(20), cancellationToken);
if (lastConfirmed is null)
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed,
$"Text segment {index + 1}/{segments.Count} was not confirmed; earlier segments may already have been sent. Do not retry blindly.");
}
catch (Exception exception) when (sendAttempted && exception is not OutOfMemoryException)
{
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed,
$"Text segment {index + 1}/{segments.Count} send result is uncertain; it may have been delivered and {index} earlier segment(s) were confirmed. Do not retry the whole text blindly.");
}
catch (Exception exception) when (index > 0 && exception is not OutOfMemoryException)
{
var cause = exception is WxAgentException agentException ? agentException.Code.ToString() : exception.GetType().Name;
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed,
$"Text sending stopped after {index} confirmed segment(s) (cause {cause}); earlier text may have been delivered. Do not retry the whole text blindly.");
}
}
// 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, 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.");
return lastConfirmed!;
}
finally
{
@@ -169,7 +195,7 @@ public static partial class WechatChatClient
{
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested conversation changed before quoting.");
}
var before = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal);
var before = CountFingerprints(ReadVisible(window));
var list = FindByAutomationId(window, WechatLocators.MessageList)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found.");
var draft = FindByAutomationId(window, WechatLocators.ChatInput)
@@ -229,6 +255,34 @@ public static partial class WechatChatClient
public static Task<ChatMessageSnapshot> SendImageAsync(string path, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) =>
SendAttachmentAsync(path, asImage: true, session, cancellationToken);
public static async Task<IReadOnlyList<ChatMessageSnapshot>> SendFilesAsync(
IReadOnlyList<string>? paths, CancellationToken cancellationToken,
string session = WechatLocators.FileTransferAssistant)
{
ArgumentException.ThrowIfNullOrWhiteSpace(session);
var fullPaths = WechatAttachmentInput.PrepareFilePaths(paths);
var confirmed = new List<ChatMessageSnapshot>(fullPaths.Count);
foreach (var fullPath in fullPaths)
{
try
{
confirmed.Add(await SendAttachmentAsync(fullPath, WechatAttachmentInput.IsImagePath(fullPath), session, cancellationToken).ConfigureAwait(false));
}
catch (WxAgentException exception) when (confirmed.Count > 0)
{
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed,
$"Attachment batch stopped after {confirmed.Count} confirmed item(s) (cause {exception.Code}); one or more attachments may already have been sent. Do not retry the whole batch blindly.");
}
catch (OperationCanceledException) when (confirmed.Count > 0)
{
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed,
$"Attachment batch was cancelled after {confirmed.Count} confirmed item(s); one or more attachments may already have been sent. Do not retry the whole batch blindly.");
}
}
return confirmed;
}
private static async Task<ChatMessageSnapshot> SendAttachmentAsync(string path, bool asImage, string session, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(session);
@@ -249,7 +303,7 @@ public static partial class WechatChatClient
{
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested conversation changed before attachment input.");
}
var before = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal);
var before = CountFingerprints(ReadVisible(window));
var input = FindByAutomationId(window, WechatLocators.ChatInput)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found.");
var inputText = input.AsTextBox().Text;
@@ -275,11 +329,20 @@ public static partial class WechatChatClient
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, 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.");
try
{
ClickCenter(send);
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 may have been sent, but no matching new visible message confirmed it. Do not retry blindly.");
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed,
"The attachment send was attempted, but its result could not be confirmed. It may have been delivered; do not retry blindly.");
}
}
finally
{
@@ -657,7 +720,7 @@ public static partial class WechatChatClient
}
private static async Task<ChatMessageSnapshot?> WaitForNewMessageAsync(
IReadOnlySet<string> previous,
IReadOnlyDictionary<string, int> previousCounts,
string session,
ChatMessageType type,
string? expectedText,
@@ -673,10 +736,14 @@ public static partial class WechatChatClient
var window = WechatDoctor.FindWechatWindow(automation);
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 =>
IReadOnlyList<ChatMessageSnapshot> visible = window is null
? Array.Empty<ChatMessageSnapshot>()
: ReadVisible(window);
var currentCounts = CountFingerprints(visible);
var message = visible.LastOrDefault(item =>
mentionMember is null
? WechatSendConfirmation.Matches(item, previous, type, expectedText)
: WechatSendConfirmation.MatchesMention(item, previous, mentionMember, expectedText));
? WechatSendConfirmation.Matches(item, previousCounts, currentCounts, type, expectedText)
: WechatSendConfirmation.MatchesMention(item, previousCounts, currentCounts, mentionMember, expectedText));
if (message is not null) return message;
await Task.Delay(250, cancellationToken);
}
@@ -687,7 +754,7 @@ public static partial class WechatChatClient
private static async Task<ChatMessageSnapshot?> WaitForMessageAsync(
string text,
string session,
IReadOnlySet<string> baseline,
IReadOnlyDictionary<string, int> baseline,
TimeSpan timeout,
CancellationToken cancellationToken)
{
@@ -699,10 +766,13 @@ public static partial class WechatChatClient
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.ReplaceLineEndings("\n"), text, StringComparison.Ordinal) && !baseline.Contains(message.Fingerprint));
IReadOnlyList<ChatMessageSnapshot> visible = window is null
? Array.Empty<ChatMessageSnapshot>()
: ReadVisible(window);
var visibleCounts = CountFingerprints(visible);
var match = visible.LastOrDefault(message =>
string.Equals(message.Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal)
&& WechatSendConfirmation.IsNewOccurrence(message.Fingerprint, baseline, visibleCounts));
if (match is not null)
{
return match;
@@ -714,6 +784,10 @@ public static partial class WechatChatClient
return null;
}
private static Dictionary<string, int> CountFingerprints(IEnumerable<ChatMessageSnapshot> messages) =>
messages.GroupBy(message => message.Fingerprint, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
private static IReadOnlyList<ChatMessageSnapshot> ReadVisible(AutomationElement window)
{
var list = FindByAutomationId(window, WechatLocators.MessageList)