feat(telegram): TelegramChatChannel delivers send files and scheduler texts in order

Add TelegramLimits, TelegramChatChannel (every delivery through
ChatRenderer.RunInOrderAsync, auto photo falls back to a document on a 400)
and wire it into ChatRegistry as AgentSessionOptions.Channel and
ChatContext.Channel. Spec: SendTextAsync names its failure text
("Telegram did not accept the message.") and throws when any chunk fails.
This commit is contained in:
EugeneTes
2026-09-26 22:00:06 +02:00
parent 1eed03e851
commit ee3049e47a
6 changed files with 277 additions and 4 deletions

View File

@@ -171,7 +171,8 @@ it (`chat.Channel`).
- Anything else becomes `ChatDeliveryException("Telegram refused the file: <description>")`.
- **Text.** `SendTextAsync(markdown)` reuses the renderer's reply path (`TelegramFormatter.Split` →
`ToHtml` → `SendHtmlAsync` with the plain fallback). It returns the id of the first message, or throws
`ChatDeliveryException` when every retry failed.
`ChatDeliveryException("Telegram did not accept the message.")` when a chunk still failed after its retries (the
other chunks are still sent).
### 4. Telegram — inbound: `InboundMedia`

View File

@@ -13,14 +13,17 @@ using Volcanic.AgenticCore.Telegram.Rendering;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>Everything that belongs to one Telegram chat: its agent session, renderer and dialog surface.</summary>
public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui, ChatRenderer renderer, IDisposable subscription, ChatInbox inbox, ILogger logger)
: IAsyncDisposable
public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui, ChatRenderer renderer, IDisposable subscription, ChatInbox inbox, ILogger logger,
IChatChannel channel) : IAsyncDisposable
{
public long ChatId => chatId;
public AgentSession Session => session;
public TelegramUi Ui => ui;
public ChatRenderer Renderer => renderer;
/// <summary>Delivers files and texts into this chat in order with rendered output (the scheduler uses it too).</summary>
public IChatChannel Channel => channel;
/// <summary>Groups this chat's incoming messages into prompts (see <see cref="ChatInbox"/>).</summary>
public ChatInbox Inbox => inbox;
@@ -104,6 +107,7 @@ public sealed class ChatRegistry(
var logger = loggerFactory.CreateLogger<ChatContext>();
var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger<ChatRenderer>());
var ui = new TelegramUi(chatId, sender, renderer);
var channel = new TelegramChatChannel(chatId, sender, renderer);
AgentSession session;
try
{
@@ -114,6 +118,7 @@ public sealed class ChatRegistry(
SessionId = last?.SessionId,
Model = options.Model,
Ui = ui,
Channel = channel,
ExtraExtensions = calendar is null ? null : [GoogleCalendarSetup.Registration(calendar)],
}).ConfigureAwait(false);
}
@@ -129,7 +134,7 @@ public sealed class ChatRegistry(
ChatContext? context = null;
var inbox = new ChatInbox(parts => FlushAsync(context!, parts), options.InputDebounce, _time,
logger: loggerFactory.CreateLogger<ChatInbox>());
context = new ChatContext(chatId, session, ui, renderer, subscription, inbox, logger);
context = new ChatContext(chatId, session, ui, renderer, subscription, inbox, logger, channel);
return context; // no lease can be taken before this returns, so the flush always sees the context
}

View File

@@ -0,0 +1,53 @@
using System.Globalization;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Telegram.Rendering;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// One chat's <see cref="IChatChannel"/>. Every delivery goes through the chat renderer's queue
/// (<see cref="ChatRenderer.RunInOrderAsync{T}"/>), so it can't overtake rendered output, and works while no run is active.
/// </summary>
public sealed class TelegramChatChannel(long chatId, TelegramSender sender, ChatRenderer renderer) : IChatChannel
{
private static readonly HashSet<string> PhotoExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
public ChatChannelLimits Limits { get; } = new(TelegramLimits.MaxPhotoBytes, TelegramLimits.MaxDocumentBytes, TelegramLimits.MaxCaptionLength);
public async Task<ChatDelivery> SendTextAsync(string markdown, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(markdown);
var (first, complete) = await renderer.RunInOrderAsync(async ct =>
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken);
return await renderer.SendReplyAsync(markdown, linked.Token).ConfigureAwait(false);
}).ConfigureAwait(false);
if (!complete) throw new ChatDeliveryException("Telegram did not accept the message.");
return new ChatDelivery(null, first?.ToString(CultureInfo.InvariantCulture));
}
public Task<ChatDelivery> SendFileAsync(ChatFile file, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(file);
return renderer.RunInOrderAsync(async ct =>
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken);
var asPhoto = file.Kind == ChatFileKind.Photo || (file.Kind == ChatFileKind.Auto && LooksLikePhoto(file.Path));
var result = await sender.SendFileAsync(chatId, file.Path, asPhoto, file.Caption, linked.Token).ConfigureAwait(false);
if (result.MessageId is { } id) return Delivered(asPhoto, id);
if (asPhoto && file.Kind == ChatFileKind.Auto && result.PhotoRejected)
{
// Telegram refuses some images as photos (dimensions, aspect ratio): send the file as a document instead.
result = await sender.SendFileAsync(chatId, file.Path, asPhoto: false, file.Caption, linked.Token).ConfigureAwait(false);
if (result.MessageId is { } documentId) return Delivered(false, documentId);
}
throw new ChatDeliveryException($"Telegram refused the file: {result.Error}");
});
}
private bool LooksLikePhoto(string path) =>
PhotoExtensions.Contains(Path.GetExtension(path)) && new FileInfo(path) is { Exists: true } info && info.Length <= Limits.MaxPhotoBytes;
private static ChatDelivery Delivered(bool asPhoto, int messageId) =>
new(asPhoto ? ChatFileKind.Photo : ChatFileKind.Document, messageId.ToString(CultureInfo.InvariantCulture));
}

View File

@@ -0,0 +1,32 @@
using System.Globalization;
namespace Volcanic.AgenticCore.Telegram.Rendering;
/// <summary>Bot API size limits (cloud Bot API server) and size text for users and the model.</summary>
public static class TelegramLimits
{
private const long MiB = 1024 * 1024;
/// <summary>Bots can download files up to 20 MB.</summary>
public const long MaxDownloadBytes = 20 * MiB;
/// <summary>Largest photo a bot can upload with <c>sendPhoto</c>.</summary>
public const long MaxPhotoBytes = 10 * MiB;
/// <summary>Largest file a bot can upload with <c>sendDocument</c>.</summary>
public const long MaxDocumentBytes = 50 * MiB;
/// <summary>Longest photo or document caption, in characters.</summary>
public const int MaxCaptionLength = 1024;
/// <summary>Megabytes with one decimal, e.g. <c>21.0</c>.</summary>
public static string Megabytes(long bytes) => (bytes / (double)MiB).ToString("0.0", CultureInfo.InvariantCulture);
/// <summary><c>5 bytes</c>, <c>1.5 KB</c>, <c>1.2 MB</c>.</summary>
public static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes.ToString(CultureInfo.InvariantCulture)} bytes",
< MiB => $"{(bytes / 1024.0).ToString("0.0", CultureInfo.InvariantCulture)} KB",
_ => $"{Megabytes(bytes)} MB",
};
}

View File

@@ -0,0 +1,76 @@
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;
public sealed class SendToolChatTests
{
private static async Task WriteAsync(ChatHost host, string relative)
{
var chat = await host.ChatAsync();
var path = Path.Combine(chat.Session.Cwd, relative);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllBytesAsync(path, [1, 2, 3]);
}
[Fact]
public async Task A_sent_png_arrives_as_a_photo_before_the_answer_and_the_status_moves_below_it()
{
await using var host = new ChatHost();
await WriteAsync(host, "out/chart.png");
host.Faux.Enqueue(
FauxTurn.Tool("send", new { path = "out/chart.png", caption = "Sales by month" }),
FauxTurn.Say("Here is the chart.").WithDelay(TimeSpan.FromMilliseconds(200))); // lets the status update render
await host.SendAsync("chart please");
await host.SettleAsync();
var upload = Assert.Single(host.Bot.Uploads);
Assert.Equal(("sendPhoto", "chart.png", "Sales by month"), (upload.Method, upload.FileName, upload.Caption));
Assert.Equal(new byte[] { 1, 2, 3 }, upload.Bytes);
var requests = host.Bot.Requests.ToList();
var uploadAt = requests.FindIndex(r => r is SendPhotoRequest);
var answerAt = requests.FindIndex(r => r is SendMessageRequest m && m.Text.Contains("Here is the chart.", StringComparison.Ordinal));
Assert.InRange(uploadAt, 0, answerAt - 1);
// the status message was deleted and sent again below the photo, before the answer
Assert.Contains(requests.Skip(uploadAt + 1).Take(answerAt - uploadAt - 1), r => r is DeleteMessageRequest);
Assert.Contains(requests.Skip(uploadAt + 1).Take(answerAt - uploadAt - 1), r => r is SendMessageRequest m && m.Text.StartsWith("⚙️", StringComparison.Ordinal));
}
[Fact]
public async Task A_sent_pdf_arrives_as_a_document()
{
await using var host = new ChatHost();
await WriteAsync(host, "report.pdf");
host.Faux.Enqueue(FauxTurn.Tool("send", new { path = "report.pdf" }), FauxTurn.Say("Done."));
await host.SendAsync("the report");
await host.SettleAsync();
Assert.Equal(("sendDocument", "report.pdf"), (Assert.Single(host.Bot.Uploads).Method, host.Bot.Uploads[0].FileName));
var chat = await host.ChatAsync();
Assert.Equal("Sent report.pdf as document.", chat.Session.Messages.OfType<ToolResultMessage>().Single().Text);
}
[Fact]
public async Task A_rejected_photo_is_resent_as_a_document()
{
await using var host = new ChatHost();
await WriteAsync(host, "out/chart.png");
host.Bot.OnSend = r => { if (r is SendPhotoRequest) throw new ApiRequestException("Bad Request: PHOTO_INVALID_DIMENSIONS", 400); };
host.Faux.Enqueue(FauxTurn.Tool("send", new { path = "out/chart.png" }), FauxTurn.Say("Done."));
await host.SendAsync("chart please");
await host.SettleAsync();
Assert.Single(host.Bot.Requests.OfType<SendPhotoRequest>());
Assert.Equal("sendDocument", Assert.Single(host.Bot.Uploads).Method);
var chat = await host.ChatAsync();
var result = chat.Session.Messages.OfType<ToolResultMessage>().Single();
Assert.False(result.IsError);
Assert.Equal("Sent out/chart.png as document.", result.Text);
}
}

View File

@@ -0,0 +1,106 @@
using Telegram.Bot.Exceptions;
using Telegram.Bot.Requests;
using Telegram.Bot.Types.Enums;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Tests.Support;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class TelegramChatChannelTests
{
private static async Task<string> WorkspaceFile(ChatHost host, string relative, byte[]? bytes = null)
{
var chat = await host.ChatAsync();
var path = Path.Combine(chat.Session.Cwd, relative);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllBytesAsync(path, bytes ?? [1, 2, 3]);
return path;
}
[Theory]
[InlineData(5L, "5 bytes")]
[InlineData(1536L, "1.5 KB")]
[InlineData(1_258_291L, "1.2 MB")]
public void FormatSize_uses_bytes_kilobytes_or_megabytes(long bytes, string expected) =>
Assert.Equal(expected, TelegramLimits.FormatSize(bytes));
[Fact]
public void Megabytes_has_one_decimal() => Assert.Equal("21.0", TelegramLimits.Megabytes(21L * 1024 * 1024));
[Fact]
public async Task Limits_are_the_bot_api_limits()
{
await using var host = new ChatHost();
var chat = await host.ChatAsync();
Assert.Equal(new ChatChannelLimits(10L * 1024 * 1024, 50L * 1024 * 1024, 1024), chat.Channel.Limits);
Assert.Same(chat.Channel, chat.Session.Channel);
}
[Fact]
public async Task SendTextAsync_while_idle_delivers_html_split_past_4096()
{
await using var host = new ChatHost();
var chat = await host.ChatAsync();
var markdown = "**Reminder**\n\n" + string.Join("\n\n", Enumerable.Repeat(new string('a', 1000), 6));
var delivery = await chat.Channel.SendTextAsync(markdown, CancellationToken.None);
Assert.False(chat.Session.IsStreaming);
Assert.True(host.Bot.Sent.Count >= 2);
Assert.All(host.Bot.Sent, s => Assert.Equal(ParseMode.Html, s.ParseMode));
Assert.Contains("<b>Reminder</b>", host.Bot.Sent[0].Text, StringComparison.Ordinal);
Assert.Null(delivery.SentAs);
Assert.Equal(host.Bot.CurrentTexts.Keys.Min().ToString(System.Globalization.CultureInfo.InvariantCulture), delivery.MessageId);
Assert.Empty(chat.Session.Messages); // never persisted
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task Auto_png_goes_as_a_photo()
{
await using var host = new ChatHost();
var chat = await host.ChatAsync();
var path = await WorkspaceFile(host, "chart.png");
var delivery = await chat.Channel.SendFileAsync(new ChatFile(path, ChatFileKind.Auto, "Sales"), CancellationToken.None);
Assert.Equal(ChatFileKind.Photo, delivery.SentAs);
var upload = Assert.Single(host.Bot.Uploads);
Assert.Equal(("sendPhoto", "Sales"), (upload.Method, upload.Caption));
Assert.Equal(upload.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture), delivery.MessageId);
}
[Fact]
public async Task Auto_non_photo_goes_as_a_document()
{
await using var host = new ChatHost();
var chat = await host.ChatAsync();
var path = await WorkspaceFile(host, "anim.gif");
var delivery = await chat.Channel.SendFileAsync(new ChatFile(path), CancellationToken.None);
Assert.Equal(ChatFileKind.Document, delivery.SentAs);
Assert.Equal("sendDocument", Assert.Single(host.Bot.Uploads).Method);
}
[Fact]
public async Task Auto_photo_rejected_with_a_400_falls_back_to_a_document()
{
await using var host = new ChatHost();
var chat = await host.ChatAsync();
var path = await WorkspaceFile(host, "wide.png");
host.Bot.OnSend = r => { if (r is SendPhotoRequest) throw new ApiRequestException("Bad Request: PHOTO_INVALID_DIMENSIONS", 400); };
var delivery = await chat.Channel.SendFileAsync(new ChatFile(path), CancellationToken.None);
Assert.Equal(ChatFileKind.Document, delivery.SentAs);
Assert.Equal("sendDocument", Assert.Single(host.Bot.Uploads).Method);
}
[Fact]
public async Task Explicit_photo_rejected_is_not_resent()
{
await using var host = new ChatHost();
var chat = await host.ChatAsync();
var path = await WorkspaceFile(host, "wide.png");
host.Bot.OnSend = r => { if (r is SendPhotoRequest) throw new ApiRequestException("Bad Request: PHOTO_INVALID_DIMENSIONS", 400); };
var ex = await Assert.ThrowsAsync<ChatDeliveryException>(() => chat.Channel.SendFileAsync(new ChatFile(path, ChatFileKind.Photo), CancellationToken.None));
Assert.Equal("Telegram refused the file: Bad Request: PHOTO_INVALID_DIMENSIONS", ex.Message);
Assert.Empty(host.Bot.Uploads);
}
}