Files
Playa/tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs
EugeneTes 6ab98a8155 fix(telegram): address review findings
- A speech or download timeout (TaskCanceledException nobody asked for) is a failure
  with the user notice, not a silent shutdown; OpenAiSpeechToText maps it to
  "the request timed out."; UpdateRouter only treats the caller's cancellation as shutdown.
- send: WorkspacePaths resolves the real path component by component (realpath),
  closing the directory-link-inside-target bypass; `.` reports a directory.
- Image documents are attached inline only when their magic bytes are JPEG/PNG/GIF/WebP,
  under the detected type.
- Audio goes to STT as an ASCII audio.<ext> name.
- MCP Browser section mentions send only when it is active.
- RunInOrderAsync takes a cancellation token; queued work is skipped once disposal gives up;
  SendReplyAsync stays private behind ReplyInOrderAsync.
- Speech HttpClient handler recycles pooled connections; BuiltInTools.Send();
  IExtensionHost.Channel is abstract; WorkspacePaths in its own file.
- Tests: timeouts, symlink chains (Linux), sniffing, channel delivery failures,
  queued-work cancellation, gated voice ordering. Spec and docs updated.
2026-09-26 22:35:20 +02:00

201 lines
8.9 KiB
C#

using Microsoft.Extensions.Time.Testing;
using Telegram.Bot.Exceptions;
using Telegram.Bot.Requests;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Telegram.Tests.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests;
/// <summary>Documents and videos end to end: saved to the chat's inbox and prompted, or refused with a notice.</summary>
public sealed class AttachmentChatTests
{
private static readonly DateTimeOffset Today = new(2026, 9, 26, 10, 0, 0, TimeSpan.Zero);
private const long MB = 1024 * 1024;
private static UserMessage Prompt(ChatHost host) => (UserMessage)host.Faux.Requests.Single().Messages.Last();
private static string Inbox(ChatHost host) => Path.Combine(host.WorkspaceOf(), "inbox");
private static string[] InboxFiles(ChatHost host) => Directory.Exists(Inbox(host)) ? Directory.GetFiles(Inbox(host)) : [];
private static async Task RouteAndFlushAsync(ChatHost host, FakeTimeProvider time, params global::Telegram.Bot.Types.Update[] updates)
{
foreach (var update in updates) await host.RouteAsync(update);
await MessageContextChatTests.FlushAsync(host, time, updates.Length);
await host.SettleAsync();
}
[Fact]
public async Task A_document_with_a_caption_is_saved_to_the_inbox_and_prompted()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Bot.Files["doc"] = "hello world"u8.ToArray();
host.Faux.Enqueue(FauxTurn.Say("Summary."));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("report.pdf", "application/pdf", 11, caption: "Please summarise"));
Assert.Equal("hello world"u8.ToArray(), await File.ReadAllBytesAsync(Path.Combine(Inbox(host), "2026-09-26-report.pdf")));
Assert.EndsWith("\nPlease summarise\n\n[File received: inbox/2026-09-26-report.pdf (application/pdf, 11 bytes)]", Prompt(host).Text, StringComparison.Ordinal);
}
[Fact]
public async Task A_second_upload_of_the_same_name_gets_a_suffix()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Bot.Files["doc"] = [1];
host.Faux.Enqueue(FauxTurn.Say("One."), FauxTurn.Say("Two."));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("report.pdf", "application/pdf", 1));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("report.pdf", "application/pdf", 1));
Assert.Contains("[File received: inbox/2026-09-26-report-1.pdf (application/pdf, 1 bytes)]", host.Faux.Requests[1].Messages.Last().Text, StringComparison.Ordinal);
Assert.Equal(2, InboxFiles(host).Length);
}
[Fact]
public async Task A_hostile_file_name_stays_in_the_inbox()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Bot.Files["doc"] = [1];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("../../evil.sh", "text/x-sh", 1));
Assert.Equal(Path.Combine(Inbox(host), "2026-09-26-evil.sh"), Assert.Single(InboxFiles(host)));
Assert.False(File.Exists(Path.Combine(host.WorkspaceOf(), "..", "evil.sh")));
}
[Fact]
public async Task A_file_over_20_MB_gets_the_size_notice_and_no_run()
{
await using var host = new ChatHost();
await host.RouteAsync(Updates.DocumentMessage("big.zip", "application/zip", 21 * MB, caption: "look"));
await host.SettleAsync();
Assert.Contains("That file is 21.0 MB; Telegram lets bots download at most 20 MB. Share a link or a smaller file.", host.Texts);
Assert.Empty(host.Bot.Requests.OfType<GetFileRequest>());
Assert.Empty(InboxFiles(host));
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task A_getFile_too_big_error_gets_the_size_notice()
{
await using var host = new ChatHost();
host.Bot.OnSend = r => { if (r is GetFileRequest) throw new ApiRequestException("Bad Request: file is too big", 400); };
await host.RouteAsync(Updates.DocumentMessage("big.zip", "application/zip", size: null));
await host.SettleAsync();
Assert.Contains("That file is over 20 MB; Telegram lets bots download at most 20 MB. Share a link or a smaller file.", host.Texts);
Assert.Empty(InboxFiles(host));
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task A_failed_download_deletes_the_partial_file_and_gets_a_notice()
{
await using var host = new ChatHost();
host.Bot.Files["doc"] = [1, 2, 3];
host.Bot.FailDownloads = true;
await host.RouteAsync(Updates.DocumentMessage("report.pdf", "application/pdf", 3, caption: "read this"));
await host.SettleAsync();
Assert.Contains("I could not download that file.", host.Texts);
Assert.Empty(InboxFiles(host));
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task An_image_document_within_the_limit_is_attached_inline()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Bot.Files["doc"] = Png;
host.Faux.Enqueue(FauxTurn.Say("A diagram."));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("diagram.png", "image/png", Png.Length));
var image = Assert.Single(Prompt(host).Content.OfType<ImageContent>());
Assert.Equal(("image/png", Convert.ToBase64String(Png)), (image.MimeType, image.Data));
Assert.EndsWith("[File received: inbox/2026-09-26-diagram.png (image/png, 10 bytes)]", Prompt(host).Text, StringComparison.Ordinal);
}
private static readonly byte[] Png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2];
[Fact]
public async Task An_image_document_is_attached_with_the_type_its_bytes_show()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 1, 2];
host.Bot.Files["doc"] = jpeg;
host.Faux.Enqueue(FauxTurn.Say("ok"));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("photo.png", "image/png", jpeg.Length));
Assert.Equal("image/jpeg", Assert.Single(Prompt(host).Content.OfType<ImageContent>()).MimeType);
}
[Fact]
public async Task An_image_document_whose_bytes_are_no_known_image_is_path_only()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Bot.Files["doc"] = [9, 8, 7]; // e.g. HEIC or a truncated file labelled image/png
host.Faux.Enqueue(FauxTurn.Say("ok"));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("diagram.png", "image/png", 3));
Assert.Empty(Prompt(host).Content.OfType<ImageContent>());
Assert.EndsWith("[File received: inbox/2026-09-26-diagram.png (image/png, 3 bytes) (not shown inline: the contents are not a JPEG, PNG, WebP or GIF image)]",
Prompt(host).Text, StringComparison.Ordinal);
}
[Fact]
public async Task An_image_document_over_the_limit_is_path_only()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Options.InlineImageMaxBytes = 2;
host.Bot.Files["doc"] = [9, 8, 7];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("diagram.png", "image/png", 3));
Assert.Empty(Prompt(host).Content.OfType<ImageContent>());
Assert.EndsWith("[File received: inbox/2026-09-26-diagram.png (image/png, 3 bytes) (too large to show inline; downscale it with bash if you need to see it)]",
Prompt(host).Text, StringComparison.Ordinal);
}
[Fact]
public async Task A_document_captioned_new_is_not_a_command()
{
await using var host = new ChatHost();
host.Bot.Files["doc"] = [1];
host.Faux.Enqueue(FauxTurn.Say("ok"));
var chat = await host.ChatAsync();
var sessionId = chat.Session.SessionId;
await host.RouteAsync(Updates.DocumentMessage("notes.txt", "text/plain", 1, caption: "/new"));
await host.SettleAsync();
Assert.Equal(sessionId, chat.Session.SessionId);
Assert.Contains("/new\n\n[File received: inbox/", host.Faux.Requests.Single().Messages.Last().Text, StringComparison.Ordinal);
}
[Fact]
public async Task A_video_without_a_name_is_saved_as_video_mp4()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Bot.Files["video"] = [1];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await RouteAndFlushAsync(host, time, Updates.VideoMessage(null, 1));
Assert.EndsWith("[File received: inbox/2026-09-26-video.mp4 (video/mp4, 1 bytes)]", Prompt(host).Text, StringComparison.Ordinal);
}
}