forked from admin/VolcanicAgenticCore
feat(telegram): save documents, videos and audio to the chat inbox
Content step 2 (InboundMedia.AddFilesAsync, InboxFiles): files land in <cwd>/inbox/<date>-<safe name>[-N], image documents within Telegram:InlineImageMaxBytes are attached inline, oversize files and failed downloads get a notice and no run. Documents, videos, audio and voice are accepted; the unsupported reply is now 'I can read text, photos, files and voice messages.' Spec: Updates helpers are named DocumentMessage/VideoMessage/VoiceMessage/AudioMessage; the getFile 'file is too big' notice reads 'over 20 MB'; the too-large inline note is a parenthesised suffix after the size; size formats spelled out.
This commit is contained in:
@@ -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 <x> 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: `<cwd>/inbox/<date>-<stem><-N><ext>`. `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`).
|
||||
|
||||
|
||||
@@ -14,23 +14,27 @@ internal sealed class InboundDraft
|
||||
public List<ImageContent> Images { get; } = [];
|
||||
/// <summary>A step refused the message (and sent its own notice): nothing is prompted.</summary>
|
||||
public bool Refused { get; set; }
|
||||
/// <summary>Where step 2 saved the message's file, for step 3 (audio).</summary>
|
||||
public string? SavedPath { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns one Telegram message into an <see cref="InboundPart"/>: 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 (<see cref="InboundMedia"/>), step 3 voice and
|
||||
/// audio transcription. Any slow work for a message happens here, while the router holds that message's
|
||||
/// <see cref="InboundLease"/>. A step never talks to the <see cref="ChatInbox"/> itself.
|
||||
/// </summary>
|
||||
public sealed class InboundComposer(TelegramSender sender, ILogger<InboundComposer> logger)
|
||||
public sealed class InboundComposer(TelegramSender sender, InboundMedia media, ILogger<InboundComposer> logger)
|
||||
{
|
||||
/// <summary>The reply to a message kind the bot can't read (no lease is taken).</summary>
|
||||
public const string UnsupportedReply = "I can read text and photos.";
|
||||
/// <summary>The reply to a message kind the bot can't read (stickers, video notes, polls…; no lease is taken).</summary>
|
||||
public const string UnsupportedReply = "I can read text, photos, files and voice messages.";
|
||||
|
||||
/// <summary>Kinds the content steps can read. Checked by the router before it takes a lease.</summary>
|
||||
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 };
|
||||
}
|
||||
|
||||
/// <summary>A message with an attachment is never a command or a pending answer; its caption is prompt text.</summary>
|
||||
@@ -51,8 +55,8 @@ public sealed class InboundComposer(TelegramSender sender, ILogger<InboundCompos
|
||||
|
||||
// Content steps, in order. Each may add body lines and images, send a notice, or refuse the message.
|
||||
await AddTextAndPhotoAsync(chat, message, draft, cancellationToken).ConfigureAwait(false); // step 1
|
||||
// Step 2 (documents, videos, audio saved to the workspace) and step 3 (voice/audio transcription) are added here
|
||||
// by the attachments spec.
|
||||
if (!draft.Refused) await media.AddFilesAsync(chat, message, draft, cancellationToken).ConfigureAwait(false); // step 2
|
||||
// Step 3 (voice and audio transcription) goes here.
|
||||
|
||||
if (draft.Refused || (draft.Body.Count == 0 && draft.Images.Count == 0)) return null;
|
||||
return new InboundPart(
|
||||
@@ -74,7 +78,7 @@ public sealed class InboundComposer(TelegramSender sender, ILogger<InboundCompos
|
||||
{
|
||||
var largest = sizes.MaxBy(p => 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)
|
||||
|
||||
101
hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs
Normal file
101
hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<see cref="ChatRenderer.SayAsync"/>) so they stay in order.
|
||||
/// </summary>
|
||||
public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, TimeProvider time, ILogger<InboundMedia> logger)
|
||||
{
|
||||
/// <summary>Sent when a file's download fails (the partial file is deleted and the message isn't prompted).</summary>
|
||||
public const string DownloadFailedNotice = "I could not download that file.";
|
||||
|
||||
/// <summary>Suffix of a <c>[File received: …]</c> line for an image too large to attach inline.</summary>
|
||||
public const string TooLargeInlineNote = " (too large to show inline; downscale it with bash if you need to see it)";
|
||||
|
||||
private static readonly HashSet<string> InlineImageTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp", "image/gif" };
|
||||
|
||||
/// <summary>The notice for a file over the 20 MB download limit; <c>over 20 MB</c> when the size is unknown.</summary>
|
||||
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.";
|
||||
|
||||
/// <summary>Download a file by id into <paramref name="destination"/> (shared with step 1's photos).</summary>
|
||||
internal Task DownloadAsync(string fileId, Stream destination, CancellationToken cancellationToken) =>
|
||||
sender.Bot.GetInfoAndDownloadFile(fileId, destination, cancellationToken);
|
||||
|
||||
/// <summary>Telegram's <c>getFile</c> refusal for a file over the download limit.</summary>
|
||||
internal static bool IsTooBig(ApiRequestException ex) => ex.Message.Contains("file is too big", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Step 2: a document, video or audio file goes to the inbox and gets a <c>[File received: …]</c> line.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
hosts/Volcanic.AgenticCore.Telegram/Inbound/InboxFiles.cs
Normal file
65
hosts/Volcanic.AgenticCore.Telegram/Inbound/InboxFiles.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Inbound;
|
||||
|
||||
/// <summary>Where received files go: <c><workspace>/inbox/<yyyy-MM-dd>-<safe name></c>, never overwriting.</summary>
|
||||
public static class InboxFiles
|
||||
{
|
||||
/// <summary>The inbox directory under the chat's workspace.</summary>
|
||||
public const string DirectoryName = "inbox";
|
||||
|
||||
/// <summary>Longest stem kept from a received file name, in characters.</summary>
|
||||
public const int MaxStemLength = 80;
|
||||
|
||||
/// <summary>Longest extension kept, dot included.</summary>
|
||||
public const int MaxExtensionLength = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Drops any directory part, replaces everything but Unicode letters, digits, <c>.</c>, <c>-</c> and <c>_</c> with
|
||||
/// <c>_</c>, trims leading dots and underscores, caps stem (80) and extension (16, dot included); <c>file</c> when empty.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Creates the next free <c>inbox/<date>-<stem>[-N]<ext></c> with <see cref="FileMode.CreateNew"/>.</summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Workspace-relative with <c>/</c>, e.g. <c>inbox/2026-09-26-report.pdf</c>.</summary>
|
||||
public static string Relative(string workspace, string path) => Path.GetRelativePath(workspace, path).Replace('\\', '/');
|
||||
|
||||
/// <summary>At most <paramref name="max"/> characters, never splitting a surrogate pair.</summary>
|
||||
private static string Cap(string text, int max) =>
|
||||
text.Length <= max ? text : text[..(char.IsHighSurrogate(text[max - 1]) ? max - 1 : max)];
|
||||
}
|
||||
@@ -65,6 +65,7 @@ builder.Services.AddSingleton<TelegramSender>();
|
||||
builder.Services.TryAddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<ChatRegistry>();
|
||||
builder.Services.AddSingleton<ChatCommands>();
|
||||
builder.Services.AddSingleton<InboundMedia>();
|
||||
builder.Services.AddSingleton<InboundComposer>();
|
||||
builder.Services.AddSingleton<IUpdateRouter, UpdateRouter>();
|
||||
builder.Services.AddSingleton<PollingService>();
|
||||
|
||||
169
tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs
Normal file
169
tests/Volcanic.AgenticCore.Telegram.Tests/AttachmentChatTests.cs
Normal file
@@ -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;
|
||||
|
||||
/// <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"] = [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<ImageContent>());
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
@@ -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<TelegramSender>.Instance), NullLogger<InboundComposer>.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!));
|
||||
|
||||
51
tests/Volcanic.AgenticCore.Telegram.Tests/InboxFilesTests.cs
Normal file
51
tests/Volcanic.AgenticCore.Telegram.Tests/InboxFilesTests.cs
Normal file
@@ -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<string>();
|
||||
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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -59,10 +59,17 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
ILoggerFactory loggers = NullLoggerFactory.Instance;
|
||||
var sender = new TelegramSender(Bot, NullLogger<TelegramSender>.Instance);
|
||||
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar);
|
||||
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender,
|
||||
new InboundComposer(sender, NullLogger<InboundComposer>.Instance), NullLogger<UpdateRouter>.Instance);
|
||||
var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger<InboundMedia>.Instance);
|
||||
Composer = new InboundComposer(sender, media, NullLogger<InboundComposer>.Instance);
|
||||
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender, Composer, NullLogger<UpdateRouter>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>The composer the router uses (content steps 1–3).</summary>
|
||||
public InboundComposer Composer { get; }
|
||||
|
||||
/// <summary>The chat's workspace directory (its session's cwd).</summary>
|
||||
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);
|
||||
|
||||
@@ -28,6 +28,12 @@ public sealed class FakeBotClient : ITelegramBotClient
|
||||
public event AsyncEventHandler<ApiRequestEventArgs>? OnMakingApiRequest;
|
||||
public event AsyncEventHandler<ApiResponseEventArgs>? OnApiResponseReceived;
|
||||
|
||||
/// <summary>Downloadable files by file id: <c>getFile</c> answers with their size and <c>DownloadFile</c> writes their bytes.</summary>
|
||||
public ConcurrentDictionary<string, byte[]> Files { get; } = new();
|
||||
|
||||
/// <summary>When set, a download of a scripted file writes one byte and then fails with a network error.</summary>
|
||||
public bool FailDownloads { get; set; }
|
||||
|
||||
/// <summary>Called for every non-poll request before it is answered; throw to simulate a failure.</summary>
|
||||
public Action<IRequest>? 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<bool> 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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
/// <summary>A document (file id <paramref name="fileId"/>, scripted in <see cref="FakeBotClient.Files"/>).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>A video (MIME <c>video/mp4</c>).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>A voice note (MIME <c>audio/ogg</c>).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>An audio file.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>A channel post origin, optionally signed by its author.</summary>
|
||||
public static MessageOriginChannel Channel(string title, string? signature = null) => new()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user