forked from admin/VolcanicAgenticCore
- 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.
108 lines
4.6 KiB
C#
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);
|
|
}
|
|
}
|