forked from admin/VolcanicAgenticCore
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.
91 lines
4.8 KiB
C#
91 lines
4.8 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Telegram.Bot;
|
|
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>What the content steps have gathered for one message so far.</summary>
|
|
internal sealed class InboundDraft
|
|
{
|
|
public List<string> Body { get; } = [];
|
|
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: 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, InboundMedia media, ILogger<InboundComposer> logger)
|
|
{
|
|
/// <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.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>
|
|
public static bool HasAttachment(Message message)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(message);
|
|
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.VideoNote is not null
|
|
|| message.Animation is not null || message.Sticker is not null;
|
|
}
|
|
|
|
/// <summary>The message's part, or null when it has nothing usable or a step refused it.</summary>
|
|
public async Task<InboundPart?> ComposeAsync(ChatContext chat, Message message, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(chat);
|
|
ArgumentNullException.ThrowIfNull(message);
|
|
var draft = new InboundDraft();
|
|
|
|
// 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
|
|
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(
|
|
message.Id,
|
|
new DateTimeOffset(DateTime.SpecifyKind(message.Date, DateTimeKind.Utc)),
|
|
message.MediaGroupId,
|
|
MessageContext.ReplyHeader(message, sender.Bot.BotId),
|
|
message.ForwardOrigin is { } origin ? MessageContext.ForwardLabel(origin) : null,
|
|
draft.Body,
|
|
draft.Images);
|
|
}
|
|
|
|
/// <summary>Step 1: the text or caption, and the largest photo size.</summary>
|
|
private async Task AddTextAndPhotoAsync(ChatContext chat, Message message, InboundDraft draft, CancellationToken cancellationToken)
|
|
{
|
|
if ((message.Text ?? message.Caption) is { Length: > 0 } text) draft.Body.Add(text);
|
|
if (message.Photo is not { Length: > 0 } sizes) return;
|
|
try
|
|
{
|
|
var largest = sizes.MaxBy(p => p.FileSize ?? (long)p.Width * p.Height)!;
|
|
using var stream = new MemoryStream();
|
|
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)
|
|
{
|
|
logger.LogWarning(ex, "Downloading a photo in chat {ChatId} failed", chat.ChatId);
|
|
chat.Report("I could not download that photo.");
|
|
}
|
|
}
|
|
}
|