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;
}
}