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
+26 -3
View File
@@ -31,6 +31,9 @@ public sealed record LocalRemoteTaskRecord
[JsonPropertyName("reported")]
public bool Reported { get; init; }
[JsonPropertyName("resultReportErrorCode")]
public string? ResultReportErrorCode { get; init; }
[JsonPropertyName("updatedAt")]
public DateTimeOffset UpdatedAt { get; init; }
}
@@ -136,6 +139,7 @@ public sealed class RemoteTaskLedger
Result = scopes.Length == 0 ? result with { Content = null } : result,
ReportingScopes = scopes,
Reported = false,
ResultReportErrorCode = null,
UpdatedAt = DateTimeOffset.UtcNow
};
SaveLocked();
@@ -147,7 +151,7 @@ public sealed class RemoteTaskLedger
lock (_gate)
{
return _records.Values
.Where(record => !record.Reported && record.Result is not null)
.Where(record => !record.Reported && record.Result is not null && record.ResultReportErrorCode is null)
.Select(record => record.Result!)
.ToArray();
}
@@ -158,19 +162,37 @@ public sealed class RemoteTaskLedger
lock (_gate)
{
return _records.Values
.Where(record => !record.Reported && record.Result is not null)
.Where(record => !record.Reported && record.Result is not null && record.ResultReportErrorCode is null)
.Select(record => new UnreportedRemoteTask(record.Result!, record.ReportingScopes))
.ToArray();
}
}
public int UnreportableResultCount()
{
lock (_gate)
return _records.Values.Count(record => !record.Reported && record.Result is not null && record.ResultReportErrorCode is not null);
}
public void MarkReported(string taskId)
{
lock (_gate)
{
if (_records.TryGetValue(taskId, out var existing) && existing.Result is not null)
{
_records[taskId] = existing with { Reported = true, UpdatedAt = DateTimeOffset.UtcNow };
_records[taskId] = existing with { Reported = true, ResultReportErrorCode = null, UpdatedAt = DateTimeOffset.UtcNow };
SaveLocked();
}
}
}
public void MarkResultTaskNotFound(string taskId)
{
lock (_gate)
{
if (_records.TryGetValue(taskId, out var existing) && existing.Result is not null && !existing.Reported)
{
_records[taskId] = existing with { ResultReportErrorCode = "TaskNotFound", UpdatedAt = DateTimeOffset.UtcNow };
SaveLocked();
}
}
@@ -190,6 +212,7 @@ public sealed class RemoteTaskLedger
Result = new RemoteTaskResult(pair.Value.TaskId, pair.Value.AccountId, pair.Value.LeaseGeneration,
RemoteTaskStatus.ResultUnconfirmed, "AgentRestartedWithIncompleteTask", "Execution was not replayed.", true, null, Guid.NewGuid().ToString("N")),
Reported = false,
ResultReportErrorCode = null,
UpdatedAt = DateTimeOffset.UtcNow
};
changed = true;
@@ -0,0 +1,37 @@
namespace WxAgent.Core;
public static class WechatAttachmentInput
{
public const int MaxBatchSize = 10;
private static readonly HashSet<string> ImageExtensions = new([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"], StringComparer.OrdinalIgnoreCase);
public static bool IsImagePath(string? path) =>
!string.IsNullOrWhiteSpace(path) && ImageExtensions.Contains(Path.GetExtension(path));
public static IReadOnlyList<string> PrepareFilePaths(IReadOnlyList<string>? paths)
{
if (paths is null || paths.Count is < 2 or > MaxBatchSize)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"File batches must contain 2-{MaxBatchSize} attachments.");
var normalized = new string[paths.Count];
for (var index = 0; index < paths.Count; index++)
{
var path = paths[index];
if (string.IsNullOrWhiteSpace(path))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Attachment paths must not be empty.");
string fullPath;
try { fullPath = Path.GetFullPath(path); }
catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException)
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "An attachment path is invalid.");
}
if (!File.Exists(fullPath))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "An attachment file does not exist.");
normalized[index] = fullPath;
}
return normalized;
}
}
@@ -0,0 +1,25 @@
namespace WxAgent.Core;
public static class WechatMentionOptionSelection
{
public static int FindUniqueVisibleOptionIndex(
IReadOnlyList<string?> groupMemberDisplayNames,
IReadOnlyList<(string? Name, bool IsOffscreen, bool HasBounds)> options,
string requestedName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(requestedName);
if (groupMemberDisplayNames.Count(name => string.Equals(name, requestedName, StringComparison.Ordinal)) != 1)
return -1;
var match = -1;
for (var index = 0; index < options.Count; index++)
{
var option = options[index];
if (!string.Equals(option.Name, requestedName, StringComparison.Ordinal)) continue;
if (match >= 0 || option.IsOffscreen || !option.HasBounds) return -1;
match = index;
}
return match;
}
}
@@ -4,10 +4,16 @@ public static class WechatSendConfirmation
{
public static bool IsMentionToken(string text) => text.TrimEnd() == "\uFFFC";
public static bool MatchesMention(ChatMessageSnapshot message, IReadOnlySet<string> baseline,
public static bool IsNewOccurrence(string fingerprint,
IReadOnlyDictionary<string, int> baselineCounts, IReadOnlyDictionary<string, int> currentCounts) =>
currentCounts.TryGetValue(fingerprint, out var currentCount)
&& currentCount > (baselineCounts.TryGetValue(fingerprint, out var baselineCount) ? baselineCount : 0);
public static bool MatchesMention(ChatMessageSnapshot message,
IReadOnlyDictionary<string, int> baselineCounts, IReadOnlyDictionary<string, int> currentCounts,
string memberName, string? body)
{
if (baseline.Contains(message.Fingerprint) || message.Type != ChatMessageType.Text) return false;
if (!IsNewOccurrence(message.Fingerprint, baselineCounts, currentCounts) || message.Type != ChatMessageType.Text) return false;
var expected = "@" + memberName + (string.IsNullOrEmpty(body) ? "" : " " + body);
return string.Equals(NormalizeWhitespace(message.Text), NormalizeWhitespace(expected), StringComparison.Ordinal);
}
@@ -16,10 +22,11 @@ public static class WechatSendConfirmation
string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
// UIA proves only a fresh matching visible row, not delivery or sender identity.
public static bool Matches(ChatMessageSnapshot message, IReadOnlySet<string> baseline,
public static bool Matches(ChatMessageSnapshot message,
IReadOnlyDictionary<string, int> baselineCounts, IReadOnlyDictionary<string, int> currentCounts,
ChatMessageType type, string? expectedText)
{
if (baseline.Contains(message.Fingerprint) || message.Type != type) return false;
if (!IsNewOccurrence(message.Fingerprint, baselineCounts, currentCounts) || message.Type != type) return false;
if (expectedText is null) return true;
return type == ChatMessageType.File
? message.Text.Split('\n').Any(line => string.Equals(line.TrimEnd('\r'), expectedText, StringComparison.Ordinal))
+42 -2
View File
@@ -1,14 +1,54 @@
using System.Globalization;
namespace WxAgent.Core;
public static class WechatTextInput
{
public const int MaxSegmentLength = 4000;
public const int MaxTextLength = 20_000;
public static string Prepare(string text)
{
var normalized = PrepareLongText(text);
if (normalized.Length > MaxSegmentLength)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Message text must not exceed {MaxSegmentLength} characters.");
return normalized;
}
public static string PrepareLongText(string text)
{
if (string.IsNullOrWhiteSpace(text))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Message text must not be empty.");
var normalized = text.ReplaceLineEndings("\n");
if (normalized.Length > 4000 || normalized.Any(character => char.IsControl(character) && character is not ('\n' or '\t')))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Message text must not exceed 4000 characters or contain unsupported control characters.");
if (normalized.Length > MaxTextLength || normalized.Any(character => char.IsControl(character) && character is not ('\n' or '\t')))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Message text must not exceed {MaxTextLength} characters or contain unsupported control characters.");
return normalized;
}
public static IReadOnlyList<string> SplitForSend(string text)
{
var normalized = PrepareLongText(text);
if (normalized.Length <= MaxSegmentLength)
return [normalized];
var textElementStarts = StringInfo.ParseCombiningCharacters(normalized);
var segments = new List<string>();
var start = 0;
while (start < normalized.Length)
{
var end = Math.Min(start + MaxSegmentLength, normalized.Length);
if (end < normalized.Length)
{
var boundaryIndex = Array.BinarySearch(textElementStarts, end);
if (boundaryIndex < 0) boundaryIndex = ~boundaryIndex - 1;
if (boundaryIndex < 0 || textElementStarts[boundaryIndex] <= start)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "A text element exceeds the per-message length limit.");
end = textElementStarts[boundaryIndex];
}
segments.Add(normalized[start..end]);
start = end;
}
return segments;
}
}
+33 -6
View File
@@ -309,6 +309,14 @@ try
WriteJson(result);
return result.Success ? 0 : 2;
}
case "chat" when args[1] == "send-files":
{
var paths = GetOptions(args, "--path");
var session = GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant;
var messages = await WechatChatClient.SendFilesAsync(paths, cancellationToken, session);
WriteJson(new { requestedCount = messages.Count, confirmedCount = messages.Count });
return 0;
}
case "chat" when args[1] is "send-file" or "send-image":
{
var path = GetRequiredOption(args, "--path");
@@ -793,10 +801,16 @@ static int ValidateCommandLine(string[] values)
return 60;
case "send":
ValidateOptions(values, 2, ["--text", "--session", "--timeout"], []);
return 30;
return 120;
case "reply-latest":
ValidateOptions(values, 2, ["--text", "--session", "--timeout"], []);
return 60;
case "send-files":
ValidateOptions(values, 2, ["--path", "--session", "--timeout"], [], ["--path"]);
var pathCount = GetOptions(values, "--path").Count;
if (pathCount is < 2 or > WechatAttachmentInput.MaxBatchSize)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"send-files requires 2-{WechatAttachmentInput.MaxBatchSize} --path values.");
return checked(pathCount * 40 + 30);
case "send-file":
case "send-image":
ValidateOptions(values, 2, ["--path", "--session", "--timeout"], []);
@@ -888,13 +902,13 @@ static int ValidateCommandLine(string[] values)
}
}
static void ValidateOptions(string[] values, int startIndex, HashSet<string> valueOptions, HashSet<string> flagOptions)
static void ValidateOptions(string[] values, int startIndex, HashSet<string> valueOptions, HashSet<string> flagOptions, HashSet<string>? repeatableValueOptions = null)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
for (var index = startIndex; index < values.Length; index++)
{
var option = values[index];
if (!seen.Add(option))
if (!seen.Add(option) && repeatableValueOptions?.Contains(option) != true)
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Duplicate option {option}.");
}
@@ -924,6 +938,19 @@ static string? GetOption(string[] values, string name)
: null;
}
static IReadOnlyList<string> GetOptions(string[] values, string name)
{
var options = new List<string>();
for (var index = 0; index + 1 < values.Length; index++)
{
if (string.Equals(values[index], name, StringComparison.Ordinal))
{
options.Add(values[++index]);
}
}
return options;
}
static string GetRequiredOption(string[] values, string name) =>
GetOption(values, name) ?? throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Missing required option {name}.");
@@ -993,8 +1020,7 @@ static int GetPositiveIntOption(string[] values, string name, int fallback, int
return parsed;
}
static bool IsImagePath(string path) => new[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp" }
.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase);
static bool IsImagePath(string path) => WechatAttachmentInput.IsImagePath(path);
static object ToEventOutput(MessageEvent messageEvent, bool includeContent) => new
{
@@ -1041,10 +1067,11 @@ WxAgent.Tray.exe must be started by double-click; use its 服务设置... window
group verify-at-all --group <name> --message <text> [--timeout 30]
chat send-url-card --to <name> --url <http-url> [--message <text>] [--timeout 60]
chat send-audio --to <name> --path <audio-file> --confirm CONFIRM [--timeout 60]
chat send --text <text-up-to-4000-chars; multiline-supported> [--session <name>] [--timeout 30]
chat send --text <text-up-to-20000-chars; split-into-4000-char-messages; multiline-supported> [--session <name>] [--timeout 120]
chat reply-latest --text <text-up-to-4000-chars; multiline-supported> [--session <name>] [--timeout 60]
chat send-file --path <file> [--session <name>] [--timeout 60]
chat send-image --path <image> [--session <name>] [--timeout 60]
chat send-files --path <file> --path <file> ... [--session <name>] [--timeout <seconds>] (2-10; images sent as images)
chat mention --session <name> --member <display-name> --text <text> --confirm CONFIRM [--timeout 60]
chat read [--limit 20] [--include-content] [--timeout 30]
chat history [--session <name>] [--limit 100] [--scrolls 10] [--include-content] [--timeout 60]
@@ -67,6 +67,7 @@ internal static class RemoteCliCommands
configured = configuration.Remote.IsConfigured,
reporting = new { configuration.Reporting.Enabled, configuration.Reporting.ConfigVersion, pendingEvents = eventQueue.PendingCount },
unreportedTaskResults = ledger.UnreportedResults().Count,
unreportableTaskResults = ledger.UnreportableResultCount(),
audit = configuration.Audit.TakeLast(20)
};
}
+9 -1
View File
@@ -138,7 +138,9 @@ public sealed class AgentService(IAgentBackend backend, ServiceSecurity security
{
var kind = RequireText(request.Kind, "kind", 80);
var accountId = RequireText(request.AccountId, "accountId", 200);
var text = PrepareText(request.Text);
var text = string.Equals(kind, "send-text", StringComparison.Ordinal)
? PrepareLongText(request.Text)
: PrepareText(request.Text);
var idempotencyKey = RequireText(request.IdempotencyKey, "idempotencyKey", 128);
if (!request.Confirmed) throw new ServiceException("ConfirmationRequired", 409, "Explicit send confirmation is required.");
@@ -271,6 +273,12 @@ public sealed class AgentService(IAgentBackend backend, ServiceSecurity security
return value;
}
private static string PrepareLongText(string? value)
{
try { return WechatTextInput.PrepareLongText(value ?? string.Empty); }
catch (WxAgentException exception) { throw new ServiceException("InvalidRequest", 400, exception.Message); }
}
private static string PrepareText(string? value)
{
try { return WechatTextInput.Prepare(value ?? string.Empty); }
@@ -841,7 +841,7 @@ public sealed class RemoteAgentHostedService(
JsonElement Content,
IReadOnlyList<RemoteReportingScope> ChatScopes);
private static async Task ReplayUnreportedResultsAsync(
private async Task ReplayUnreportedResultsAsync(
RemoteControlClient client,
RemoteTaskLedger ledger,
ReportingConfig reporting,
@@ -851,7 +851,7 @@ public sealed class RemoteAgentHostedService(
await ReportResultAsync(client, ledger, pending.Result, reporting, cancellationToken, pending.ReportingScopes);
}
private static async Task ReportResultAsync(
private async Task ReportResultAsync(
RemoteControlClient client,
RemoteTaskLedger ledger,
RemoteTaskResult result,
@@ -859,8 +859,16 @@ public sealed class RemoteAgentHostedService(
CancellationToken cancellationToken,
IReadOnlyList<RemoteReportingScope>? chatScopes = null)
{
await client.SendTaskResultAsync(result, reporting, chatScopes: chatScopes, cancellationToken: cancellationToken);
ledger.MarkReported(result.TaskId);
try
{
await client.SendTaskResultAsync(result, reporting, chatScopes: chatScopes, cancellationToken: cancellationToken);
ledger.MarkReported(result.TaskId);
}
catch (RemoteClientException exception) when (exception.StatusCode == 404 && exception.Code == "TaskNotFound")
{
ledger.MarkResultTaskNotFound(result.TaskId);
logger.LogWarning("Remote task result no longer exists on the control plane; result retained locally and will not be retried.");
}
}
private async Task<BackendSnapshot> ReadSnapshotAsync(RemoteAgentOptions remote, CancellationToken cancellationToken)
@@ -972,8 +980,16 @@ public sealed class RemoteAgentHostedService(
|| confirmed.ValueKind != JsonValueKind.True)
return false;
targetId = target.GetString() ?? "";
text = content.GetString() ?? "";
return targetId.Length is > 0 and <= 512 && text.Length is > 0 and <= 4000;
if (targetId.Length is 0 or > 512) return false;
try
{
text = WechatTextInput.PrepareLongText(content.GetString() ?? "");
return true;
}
catch (WxAgentException)
{
return false;
}
}
private static bool GetBoolean(JsonElement value, string propertyName) =>
@@ -50,7 +50,7 @@
<label class="toggle"><input id="content" type="checkbox"> 显示正文 <span class="muted">需要 content 权限</span></label>
</section>
<section class="panel"><div class="section-heading"><h2>会话</h2><span class="muted">当前账号</span></div><div id="sessions" class="list"></div></section>
<section class="panel reply-panel"><div class="section-heading"><h2>回复</h2><span id="replyCapability" class="muted">发送能力检查中…</span></div><div class="reply-form"><label for="replyTarget">目标会话</label><select id="replyTarget"><option value="">先选择会话</option></select><label for="replyText">文本</label><textarea id="replyText" rows="4" maxlength="4000" placeholder="输入要发送的文本" disabled></textarea><div class="reply-actions"><button id="sendReply" disabled>发送</button><span id="replyState" class="muted">写能力尚未开放。</span></div></div></section>
<section class="panel reply-panel"><div class="section-heading"><h2>回复</h2><span id="replyCapability" class="muted">发送能力检查中…</span></div><div class="reply-form"><label for="replyTarget">目标会话</label><select id="replyTarget"><option value="">先选择会话</option></select><label for="replyText">文本</label><textarea id="replyText" rows="4" maxlength="20000" placeholder="输入要发送的文本" disabled></textarea><div class="reply-actions"><button id="sendReply" disabled>发送</button><span id="replyState" class="muted">写能力尚未开放。</span></div></div></section>
<section class="panel"><div class="section-heading"><h2>可见消息</h2><span class="muted">只显示当前可读取的数据</span></div><div id="messages" class="list"></div></section>
</section>
@@ -277,6 +277,14 @@ public static partial class WechatChatClient
if (memberName.Any(char.IsControl) || memberName.Length > 100 ||
message is not null && (message.Any(char.IsControl) || message.Length > 4000))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Mention names and text must be single-line, with no control characters.");
var groupMemberNames = new[] { memberName };
if (!string.Equals(memberName, "所有人", StringComparison.Ordinal))
{
var members = await GetGroupMembersPageAsync(session, limit: 10000, offset: 0, cancellationToken: cancellationToken).ConfigureAwait(false);
if (members.HasMore)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The group member list exceeds the safe mention-selection bound; no send was attempted.");
groupMemberNames = members.Members.Select(member => member.DisplayName).ToArray();
}
await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false);
var input = FindByAutomationId(main, WechatLocators.ChatInput)
?? throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Chat input was not found.");
@@ -284,7 +292,7 @@ public static partial class WechatChatClient
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before mention input.");
if (!string.IsNullOrEmpty(input.AsTextBox().Text))
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The conversation contains an existing draft; it was not overwritten.");
var baseline = ReadVisible(main).Select(item => item.Fingerprint).ToHashSet(StringComparer.Ordinal);
var baseline = CountFingerprints(ReadVisible(main));
if (main.FindAllDescendants(cf => cf.ByAutomationId(WechatLocators.MentionPopover))
.Any(element => !element.Properties.IsOffscreen.ValueOrDefault))
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "An existing mention popup must be dismissed before starting a new operation.");
@@ -299,14 +307,17 @@ public static partial class WechatChatClient
var lists = popovers[0].FindAllDescendants(cf => cf.ByAutomationId(WechatLocators.MentionList));
if (lists.Length != 1)
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "A unique mention list was not found; no send was attempted.");
var options = lists[0].FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem).And(cf.ByName(memberName)))
.Where(element => !element.Properties.IsOffscreen.ValueOrDefault && !element.BoundingRectangle.IsEmpty)
.ToArray();
if (options.Length != 1)
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "A unique mention option in a fresh WeChat popup was not found; no send was attempted.");
var options = lists[0].FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem));
var optionSnapshots = options.Select(element => (
Name: (string?)SafeName(element),
IsOffscreen: element.Properties.IsOffscreen.ValueOrDefault,
HasBounds: !element.BoundingRectangle.IsEmpty)).ToArray();
var optionIndex = WechatMentionOptionSelection.FindUniqueVisibleOptionIndex(groupMemberNames, optionSnapshots, memberName);
if (optionIndex < 0)
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "A unique visible mention option was not found; no send was attempted.");
if (!IsListeningSession(main, session, independent: false))
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before mention selection.");
ClickCenter(options[0]);
ClickCenter(options[optionIndex]);
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
input = FindByAutomationId(main, WechatLocators.ChatInput)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Chat input disappeared after mention selection.");
+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)