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:
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user