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:
EugeneTes
2026-09-26 22:09:16 +02:00
parent 8758f4b3d1
commit a0cd2975b8
12 changed files with 483 additions and 23 deletions

View File

@@ -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)

View 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);
}
}
}

View File

@@ -0,0 +1,65 @@
using System.Globalization;
using System.Text;
namespace Volcanic.AgenticCore.Telegram.Inbound;
/// <summary>Where received files go: <c>&lt;workspace&gt;/inbox/&lt;yyyy-MM-dd&gt;-&lt;safe name&gt;</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/&lt;date&gt;-&lt;stem&gt;[-N]&lt;ext&gt;</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)];
}

View File

@@ -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>();