forked from admin/VolcanicAgenticCore
While on, the admin's updates are routed as the target user's (allow-list, chat, workspace, session, settings, jobs) and the target chat's output goes to the admin's chat. The target's own updates are dropped with a notice that an admin is working with their account. In memory; /impersonate 0 stops.
142 lines
7.0 KiB
C#
142 lines
7.0 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. 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"/>).
|
|
/// </summary>
|
|
public sealed class UpdateRouter(
|
|
TelegramOptions options,
|
|
ChatRegistry chats,
|
|
ChatCommands commands,
|
|
TelegramSender sender,
|
|
InboundComposer composer,
|
|
ILogger<UpdateRouter> logger,
|
|
Impersonation? impersonation = 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 }:
|
|
await OnMessageAsync(message, 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 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);
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|