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
@@ -334,6 +334,21 @@ public sealed class RemoteQueueAndLedgerTests
var scope = Assert.Single(pending.ReportingScopes);
Assert.Equal("chat-a", scope.ChatId);
Assert.Equal(ReportingChatType.Private, scope.ChatType);
reloaded.MarkResultTaskNotFound(pending.Result.TaskId);
Assert.Empty(reloaded.UnreportedResults());
Assert.Empty(reloaded.UnreportedResultsWithScopes());
Assert.Equal(1, reloaded.UnreportableResultCount());
Assert.True(reloaded.TryGet("task-read", out var retained));
Assert.NotNull(retained);
Assert.False(retained.Reported);
Assert.Equal("TaskNotFound", retained.ResultReportErrorCode);
Assert.NotNull(retained.Result?.Content);
Assert.Single(retained.ReportingScopes);
var terminal = new RemoteTaskLedger(path);
Assert.Equal(1, terminal.UnreportableResultCount());
Assert.Empty(terminal.UnreportedResultsWithScopes());
}
finally { directory.Delete(true); }
}
@@ -0,0 +1,62 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatAttachmentInputTests
{
[Fact]
public void PrepareFilePathsRejectsOutOfRangeBatchesBeforeAnySend()
{
var single = Assert.Throws<WxAgentException>(() => WechatAttachmentInput.PrepareFilePaths(["one.txt"]));
var tooMany = Assert.Throws<WxAgentException>(() => WechatAttachmentInput.PrepareFilePaths(
Enumerable.Repeat("unused.txt", WechatAttachmentInput.MaxBatchSize + 1).ToArray()));
Assert.Equal(WxAgentErrorCode.InvalidArgument, single.Code);
Assert.Equal(WxAgentErrorCode.InvalidArgument, tooMany.Code);
}
[Fact]
public void PrepareFilePathsValidatesEveryFileBeforeReturningCanonicalPaths()
{
var directory = Directory.CreateTempSubdirectory();
try
{
var first = Path.Combine(directory.FullName, "first.pdf");
var second = Path.Combine(directory.FullName, "second.png");
File.WriteAllText(first, "first");
File.WriteAllText(second, "second");
var paths = WechatAttachmentInput.PrepareFilePaths([first, second]);
Assert.Equal([Path.GetFullPath(first), Path.GetFullPath(second)], paths);
Assert.True(WechatAttachmentInput.IsImagePath(second));
Assert.True(WechatAttachmentInput.IsImagePath("photo.JPEG"));
Assert.False(WechatAttachmentInput.IsImagePath(first));
}
finally
{
directory.Delete(recursive: true);
}
}
[Fact]
public void PrepareFilePathsRejectsASecondMissingFileBeforeSendingTheFirst()
{
var directory = Directory.CreateTempSubdirectory();
try
{
var first = Path.Combine(directory.FullName, "first.pdf");
File.WriteAllText(first, "first");
var exception = Assert.Throws<WxAgentException>(() => WechatAttachmentInput.PrepareFilePaths(
[first, Path.Combine(directory.FullName, "missing.pdf")]));
Assert.Equal(WxAgentErrorCode.InvalidArgument, exception.Code);
}
finally
{
directory.Delete(recursive: true);
}
}
}
@@ -24,8 +24,10 @@ public sealed class WechatMentionConfirmationTests
public void RequiresFreshExactMentionAndBody(string text, string member, bool expected)
{
var message = new ChatMessageSnapshot(text, "fresh", 0, ChatMessageType.Text);
Assert.Equal(expected, WechatSendConfirmation.MatchesMention(message, new HashSet<string>(), member, "marker"));
Assert.False(WechatSendConfirmation.MatchesMention(message, new HashSet<string> { "fresh" }, member, "marker"));
Assert.False(WechatSendConfirmation.MatchesMention(message with { Type = ChatMessageType.Quote }, new HashSet<string>(), member, "marker"));
var empty = new Dictionary<string, int>();
var current = new Dictionary<string, int> { ["fresh"] = 1 };
Assert.Equal(expected, WechatSendConfirmation.MatchesMention(message, empty, current, member, "marker"));
Assert.False(WechatSendConfirmation.MatchesMention(message, current, current, member, "marker"));
Assert.False(WechatSendConfirmation.MatchesMention(message with { Type = ChatMessageType.Quote }, empty, current, member, "marker"));
}
}
@@ -0,0 +1,40 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatMentionOptionSelectionTests
{
[Fact]
public void SelectsOnlyAUniqueVisibleOptionForAUniqueDatabaseDisplayName()
{
string?[] members = ["Alice", "Bob"];
(string? Name, bool IsOffscreen, bool HasBounds)[] options =
[
("Alice", false, true),
("Bob", false, true)
];
Assert.Equal(0, WechatMentionOptionSelection.FindUniqueVisibleOptionIndex(members, options, "Alice"));
}
[Fact]
public void RejectsDatabaseDuplicatesEvenWhenOnlyOneCandidateIsVisible()
{
string?[] members = ["Alice", "Alice"];
(string? Name, bool IsOffscreen, bool HasBounds)[] options = [("Alice", false, true)];
Assert.Equal(-1, WechatMentionOptionSelection.FindUniqueVisibleOptionIndex(members, options, "Alice"));
}
[Fact]
public void RejectsOffscreenOrDuplicateUiCandidates()
{
string?[] members = ["Alice"];
(string? Name, bool IsOffscreen, bool HasBounds)[] offscreen = [("Alice", true, false)];
(string? Name, bool IsOffscreen, bool HasBounds)[] duplicate = [("Alice", false, true), ("Alice", true, false)];
Assert.Equal(-1, WechatMentionOptionSelection.FindUniqueVisibleOptionIndex(members, offscreen, "Alice"));
Assert.Equal(-1, WechatMentionOptionSelection.FindUniqueVisibleOptionIndex(members, duplicate, "Alice"));
}
}
@@ -37,6 +37,27 @@ public sealed class WechatMergedChatTests
Assert.DoesNotContain("must-not-be-exported", JsonSerializer.Serialize(result));
}
[Fact]
public void PreservesMixedImageVideoFileAndNestedAttachmentMetadata()
{
static XElement Attachment(int type, string title, string format, long size) => new("dataitem",
new XAttribute("datatype", type), new XElement("datatitle", title), new XElement("datafmt", format), new XElement("datasize", size));
var image = Attachment(2, "image.jpg", "jpg", 12);
var video = Attachment(4, "clip.mp4", "mp4", 34);
var file = Attachment(8, "notes.pdf", "pdf", 56);
var nested = new XElement("dataitem", new XAttribute("datatype", 17), Record(Attachment(2, "nested.png", "png", 78)));
var result = WechatMergedChatParser.Parse(Record(image, video, file, nested).ToString());
Assert.Equal([2, 4, 8, 17], result.Messages.Select(message => message.DataType));
Assert.Equal([12L, 34L, 56L], result.Messages.Take(3).Select(message => message.SizeBytes));
Assert.Equal(["image.jpg", "clip.mp4", "notes.pdf"], result.Messages.Take(3).Select(message => message.Title));
var nestedMessage = result.Messages[3].NestedRecord!.Messages[0];
Assert.Equal("3/0", nestedMessage.Path);
Assert.Equal(2, nestedMessage.DataType);
}
[Fact]
public void RejectsTruncationMissingPayloadAndInvalidTimestamp()
{
@@ -0,0 +1,19 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatSendConfirmationOccurrenceTests
{
[Fact]
public void IdenticalFingerprintIsNewOnlyWhenItsVisibleCountIncreases()
{
var baseline = new Dictionary<string, int> { ["same-message"] = 1 };
var unchanged = new Dictionary<string, int> { ["same-message"] = 1 };
var appended = new Dictionary<string, int> { ["same-message"] = 2 };
Assert.False(WechatSendConfirmation.IsNewOccurrence("same-message", baseline, unchanged));
Assert.True(WechatSendConfirmation.IsNewOccurrence("same-message", baseline, appended));
Assert.False(WechatSendConfirmation.IsNewOccurrence("other-message", baseline, appended));
}
}
@@ -8,12 +8,13 @@ public sealed class WechatSendConfirmationTests
[Fact]
public void RequiresFreshMatchingTypeAndExactReplyText()
{
var baseline = new HashSet<string> { "old" };
var baseline = new Dictionary<string, int> { ["old"] = 1 };
var current = new Dictionary<string, int> { ["old"] = 1, ["new"] = 1 };
var reply = new ChatMessageSnapshot("reply", "new", 0, ChatMessageType.Quote);
Assert.True(WechatSendConfirmation.Matches(reply, baseline, ChatMessageType.Quote, "reply"));
Assert.False(WechatSendConfirmation.Matches(reply with { Fingerprint = "old" }, baseline, ChatMessageType.Quote, "reply"));
Assert.False(WechatSendConfirmation.Matches(reply with { Type = ChatMessageType.Text }, baseline, ChatMessageType.Quote, "reply"));
Assert.False(WechatSendConfirmation.Matches(reply with { Text = "another reply" }, baseline, ChatMessageType.Quote, "reply"));
Assert.True(WechatSendConfirmation.Matches(reply, baseline, current, ChatMessageType.Quote, "reply"));
Assert.False(WechatSendConfirmation.Matches(reply with { Fingerprint = "old" }, baseline, current, ChatMessageType.Quote, "reply"));
Assert.False(WechatSendConfirmation.Matches(reply with { Type = ChatMessageType.Text }, baseline, current, ChatMessageType.Quote, "reply"));
Assert.False(WechatSendConfirmation.Matches(reply with { Text = "another reply" }, baseline, current, ChatMessageType.Quote, "reply"));
}
[Theory]
@@ -25,15 +26,18 @@ public sealed class WechatSendConfirmationTests
public void FileConfirmationRequiresExactFilenameLine(string text, bool expected)
{
var message = new ChatMessageSnapshot(text, "new", 0, ChatMessageType.File);
Assert.Equal(expected, WechatSendConfirmation.Matches(message, new HashSet<string>(), ChatMessageType.File, "report.txt"));
Assert.Equal(expected, WechatSendConfirmation.Matches(message, new Dictionary<string, int>(),
new Dictionary<string, int> { ["new"] = 1 }, ChatMessageType.File, "report.txt"));
}
[Fact]
public void ImageConfirmationRejectsOtherTypesAndOldRows()
{
var message = new ChatMessageSnapshot("[图片]", "new", 0, ChatMessageType.Image);
Assert.True(WechatSendConfirmation.Matches(message, new HashSet<string>(), ChatMessageType.Image, null));
Assert.False(WechatSendConfirmation.Matches(message, new HashSet<string> { "new" }, ChatMessageType.Image, null));
Assert.False(WechatSendConfirmation.Matches(message with { Type = ChatMessageType.Text }, new HashSet<string>(), ChatMessageType.Image, null));
var empty = new Dictionary<string, int>();
var current = new Dictionary<string, int> { ["new"] = 1 };
Assert.True(WechatSendConfirmation.Matches(message, empty, current, ChatMessageType.Image, null));
Assert.False(WechatSendConfirmation.Matches(message, current, current, ChatMessageType.Image, null));
Assert.False(WechatSendConfirmation.Matches(message with { Type = ChatMessageType.Text }, empty, current, ChatMessageType.Image, null));
}
}
@@ -0,0 +1,47 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatTextInputSegmentationTests
{
[Fact]
public void SplitForSendSplitsLongTextAtTheSingleMessageBoundary()
{
var text = new string('x', WechatTextInput.MaxSegmentLength + 1);
var segments = WechatTextInput.SplitForSend(text);
Assert.Equal(2, segments.Count);
Assert.Equal(WechatTextInput.MaxSegmentLength, segments[0].Length);
Assert.Equal(1, segments[1].Length);
Assert.Equal(text, string.Concat(segments));
Assert.Throws<WxAgentException>(() => WechatTextInput.Prepare(text));
}
[Fact]
public void SplitForSendDoesNotBreakACombiningTextElement()
{
var prefix = new string('x', WechatTextInput.MaxSegmentLength - 1);
var combiningElement = "e\u0301";
var text = prefix + combiningElement + "tail";
var segments = WechatTextInput.SplitForSend(text);
Assert.Equal(2, segments.Count);
Assert.Equal(prefix, segments[0]);
Assert.StartsWith(combiningElement, segments[1], StringComparison.Ordinal);
Assert.All(segments, segment => Assert.InRange(segment.Length, 1, WechatTextInput.MaxSegmentLength));
Assert.Equal(text, string.Concat(segments));
}
[Fact]
public void SplitForSendRejectsTextBeyondTheBoundedTotalLength()
{
var text = new string('x', WechatTextInput.MaxTextLength + 1);
var exception = Assert.Throws<WxAgentException>(() => WechatTextInput.SplitForSend(text));
Assert.Equal(WxAgentErrorCode.InvalidArgument, exception.Code);
}
}
@@ -28,7 +28,8 @@ public sealed class WechatTextInputTests
var parsed = Assert.Single(VisibleMessageParser.Parse([reply + "\n引用 Alice 的消息 : original"]));
Assert.Equal(reply, parsed.Text);
Assert.Equal(ChatMessageType.Quote, parsed.Type);
Assert.True(WechatSendConfirmation.Matches(parsed, new HashSet<string>(), ChatMessageType.Quote, reply));
Assert.True(WechatSendConfirmation.Matches(parsed, new Dictionary<string, int>(),
new Dictionary<string, int> { [parsed.Fingerprint] = 1 }, ChatMessageType.Quote, reply));
Assert.Equal("original", parsed.Quote!.Text);
}
}
@@ -13,6 +13,7 @@ public sealed class BroadcastOperationTests
private sealed class Backend(bool failSecond = false) : IAgentBackend
{
public List<string> SentTargets { get; } = [];
public List<string> SentTexts { get; } = [];
public IReadOnlyList<AgentCapability> Capabilities =>
[
@@ -35,6 +36,7 @@ public sealed class BroadcastOperationTests
if (failSecond && targetId == "session-b")
throw new ServiceException("TargetUnavailable", 409, "Target is no longer available.");
SentTargets.Add(targetId);
SentTexts.Add(text);
return Task.CompletedTask;
}
}
@@ -136,6 +138,53 @@ public sealed class BroadcastOperationTests
}
}
[Fact]
public async Task SendTextAcceptsBoundedLongTextWhileBroadcastRetainsSingleMessageLimit()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
var token = new string('F', 43);
var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[]
{
new ServiceCredential("p", ServiceOptions.HashToken(token), ["read", "write"], [])
}));
var backend = new Backend();
await using var app = ServiceHost.Build(options, backend, b => b.WebHost.UseTestServer());
try
{
await app.StartAsync();
using var client = app.GetTestClient();
client.BaseAddress = new Uri("http://localhost:5088");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var longText = new string('x', WxAgent.Core.WechatTextInput.MaxSegmentLength + 1);
var submitted = await client.PostAsJsonAsync("/api/v1/operations", new
{
kind = "send-text", accountId = "account-1", targetId = "session-a", text = longText,
idempotencyKey = "send-long-text-1", confirmed = true
});
Assert.Equal(HttpStatusCode.OK, submitted.StatusCode);
var queued = await submitted.Content.ReadFromJsonAsync<JsonElement>();
var completed = await WaitForTerminalAsync(client, queued.GetProperty("id").GetString()!);
Assert.Equal("Succeeded", completed.GetProperty("state").GetString());
Assert.Equal(["session-a"], backend.SentTargets);
Assert.Equal([longText], backend.SentTexts);
var broadcast = await client.PostAsJsonAsync("/api/v1/operations", new
{
kind = "broadcast-text", accountId = "account-1", targets = new[] { "session-a" }, text = longText,
idempotencyKey = "broadcast-long-text-1", confirmed = true, stopOnError = true
});
Assert.Equal(HttpStatusCode.BadRequest, broadcast.StatusCode);
Assert.Single(backend.SentTargets);
}
finally
{
await app.StopAsync();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
Directory.Delete(dir, true);
}
}
private static async Task<JsonElement> WaitForTerminalAsync(HttpClient client, string operationId)
{
for (var i = 0; i < 100; i++)