Files
Playa/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs
EugeneTes a0cd2975b8 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.
2026-09-26 22:09:16 +02:00

102 lines
4.9 KiB
C#

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