feat: support long text and safe attachment batches
Build web service image / build (push) Successful in 1m15s
Build web service image / build (push) Successful in 1m15s
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user