Files
Playa/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs
EugeneTes 6ab98a8155 fix(telegram): address review findings
- A speech or download timeout (TaskCanceledException nobody asked for) is a failure
  with the user notice, not a silent shutdown; OpenAiSpeechToText maps it to
  "the request timed out."; UpdateRouter only treats the caller's cancellation as shutdown.
- send: WorkspacePaths resolves the real path component by component (realpath),
  closing the directory-link-inside-target bypass; `.` reports a directory.
- Image documents are attached inline only when their magic bytes are JPEG/PNG/GIF/WebP,
  under the detected type.
- Audio goes to STT as an ASCII audio.<ext> name.
- MCP Browser section mentions send only when it is active.
- RunInOrderAsync takes a cancellation token; queued work is skipped once disposal gives up;
  SendReplyAsync stays private behind ReplyInOrderAsync.
- Speech HttpClient handler recycles pooled connections; BuiltInTools.Send();
  IExtensionHost.Channel is abstract; WorkspacePaths in its own file.
- Tests: timeouts, symlink chains (Linux), sniffing, channel delivery failures,
  queued-work cancellation, gated voice ordering. Spec and docs updated.
2026-09-26 22:35:20 +02:00

108 lines
4.6 KiB
C#

using Microsoft.Extensions.Logging;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Polling;
using Volcanic.AgenticCore.Telegram.Rendering;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// Routes an update to its chat: only allow-listed users in private chats get through; button presses and answers go to
/// the chat's pending dialog, bot commands run directly, everything else goes through the chat's <see cref="ChatInbox"/>:
/// the router takes a lease in update order, composes the message in the background, and the inbox turns the batch into
/// a prompt that runs in the background.
/// </summary>
public sealed class UpdateRouter(
TelegramOptions options,
ChatRegistry chats,
ChatCommands commands,
TelegramSender sender,
InboundComposer composer,
ILogger<UpdateRouter> logger) : IUpdateRouter
{
public async Task RouteAsync(Update update, CancellationToken cancellationToken)
{
switch (update)
{
case { CallbackQuery: { } query }:
await OnCallbackAsync(query, cancellationToken).ConfigureAwait(false);
break;
case { Message: { } message }:
await OnMessageAsync(message, cancellationToken).ConfigureAwait(false);
break;
}
}
private async Task OnCallbackAsync(CallbackQuery query, CancellationToken cancellationToken)
{
if (!options.IsAllowed(query.From.Id))
{
logger.LogWarning("Ignored a button press from user {UserId} (not allowed)", query.From.Id);
return;
}
var resolved = query.Message is { } message && query.Data is { } data
&& chats.Find(message.Chat.Id) is { } chat && chat.Ui.TryResolveCallback(data);
await sender.AnswerCallbackAsync(query.Id, resolved ? null : "This question has expired.", cancellationToken).ConfigureAwait(false);
}
private async Task OnMessageAsync(Message message, CancellationToken cancellationToken)
{
if (!options.IsAllowed(message.From?.Id))
{
logger.LogWarning("Ignored a message from user {UserId} ({Username}) in chat {ChatId}: not in the allow-list",
message.From?.Id, message.From?.Username, message.Chat.Id);
return;
}
if (message.Chat.Type != ChatType.Private)
{
logger.LogInformation("Ignored a message in non-private chat {ChatId}", message.Chat.Id);
return;
}
if (!InboundComposer.IsSupported(message))
{
await sender.SendTextAsync(message.Chat.Id, InboundComposer.UnsupportedReply, cancellationToken: cancellationToken).ConfigureAwait(false);
return;
}
var chat = await chats.GetAsync(message.Chat.Id, cancellationToken).ConfigureAwait(false);
var text = message.Text ?? message.Caption ?? "";
// Commands and pending answers are checked before the inbox, and only for messages without an attachment that
// weren't forwarded: a forwarded message is content, never a command or an answer.
if (!InboundComposer.HasAttachment(message) && message.ForwardOrigin is null)
{
if (ChatCommands.Parse(text) is not null)
{
if (await commands.TryHandleAsync(chat, text, cancellationToken).ConfigureAwait(false)) return;
// Not one of ours: straight to the session, without headers or the window, so extension commands still parse.
_ = chat.PromptInBackground(text);
return;
}
if (chat.Ui.IsWaitingForInput && chat.Ui.TryResolveInput(text)) return;
}
// Take the lease now, in update order; compose (downloads) in the background so polling isn't blocked.
var lease = chat.Inbox.Begin(message.Id);
_ = Task.Run(async () =>
{
using (lease)
{
try
{
lease.Complete(await composer.ComposeAsync(chat, message, cancellationToken).ConfigureAwait(false));
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// shutting down: the lease completes empty
}
catch (Exception ex)
{
logger.LogWarning(ex, "Reading message {MessageId} in chat {ChatId} failed", message.Id, chat.ChatId);
chat.Report("I could not read that message.");
}
}
}, CancellationToken.None);
}
}