diff --git a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md index 21ed7b9..677675f 100644 --- a/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md +++ b/docs/superpowers/specs/2026-09-26-telegram-attachments-design.md @@ -205,7 +205,8 @@ Router changes (`UpdateRouter.OnMessageAsync`), on top of the message-context sp 1. `FileSize > 20 MB` (`TelegramLimits.MaxDownloadBytes`) → notice `That file is MB; Telegram lets bots download at most 20 MB. Share a link or a smaller file.` The message isn't prompted, caption included (the caption is - about the file). A `getFile` error saying "file is too big" (a missing `FileSize`) is reported the same way. + about the file). A `getFile` error saying "file is too big" (a missing `FileSize`) is reported the same way, as + `That file is over 20 MB; …`. 2. Target: `/inbox/-<-N>`. `date` is `TimeProvider.GetLocalNow()` as `yyyy-MM-dd`. The name is `FileName ?? "file"`: `Path.GetFileName` drops any directory part, and every character other than a Unicode letter or digit, `.`, `-` or `_` becomes `_`. Leading dots and underscores are trimmed (no hidden files). Stem is @@ -215,10 +216,11 @@ Router changes (`UpdateRouter.OnMessageAsync`), on top of the message-context sp notice `I could not download that file.` The message isn't prompted. 4. Prompt text: the caption (if any), a blank line, then `[File received: inbox/2026-09-26-report.pdf (application/pdf, 1.2 MB)]`. The path is relative to the workspace - and uses `/`. + and uses `/`. Sizes read `N bytes`, `N.N KB` or `N.N MB`. 5. Image documents: when `MimeType` is `image/jpeg|png|webp|gif` and the size is ≤ `Telegram:InlineImageMaxBytes` (default 3 750 000), the saved bytes are also attached as `ImageContent(base64, mime)`. Larger images are - path-only, and the line says `(too large to show inline; downscale it with bash if you need to see it)`. + path-only, and the line gets a parenthesised suffix after the size: + `[File received: inbox/x.png (image/png, 5.0 MB) (too large to show inline; downscale it with bash if you need to see it)]`. **Voice and audio** (`Voice`, `Audio`): @@ -355,7 +357,8 @@ xunit, no network, no paid API. tests can assert the uploaded bytes, name and caption (`Uploads`). - `OnSend` can throw an `ApiRequestException(400, "PHOTO_INVALID_DIMENSIONS")` to exercise the fallback. -`Updates` gets `Document(...)`, `Voice(...)`, `Audio(...)` and `Sticker()`. `ChatHost` takes an optional +`Updates` gets `DocumentMessage(...)`, `VideoMessage(...)`, `VoiceMessage(...)` and `AudioMessage(...)`, and reuses +`StickerMessage()` (a member named like a Telegram type would hide that type inside the class). `ChatHost` takes an optional `ISpeechToText` (a `FakeSpeechToText` returning a scripted transcript or throwing) and uses the optional `TimeProvider` the message-context spec adds (`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing`). diff --git a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs index ea3563a..5ad7c98 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundComposer.cs @@ -14,23 +14,27 @@ internal sealed class InboundDraft public List Images { get; } = []; /// A step refused the message (and sent its own notice): nothing is prompted. public bool Refused { get; set; } + /// Where step 2 saved the message's file, for step 3 (audio). + public string? SavedPath { get; set; } } /// /// Turns one Telegram message into an : reply and forward headers, then the content steps in a -/// fixed order. Any slow work for a message happens here, while the router holds that message's +/// fixed order: step 1 text and photo, step 2 files saved to the inbox (), step 3 voice and +/// audio transcription. Any slow work for a message happens here, while the router holds that message's /// . A step never talks to the itself. /// -public sealed class InboundComposer(TelegramSender sender, ILogger logger) +public sealed class InboundComposer(TelegramSender sender, InboundMedia media, ILogger logger) { - /// The reply to a message kind the bot can't read (no lease is taken). - public const string UnsupportedReply = "I can read text and photos."; + /// The reply to a message kind the bot can't read (stickers, video notes, polls…; no lease is taken). + public const string UnsupportedReply = "I can read text, photos, files and voice messages."; /// Kinds the content steps can read. Checked by the router before it takes a lease. public static bool IsSupported(Message message) { ArgumentNullException.ThrowIfNull(message); - return message.Photo is { Length: > 0 } || (message.Text ?? message.Caption) is { Length: > 0 }; + return message.Photo is { Length: > 0 } || message.Document is not null || message.Video is not null + || message.Audio is not null || message.Voice is not null || (message.Text ?? message.Caption) is { Length: > 0 }; } /// A message with an attachment is never a command or a pending answer; its caption is prompt text. @@ -51,8 +55,8 @@ public sealed class InboundComposer(TelegramSender sender, ILogger p.FileSize ?? (long)p.Width * p.Height)!; using var stream = new MemoryStream(); - await sender.Bot.GetInfoAndDownloadFile(largest.FileId, stream, cancellationToken).ConfigureAwait(false); + await media.DownloadAsync(largest.FileId, stream, cancellationToken).ConfigureAwait(false); draft.Images.Add(new ImageContent(Convert.ToBase64String(stream.ToArray()), "image/jpeg")); } catch (Exception ex) when (ex is not OperationCanceledException) diff --git a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs new file mode 100644 index 0000000..b18c278 --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs @@ -0,0 +1,101 @@ +using Microsoft.Extensions.Logging; +using Telegram.Bot; +using Telegram.Bot.Exceptions; +using Telegram.Bot.Types; +using Volcanic.AgenticCore.Telegram.Chats; +using Volcanic.AgenticCore.Telegram.Rendering; +using ImageContent = Volcanic.AgenticCore.Abstractions.Messages.ImageContent; + +namespace Volcanic.AgenticCore.Telegram.Inbound; + +/// +/// Content steps for attachments (spec: telegram-attachments §4): step 2 saves documents, videos and audio to the +/// workspace inbox. Notices go through the chat renderer () so they stay in order. +/// +public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, TimeProvider time, ILogger logger) +{ + /// Sent when a file's download fails (the partial file is deleted and the message isn't prompted). + public const string DownloadFailedNotice = "I could not download that file."; + + /// Suffix of a [File received: …] line for an image too large to attach inline. + public const string TooLargeInlineNote = " (too large to show inline; downscale it with bash if you need to see it)"; + + private static readonly HashSet InlineImageTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp", "image/gif" }; + + /// The notice for a file over the 20 MB download limit; over 20 MB when the size is unknown. + public static string TooBigNotice(long? bytes) => + $"That file is {(bytes is { } b ? TelegramLimits.Megabytes(b) + " MB" : "over 20 MB")}; Telegram lets bots download at most 20 MB. Share a link or a smaller file."; + + /// Download a file by id into (shared with step 1's photos). + internal Task DownloadAsync(string fileId, Stream destination, CancellationToken cancellationToken) => + sender.Bot.GetInfoAndDownloadFile(fileId, destination, cancellationToken); + + /// Telegram's getFile refusal for a file over the download limit. + internal static bool IsTooBig(ApiRequestException ex) => ex.Message.Contains("file is too big", StringComparison.OrdinalIgnoreCase); + + /// Step 2: a document, video or audio file goes to the inbox and gets a [File received: …] line. + internal async Task AddFilesAsync(ChatContext chat, Message message, InboundDraft draft, CancellationToken cancellationToken) + { + var (fileId, size, name, mime) = message switch + { + { Document: { } d } => (d.FileId, d.FileSize, d.FileName ?? "file", d.MimeType ?? "application/octet-stream"), + { Video: { } v } => (v.FileId, v.FileSize, v.FileName ?? "video.mp4", v.MimeType ?? "video/mp4"), + { Audio: { } a } => (a.FileId, a.FileSize, a.FileName ?? "audio", a.MimeType ?? "audio/mpeg"), + _ => ((string?)null, (long?)null, "", ""), + }; + if (fileId is null) return; + if (size > TelegramLimits.MaxDownloadBytes) + { + await chat.Renderer.SayAsync(TooBigNotice(size)).ConfigureAwait(false); + draft.Refused = true; + return; + } + + var workspace = chat.Session.Cwd; + var (path, stream) = InboxFiles.Create(workspace, time.GetLocalNow(), name); + try + { + await using (stream.ConfigureAwait(false)) + await DownloadAsync(fileId, stream, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + TryDelete(path); + throw; + } + catch (Exception ex) + { + TryDelete(path); + logger.LogWarning(ex, "Downloading {File} in chat {ChatId} failed", name, chat.ChatId); + await chat.Renderer.SayAsync(ex is ApiRequestException api && IsTooBig(api) ? TooBigNotice(size) : DownloadFailedNotice).ConfigureAwait(false); + draft.Refused = true; + return; + } + + var bytes = new FileInfo(path).Length; + draft.SavedPath = path; + var note = ""; + if (InlineImageTypes.Contains(mime)) + { + if (bytes <= options.InlineImageMaxBytes) + { + var data = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false); + draft.Images.Add(new ImageContent(Convert.ToBase64String(data), mime.ToLowerInvariant())); + } + else + { + note = TooLargeInlineNote; + } + } + draft.Body.Add($"[File received: {InboxFiles.Relative(workspace, path)} ({mime}, {TelegramLimits.FormatSize(bytes)}){note}]"); + } + + private void TryDelete(string path) + { + try { File.Delete(path); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogDebug(ex, "Could not delete the partial file {Path}", path); + } + } +} diff --git a/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboxFiles.cs b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboxFiles.cs new file mode 100644 index 0000000..c5b524b --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboxFiles.cs @@ -0,0 +1,65 @@ +using System.Globalization; +using System.Text; + +namespace Volcanic.AgenticCore.Telegram.Inbound; + +/// Where received files go: <workspace>/inbox/<yyyy-MM-dd>-<safe name>, never overwriting. +public static class InboxFiles +{ + /// The inbox directory under the chat's workspace. + public const string DirectoryName = "inbox"; + + /// Longest stem kept from a received file name, in characters. + public const int MaxStemLength = 80; + + /// Longest extension kept, dot included. + public const int MaxExtensionLength = 16; + + /// + /// Drops any directory part, replaces everything but Unicode letters, digits, ., - and _ with + /// _, trims leading dots and underscores, caps stem (80) and extension (16, dot included); file when empty. + /// + public static string SafeName(string? fileName) + { + var name = Path.GetFileName((fileName ?? "").Replace('\\', '/')); + var cleaned = new StringBuilder(name.Length); + foreach (var rune in name.EnumerateRunes()) + cleaned.Append(Rune.IsLetterOrDigit(rune) || rune.Value is '.' or '-' or '_' ? rune.ToString() : "_"); + var safe = cleaned.ToString().TrimStart('.', '_'); + var extension = Path.GetExtension(safe); + var stem = extension.Length == 0 ? safe : safe[..^extension.Length]; + extension = Cap(extension, MaxExtensionLength); + stem = Cap(stem, MaxStemLength); + if (stem.Length == 0) stem = "file"; + return stem + extension; + } + + /// Creates the next free inbox/<date>-<stem>[-N]<ext> with . + public static (string Path, FileStream Stream) Create(string workspace, DateTimeOffset localNow, string? fileName) + { + var directory = Path.Combine(workspace, DirectoryName); + Directory.CreateDirectory(directory); + var safe = SafeName(fileName); + var extension = Path.GetExtension(safe); + var prefix = localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + "-" + safe[..^extension.Length]; + for (var n = 0; ; n++) + { + var path = Path.Combine(directory, prefix + (n == 0 ? "" : "-" + n.ToString(CultureInfo.InvariantCulture)) + extension); + try + { + return (path, new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true)); + } + catch (IOException) when (File.Exists(path) && n < 10_000) + { + // taken: try the next suffix + } + } + } + + /// Workspace-relative with /, e.g. inbox/2026-09-26-report.pdf. + public static string Relative(string workspace, string path) => Path.GetRelativePath(workspace, path).Replace('\\', '/'); + + /// At most characters, never splitting a surrogate pair. + private static string Cap(string text, int max) => + text.Length <= max ? text : text[..(char.IsHighSurrogate(text[max - 1]) ? max - 1 : max)]; +} diff --git a/hosts/Volcanic.AgenticCore.Telegram/Program.cs b/hosts/Volcanic.AgenticCore.Telegram/Program.cs index 35726c2..58eeda5 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Program.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Program.cs @@ -65,6 +65,7 @@ builder.Services.AddSingleton(); builder.Services.TryAddSingleton(TimeProvider.System); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs new file mode 100644 index 0000000..1f5d829 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs @@ -0,0 +1,169 @@ +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; + +/// Documents and videos end to end: saved to the chat's inbox and prompted, or refused with a notice. +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()); + 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"] = [9, 8, 7]; + host.Faux.Enqueue(FauxTurn.Say("A diagram.")); + + await RouteAndFlushAsync(host, time, Updates.DocumentMessage("diagram.png", "image/png", 3)); + + var image = Assert.Single(Prompt(host).Content.OfType()); + Assert.Equal(("image/png", Convert.ToBase64String([9, 8, 7])), (image.MimeType, image.Data)); + Assert.EndsWith("[File received: inbox/2026-09-26-diagram.png (image/png, 3 bytes)]", 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()); + 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); + } +} diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/InboundComposerTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/InboundComposerTests.cs index 9b28b8b..312cd43 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/InboundComposerTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/InboundComposerTests.cs @@ -1,15 +1,12 @@ -using Microsoft.Extensions.Logging.Abstractions; using Telegram.Bot.Requests; using Volcanic.AgenticCore.Telegram.Inbound; -using Volcanic.AgenticCore.Telegram.Rendering; using Volcanic.AgenticCore.Telegram.Tests.Support; namespace Volcanic.AgenticCore.Telegram.Tests; public sealed class InboundComposerTests { - private static InboundComposer Composer(ChatHost host) => - new(new TelegramSender(host.Bot, NullLogger.Instance), NullLogger.Instance); + private static InboundComposer Composer(ChatHost host) => host.Composer; [Fact] public async Task Text_becomes_the_body_with_reply_and_forward_headers() @@ -77,10 +74,14 @@ public sealed class InboundComposerTests } [Fact] - public void Only_text_captions_and_photos_are_supported_in_this_wave() + public void Text_photos_files_and_voice_are_supported() { Assert.True(InboundComposer.IsSupported(Updates.Text("hi").Message!)); Assert.True(InboundComposer.IsSupported(Updates.Photo().Message!)); + Assert.True(InboundComposer.IsSupported(Updates.DocumentMessage("a.pdf", "application/pdf", 3).Message!)); + Assert.True(InboundComposer.IsSupported(Updates.VideoMessage(null, 3).Message!)); + Assert.True(InboundComposer.IsSupported(Updates.AudioMessage("a.mp3", "audio/mpeg", 3).Message!)); + Assert.True(InboundComposer.IsSupported(Updates.VoiceMessage().Message!)); Assert.False(InboundComposer.IsSupported(Updates.StickerMessage().Message!)); Assert.True(InboundComposer.HasAttachment(Updates.Photo("caption").Message!)); Assert.False(InboundComposer.HasAttachment(Updates.Text("/status").Message!)); diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/InboxFilesTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/InboxFilesTests.cs new file mode 100644 index 0000000..85c528c --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/InboxFilesTests.cs @@ -0,0 +1,51 @@ +using Volcanic.AgenticCore.Telegram.Inbound; +using Volcanic.AgenticCore.Telegram.Rendering; + +namespace Volcanic.AgenticCore.Telegram.Tests; + +public sealed class InboxFilesTests : IDisposable +{ + private readonly string _ws = Path.Combine(Path.GetTempPath(), "volcanic-inbox", Guid.NewGuid().ToString("N")); + private static readonly DateTimeOffset Now = new(2026, 9, 26, 10, 0, 0, TimeSpan.Zero); + + public void Dispose() { try { Directory.Delete(_ws, true); } catch (IOException) { } } + + [Theory] + [InlineData("report.pdf", "report.pdf")] + [InlineData("../../evil.sh", "evil.sh")] + [InlineData("..\\..\\win.bat", "win.bat")] + [InlineData(".bashrc", "bashrc")] + [InlineData("__init__.py", "init__.py")] + [InlineData("my report (final).pdf", "my_report__final_.pdf")] + [InlineData("отчёт.pdf", "отчёт.pdf")] + [InlineData("...", "file")] + [InlineData("", "file")] + [InlineData(null, "file")] + public void Hostile_names_are_sanitised(string? name, string expected) => Assert.Equal(expected, InboxFiles.SafeName(name)); + + [Fact] + public void Long_stems_and_extensions_are_capped() + { + Assert.Equal(new string('a', 80) + ".txt", InboxFiles.SafeName(new string('a', 300) + ".txt")); + Assert.Equal("x." + new string('b', 15), InboxFiles.SafeName("x." + new string('b', 30))); + } + + [Fact] + public void Collisions_get_numbered_suffixes() + { + var paths = new List(); + for (var i = 0; i < 3; i++) + { + var (path, stream) = InboxFiles.Create(_ws, Now, "report.pdf"); + stream.Dispose(); + paths.Add(InboxFiles.Relative(_ws, path)); + } + Assert.Equal(["inbox/2026-09-26-report.pdf", "inbox/2026-09-26-report-1.pdf", "inbox/2026-09-26-report-2.pdf"], paths); + } + + [Theory] + [InlineData(5, "5 bytes")] + [InlineData(1536, "1.5 KB")] + [InlineData(1_258_291, "1.2 MB")] + public void Sizes_are_formatted(long bytes, string expected) => Assert.Equal(expected, TelegramLimits.FormatSize(bytes)); +} diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/MessageContextChatTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/MessageContextChatTests.cs index 90ffcd7..0a87dbe 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/MessageContextChatTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/MessageContextChatTests.cs @@ -282,13 +282,13 @@ public sealed class MessageContextChatTests } [Fact] - public async Task Unsupported_messages_keep_the_old_reply_and_take_no_lease() + public async Task Unsupported_messages_get_the_reply_and_take_no_lease() { await using var host = new ChatHost(); await host.RouteAsync(Updates.StickerMessage()); - Assert.Equal("I can read text and photos.", Assert.Single(host.Bot.Sent).Text); + Assert.Equal("I can read text, photos, files and voice messages.", Assert.Single(host.Bot.Sent).Text); Assert.Null(host.Chats.Find(Updates.AllowedUser)); Assert.Empty(host.Faux.Requests); } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs index 8810deb..a87b21a 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs @@ -59,10 +59,17 @@ public sealed class ChatHost : IAsyncDisposable ILoggerFactory loggers = NullLoggerFactory.Instance; var sender = new TelegramSender(Bot, NullLogger.Instance); Chats = new ChatRegistry(_services.GetRequiredService(), sender, Options, loggers, time, calendar); - Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender, - new InboundComposer(sender, NullLogger.Instance), NullLogger.Instance); + var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger.Instance); + Composer = new InboundComposer(sender, media, NullLogger.Instance); + Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender, Composer, NullLogger.Instance); } + /// The composer the router uses (content steps 1–3). + public InboundComposer Composer { get; } + + /// The chat's workspace directory (its session's cwd). + public string WorkspaceOf(long chatId = Updates.AllowedUser) => Chats.WorkspaceFor(chatId); + public Task SendAsync(string text, long userId = Updates.AllowedUser) => Router.RouteAsync(Updates.Text(text, userId), CancellationToken.None); public Task RouteAsync(Update update) => Router.RouteAsync(update, CancellationToken.None); diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs index 80dd0bf..076d191 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeBotClient.cs @@ -28,6 +28,12 @@ public sealed class FakeBotClient : ITelegramBotClient public event AsyncEventHandler? OnMakingApiRequest; public event AsyncEventHandler? OnApiResponseReceived; + /// Downloadable files by file id: getFile answers with their size and DownloadFile writes their bytes. + public ConcurrentDictionary Files { get; } = new(); + + /// When set, a download of a scripted file writes one byte and then fails with a network error. + public bool FailDownloads { get; set; } + /// Called for every non-poll request before it is answered; throw to simulate a failure. public Action? OnSend { get; set; } @@ -115,6 +121,9 @@ public sealed class FakeBotClient : ITelegramBotClient _texts.TryRemove(delete.MessageId, out _); result = true; break; + case GetFileRequest getFile when Files.TryGetValue(getFile.FileId, out var bytes): + result = new TGFile { FileId = getFile.FileId, FileUniqueId = getFile.FileId + "-u", FilePath = $"files/{getFile.FileId}", FileSize = bytes.Length }; + break; case GetFileRequest getFile: result = new TGFile { FileId = getFile.FileId, FileUniqueId = getFile.FileId + "-u", FilePath = $"photos/{getFile.FileId}.jpg", FileSize = 3 }; break; @@ -132,6 +141,17 @@ public sealed class FakeBotClient : ITelegramBotClient } public Task TestApi(CancellationToken cancellationToken = default) => Task.FromResult(true); - public Task DownloadFile(string filePath, Stream destination, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task DownloadFile(TGFile file, Stream destination, CancellationToken cancellationToken = default) => Task.CompletedTask; + public async Task DownloadFile(string filePath, Stream destination, CancellationToken cancellationToken = default) + { + if (!filePath.StartsWith("files/", StringComparison.Ordinal) || !Files.TryGetValue(filePath["files/".Length..], out var bytes)) return; + if (FailDownloads) + { + await destination.WriteAsync(bytes.AsMemory(0, Math.Min(1, bytes.Length)), cancellationToken); // a partial file + throw NetworkDown(); + } + await destination.WriteAsync(bytes, cancellationToken); + } + + public Task DownloadFile(TGFile file, Stream destination, CancellationToken cancellationToken = default) => + DownloadFile(file.FilePath ?? "", destination, cancellationToken); } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/Updates.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/Updates.cs index 336a078..aa9547d 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/Updates.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/Updates.cs @@ -117,6 +117,44 @@ public static class Updates return Wrap(m); } + // Media helpers end in "Message": a member named Document/Video/Voice/Audio would hide the Telegram type in this class. + + /// A document (file id , scripted in ). + public static Update DocumentMessage(string? fileName, string? mimeType, long? size, string? caption = null, string fileId = "doc") + { + var m = NewMessage(null); + m.Document = new Document { FileId = fileId, FileUniqueId = fileId + "-u", FileName = fileName, MimeType = mimeType, FileSize = size }; + m.Caption = caption; + return Wrap(m); + } + + /// A video (MIME video/mp4). + public static Update VideoMessage(string? fileName, long? size, string? caption = null, string fileId = "video") + { + var m = NewMessage(null); + m.Video = new Video { FileId = fileId, FileUniqueId = fileId + "-u", Width = 1, Height = 1, Duration = 1, FileName = fileName, MimeType = "video/mp4", FileSize = size }; + m.Caption = caption; + return Wrap(m); + } + + /// A voice note (MIME audio/ogg). + public static Update VoiceMessage(long? size = 3, string? caption = null, string fileId = "voice") + { + var m = NewMessage(null); + m.Voice = new Voice { FileId = fileId, FileUniqueId = fileId + "-u", Duration = 2, MimeType = "audio/ogg", FileSize = size }; + m.Caption = caption; + return Wrap(m); + } + + /// An audio file. + public static Update AudioMessage(string? fileName, string? mimeType, long? size, string? caption = null, string fileId = "audio") + { + var m = NewMessage(null); + m.Audio = new Audio { FileId = fileId, FileUniqueId = fileId + "-u", Duration = 60, FileName = fileName, MimeType = mimeType, FileSize = size }; + m.Caption = caption; + return Wrap(m); + } + /// A channel post origin, optionally signed by its author. public static MessageOriginChannel Channel(string title, string? signature = null) => new() {