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