Streamable HTTP/SSE servers from the workspace's .volcanic/mcp/servers.json. Headers are sealed with AES-GCM; the chat and the visible fields are the associated data, so only /mcp_add|remove|tools change servers. Tools <server>__<tool> go live in the session. An unreachable server is skipped and reported in the system prompt. Keys are redacted from tool results, and Telegram deletes messages that carry one.
161 lines
8.3 KiB
C#
161 lines
8.3 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Telegram.Bot.Types;
|
|
using Telegram.Bot.Types.Enums;
|
|
using Volcanic.AgenticCore.Telegram.Groups;
|
|
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. The admin's <c>/impersonate</c> is handled first; while it is on, the admin's
|
|
/// updates are routed as the impersonated user's (see <see cref="Impersonation"/>). Group messages and the bot's own
|
|
/// membership changes go to the <see cref="GroupFeed"/> before any allow-list check (every member's messages are
|
|
/// classified); without one (no OpenRouter key) they are dropped. Nothing is ever sent to a group.
|
|
/// </summary>
|
|
public sealed class UpdateRouter(
|
|
TelegramOptions options,
|
|
ChatRegistry chats,
|
|
ChatCommands commands,
|
|
TelegramSender sender,
|
|
InboundComposer composer,
|
|
ILogger<UpdateRouter> logger,
|
|
Impersonation? impersonation = null,
|
|
GroupFeed? groups = null) : IUpdateRouter
|
|
{
|
|
/// <summary>The id the bot acts for: the impersonated user's for the admin while impersonating, else <paramref name="id"/>.</summary>
|
|
private long Effective(long id) => impersonation?.EffectiveId(id) ?? id;
|
|
|
|
public async Task RouteAsync(Update update, CancellationToken cancellationToken)
|
|
{
|
|
switch (update)
|
|
{
|
|
case { CallbackQuery: { } query }:
|
|
await OnCallbackAsync(query, cancellationToken).ConfigureAwait(false);
|
|
break;
|
|
case { Message: { } message } when GroupFeed.IsGroup(message.Chat):
|
|
if (groups is not null) await groups.OnMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
|
else logger.LogDebug("Ignored a message in group {ChatId}: group triggers are off", message.Chat.Id);
|
|
break;
|
|
case { Message: { } message }:
|
|
await OnMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
|
break;
|
|
case { MyChatMember: { } member }:
|
|
if (groups is not null) await groups.OnMyChatMemberAsync(member, cancellationToken).ConfigureAwait(false);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private async Task OnCallbackAsync(CallbackQuery query, CancellationToken cancellationToken)
|
|
{
|
|
var userId = Effective(query.From.Id);
|
|
if (!options.IsAllowed(userId))
|
|
{
|
|
logger.LogWarning("Ignored a button press from user {UserId} (not allowed)", userId);
|
|
return;
|
|
}
|
|
if (impersonation?.IsBlocked(query.From.Id) == true)
|
|
{
|
|
await sender.AnswerCallbackAsync(query.Id, Impersonation.BlockedNotice, cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
// The impersonated chat's question first, then the admin's own (asked before impersonation started).
|
|
var resolved = query.Message is { } message && query.Data is { } data
|
|
&& ((chats.Find(Effective(message.Chat.Id)) is { } chat && chat.Ui.TryResolveCallback(data))
|
|
|| (Effective(message.Chat.Id) != message.Chat.Id && chats.Find(message.Chat.Id) is { } own && own.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 (await TryImpersonateAsync(message, cancellationToken).ConfigureAwait(false)) return;
|
|
var userId = message.From?.Id is { } from ? Effective(from) : (long?)null;
|
|
if (!options.IsAllowed(userId))
|
|
{
|
|
logger.LogWarning("Ignored a message from user {UserId} ({Username}) in chat {ChatId}: not in the allow-list",
|
|
userId, 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 (impersonation?.IsBlocked(message.From?.Id) == true)
|
|
{
|
|
// Straight to the user's own chat: its output goes to the admin meanwhile.
|
|
await sender.SendTextAsync(message.Chat.Id, Impersonation.BlockedNotice, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (!InboundComposer.IsSupported(message))
|
|
{
|
|
await sender.SendTextAsync(message.Chat.Id, InboundComposer.UnsupportedReply, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
var chat = await chats.GetAsync(Effective(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 { } parsed)
|
|
{
|
|
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);
|
|
if (RemoteMcpSetup.CarriesKey(parsed.Name, parsed.Args)) _ = DeleteQuietlyAsync(message);
|
|
return;
|
|
}
|
|
if (chat.Ui.IsWaitingForInput && chat.Ui.TryResolveInput(text, out var secret))
|
|
{
|
|
if (secret) _ = DeleteQuietlyAsync(message);
|
|
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);
|
|
}
|
|
|
|
/// <summary>The admin's <c>/impersonate <user_id></c> in a private chat, checked before the allow-list. False otherwise.</summary>
|
|
private async Task<bool> TryImpersonateAsync(Message message, CancellationToken cancellationToken)
|
|
{
|
|
if (impersonation is null || !impersonation.IsAdmin(message.From?.Id) || message.Chat.Type != ChatType.Private
|
|
|| InboundComposer.HasAttachment(message) || message.ForwardOrigin is not null
|
|
|| ChatCommands.Parse(message.Text ?? "") is not { Name: "impersonate" } command)
|
|
return false;
|
|
var reply = impersonation.Apply(command.Args);
|
|
logger.LogInformation("Admin {AdminId}: impersonating {Target}", message.From!.Id, impersonation.Target?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "nobody");
|
|
await sender.SendTextAsync(message.Chat.Id, reply, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Removes a message that carried a key (best effort; the sender logs a failure).</summary>
|
|
private Task DeleteQuietlyAsync(Message message) => sender.DeleteAsync(message.Chat.Id, message.Id, CancellationToken.None);
|
|
}
|