From 92a0061fddbec4ec33b6300a069e3941781ebe87 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Mon, 5 Oct 2026 07:24:07 +0000 Subject: [PATCH] feat(telegram): ADMIN_USER_ID may /impersonate another user 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. --- .env.example | 3 + CLAUDE.md | 4 +- docker-compose.yml | 1 + docs/docker.md | 1 + .../specs/2026-09-25-telegram-host-design.md | 2 + docs/telegram-bot.md | 20 +- .../BotSetupService.cs | 22 ++- .../Chats/ChatCommands.cs | 5 +- .../Chats/ChatRegistry.cs | 15 +- .../Chats/Impersonation.cs | 47 +++++ .../Chats/TelegramChatChannel.cs | 6 +- .../Chats/TelegramUi.cs | 9 +- .../Chats/UpdateRouter.cs | 50 ++++- .../Volcanic.AgenticCore.Telegram/Program.cs | 2 + .../Rendering/ChatRenderer.cs | 33 ++-- .../TelegramOptions.cs | 9 + .../BotSetupServiceTests.cs | 19 ++ .../ImpersonationTests.cs | 178 ++++++++++++++++++ .../Support/ChatHost.cs | 8 +- .../TelegramChatChannelTests.cs | 2 +- 20 files changed, 398 insertions(+), 38 deletions(-) create mode 100644 hosts/Volcanic.AgenticCore.Telegram/Chats/Impersonation.cs create mode 100644 tests/Volcanic.AgenticCore.Telegram.Tests/ImpersonationTests.cs diff --git a/.env.example b/.env.example index cd3b0b6..50529f2 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ TELEGRAM_BOT_TOKEN= TELEGRAM_ALLOWED_USER_IDS= +# Telegram user id that may /impersonate other users (empty = off). +ADMIN_USER_ID= + # Model every chat uses (empty = the compose default), log level, container time zone. TELEGRAM_MODEL= LOG_LEVEL=Information diff --git a/CLAUDE.md b/CLAUDE.md index 871548c..074537f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,9 @@ Each chat's time zone lives in `Chats/ChatSettingsStore` (`/telegram/c feeds the session's zone and the timestamp header. `Scheduling/TelegramJobDelivery` implements `IJobDelivery` (texts through the chat's `IChatChannel`, prompts as session follow-ups with the timestamp header, never through the inbox) and `SchedulerHostedService` starts the scheduler; `/jobs` and `/cancel` list and cancel a chat's jobs. -Answers go out complete, not streamed (`Rendering/`). Tests drive the host through `Support/FakeBotClient` and `ChatHost`. +`ADMIN_USER_ID` (`Telegram:AdminUserId`) may `/impersonate ` (`Chats/Impersonation`, in memory, `0` stops): the +router then handles the admin's updates as the target's, and the target chat's renderer delivers to the admin's chat +(`ChatRenderer.DeliveryChatId`); the target's own updates get `Impersonation.BlockedNotice` instead. Answers go out complete, not streamed (`Rendering/`). Tests drive the host through `Support/FakeBotClient` and `ChatHost`. ## Deployment diff --git a/docker-compose.yml b/docker-compose.yml index d32001f..3aac073 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: - STT_MODEL=${STT_MODEL:-} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} # Google Calendar (calendar spec) - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} + - ADMIN_USER_ID=${ADMIN_USER_ID:-} # may /impersonate other users (telegram-bot.md) - DEFAULT_TIMEZONE=${DEFAULT_TIMEZONE:-} # chats' default zone (scheduler spec ยง7); empty โ†’ TZ - PROCESS_ENV_ALLOW=${PROCESS_ENV_ALLOW:-} # deny-listed variables the agent may see, e.g. GH_TOKEN # Playwright MCP preset (MCP spec): the preinstalled binary, no npx download. Set PLAYWRIGHT_ENABLED=false for an diff --git a/docs/docker.md b/docs/docker.md index 30c6206..21de767 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -68,6 +68,7 @@ The compose file maps them into the container. |---|---|---|---| | `TELEGRAM_BOT_TOKEN` | โ€” | Required. The token from @BotFather | No, and redacted from tool output | | `TELEGRAM_ALLOWED_USER_IDS` | โ€” | Required. Comma-separated Telegram user ids; the first one is the owner (the `๐ŸŸข Online.` message, Claude re-login prompts) | Yes | +| `ADMIN_USER_ID` | โ€” | Telegram user id that may `/impersonate` other users ([telegram-bot.md](telegram-bot.md#impersonation)) | Yes | | `TELEGRAM_MODEL` | `claude-code/claude-opus-5-5` | Model every chat uses; compose passes it as `Telegram__Model` | Yes, as `Telegram__Model` | | `TZ` | `UTC` | The container's time zone | The agent's `bash` gets the chat's zone as `TZ` instead | | `LOG_LEVEL` | `Information` | Passed as `Logging__LogLevel__Default` | Yes | diff --git a/docs/superpowers/specs/2026-09-25-telegram-host-design.md b/docs/superpowers/specs/2026-09-25-telegram-host-design.md index f2f7daa..ef2eb3f 100644 --- a/docs/superpowers/specs/2026-09-25-telegram-host-design.md +++ b/docs/superpowers/specs/2026-09-25-telegram-host-design.md @@ -19,6 +19,7 @@ Chat with the agent through a Telegram bot. A new host project in this repo, run | Tools | All built-in tools; each chat works in its own directory `/` | | Access | Mandatory user-id allow-list; empty list = refuse to start; other users are ignored silently | | Chats | Private chats only | +| Impersonation | Optional `AdminUserId` (`ADMIN_USER_ID`) may `/impersonate ` (`0` stops, in memory). The admin's updates are then routed as the target's (allow-list, chat, workspace, session, settings, jobs) and the target chat's output (replies, questions, files, scheduled deliveries) goes to the admin's chat. The target's own updates are dropped with a "an admin is working with your account" notice | Out of scope: webhooks, Docker/Coolify (see `2026-09-26-docker-deployment-design.md`), voice and documents, group chats, several bot tokens, a custom polling loop. @@ -30,6 +31,7 @@ Out of scope: webhooks, Docker/Coolify (see `2026-09-26-docker-deployment-design - `AllowedUserIds`, required, non-empty (`Telegram__AllowedUserIds__0=123`, or `TELEGRAM_ALLOWED_USER_IDS=1,2`). - `WorkspaceRoot`, default `~/.volcanic/telegram-workspaces`. - `Model`, default `claude-code/claude-opus-5-5`. +- `AdminUserId` (also `ADMIN_USER_ID`), optional, positive: the user who may `/impersonate`. At start the host logs whether the `claude` CLI was found; a missing CLI is a warning (the doctor command explains). diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index 07d06df..3e72ae4 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -29,6 +29,7 @@ settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section |---|---|---| | `TELEGRAM_BOT_TOKEN` / `Telegram:BotToken` | โ€” | Required | | `TELEGRAM_ALLOWED_USER_IDS` / `Telegram:AllowedUserIds` | โ€” | Required, comma separated. The bot refuses to start without it | +| `ADMIN_USER_ID` / `Telegram:AdminUserId` | โ€” (off) | The Telegram user id that may use `/impersonate` (see [Impersonation](#impersonation)). Need not be on the allow-list. Must be positive | | `Telegram:WorkspaceRoot` | `~/.volcanic/telegram-workspaces` | Chat `` works in `/`; put `AGENTS.md`, `PERSONA.md`, `MEMORY.md` there | | `Telegram:Model` | `claude-code/claude-opus-5-5` | Any `provider/model` the Claude Code provider serves | | `Telegram:EditInterval` | `00:00:01.5` | Minimum time between edits of the "Working..." status message | @@ -205,6 +206,22 @@ the current format, and keeps the original next to it as `jobs.json.secretary-`; `/impersonate 0` (or the admin's +own id) stops. The command shows up in the admin's command menu only, and from anyone else it is an ordinary unknown +command passed to the agent. + +While it is on, the bot treats the admin's messages, commands, button presses and answers exactly as the target user's: +the allow-list check (impersonating someone outside it means the bot ignores the admin until they stop), the chat, its +workspace, session, time zone, jobs and calendar. Everything the target's chat sends goes to the admin's chat instead: +replies, questions, files, notices and scheduled deliveries, including runs started by the target user's own messages. +Meanwhile the target user is blocked: whatever they send (messages, commands, button presses) is dropped, and the bot +answers "๐Ÿ”’ An admin is working with your account right now. Please try again later." The admin's own chat (their own jobs, for +example) keeps working as before. + +Impersonation is kept in memory only: a restart ends it. + ### Google Calendar Each chat connects its own Google account with `/calendar_connect`, and the agent's `calendar` tool only uses that @@ -215,7 +232,8 @@ storage and time zones are in [google-calendar.md](google-calendar.md). ## Security The agent has the built-in tools, including `bash`, and runs them as the bot's OS user. Only allow-listed user ids -are served (everyone else is ignored and logged), and only in private chats. Run it in the container image +are served (everyone else is ignored and logged), and only in private chats. `ADMIN_USER_ID` can act as any +allow-listed user with `/impersonate`, so it gets that user's workspace, sessions, jobs and connected calendar. Run it in the container image ([docker.md](docker.md)), which is the recommended setup, or at least as a dedicated user, and treat the workspace root as writable by anyone on the allow-list. diff --git a/hosts/Volcanic.AgenticCore.Telegram/BotSetupService.cs b/hosts/Volcanic.AgenticCore.Telegram/BotSetupService.cs index 7df0e5c..3ee4014 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/BotSetupService.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/BotSetupService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Telegram.Bot; +using Telegram.Bot.Types; using Volcanic.AgenticCore.Extensions.GoogleCalendar; using Volcanic.AgenticCore.Telegram.Chats; using Volcanic.AgenticCore.Telegram.Rendering; @@ -30,6 +31,23 @@ public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions opti } } + /// + /// The admin's chat also lists /impersonate. Best-effort and not retried: it fails while the admin has never + /// started the bot, and the command works without the menu entry. + /// + private async Task SetAdminMenuAsync(IEnumerable menu, CancellationToken stoppingToken) + { + if (options.AdminUserId is not { } admin) return; + try + { + await bot.SetMyCommands([.. menu, ChatCommands.ImpersonateCommand], BotCommandScope.Chat(admin), cancellationToken: stoppingToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Setting the admin's command menu failed"); + } + } + /// True once getMe and setMyCommands succeeded; false when stopped first. private async Task SetUpAsync(CancellationToken stoppingToken) { @@ -39,8 +57,10 @@ public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions opti try { var me = await bot.GetMe(stoppingToken).ConfigureAwait(false); - await bot.SetMyCommands(GoogleCalendarSetup.MenuFor(ChatCommands.Menu, calendar), cancellationToken: stoppingToken).ConfigureAwait(false); + var menu = GoogleCalendarSetup.MenuFor(ChatCommands.Menu, calendar); + await bot.SetMyCommands(menu, cancellationToken: stoppingToken).ConfigureAwait(false); logger.LogInformation("Bot @{Username} ({Id}) is ready", me.Username, me.Id); + await SetAdminMenuAsync(menu, stoppingToken).ConfigureAwait(false); return true; } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs index 1f06336..3cf2b44 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs @@ -26,6 +26,9 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon new() { Command = "help", Description = "What this bot does" }, ]; + /// The admin's chat gets plus this (see ). + public static readonly BotCommand ImpersonateCommand = new() { Command = "impersonate", Description = "Act as another user: /impersonate , 0 stops" }; + /// Split /name@bot args. Null when the text is not a command. public static (string Name, string Args)? Parse(string text) { @@ -120,7 +123,7 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon } private Task ReplyAsync(ChatContext chat, string text, CancellationToken cancellationToken) => - sender.SendHtmlAsync(chat.ChatId, TelegramFormatter.ToHtml(text), text, cancellationToken: cancellationToken); + sender.SendHtmlAsync(chat.DeliveryChatId, TelegramFormatter.ToHtml(text), text, cancellationToken: cancellationToken); private static string Help(ChatContext chat) => $""" diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs index 7d6dade..2f00f49 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs @@ -19,6 +19,9 @@ public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui IChatChannel channel) : IAsyncDisposable { public long ChatId => chatId; + + /// The Telegram chat replies go to: , or the admin's while impersonated (see ). + public long DeliveryChatId => renderer.DeliveryChatId; public AgentSession Session => session; public TelegramUi Ui => ui; public ChatRenderer Renderer => renderer; @@ -74,7 +77,8 @@ public sealed class ChatRegistry( GoogleCalendarOptions? calendar = null, ChatSettingsStore? settings = null, ChatTimeZoneService? zones = null, - JobScheduler? scheduler = null) : IAsyncDisposable + JobScheduler? scheduler = null, + Impersonation? impersonation = null) : IAsyncDisposable { private readonly ConcurrentDictionary>> _chats = new(); private readonly ILogger _logger = loggerFactory.CreateLogger(); @@ -114,9 +118,10 @@ public sealed class ChatRegistry( var last = (await factory.ListSessionsAsync(cwd, scope).ConfigureAwait(false)).FirstOrDefault(); var logger = loggerFactory.CreateLogger(); - var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger()); - var ui = new TelegramUi(chatId, sender, renderer); - var channel = new TelegramChatChannel(chatId, sender, renderer); + var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger(), + deliverTo: impersonation is null ? null : () => impersonation.DeliveryChatFor(chatId)); + var ui = new TelegramUi(sender, renderer); + var channel = new TelegramChatChannel(sender, renderer); var extras = new List(); if (calendar is not null) extras.Add(GoogleCalendarSetup.Registration(calendar)); if (zones is not null) extras.Add(ExtensionRegistration.FromInstance(new ChatSettingsExtension(chatId, zones))); @@ -167,7 +172,7 @@ public sealed class ChatRegistry( // Tell the user their message joined the running request (best-effort, fire-and-forget). If the run ends between // this check and the steer, the message starts a new run and still shows ๐Ÿ‘€: harmless. if (chat.Session.IsStreaming) - foreach (var part in parts) _ = sender.ReactAsync(chat.ChatId, part.MessageId, "๐Ÿ‘€"); + foreach (var part in parts) _ = sender.ReactAsync(chat.DeliveryChatId, part.MessageId, "๐Ÿ‘€"); _ = chat.PromptInBackground(text, images.Count > 0 ? images : null); return Task.CompletedTask; } diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/Impersonation.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/Impersonation.cs new file mode 100644 index 0000000..7cd88a9 --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/Impersonation.cs @@ -0,0 +1,47 @@ +using System.Globalization; + +namespace Volcanic.AgenticCore.Telegram.Chats; + +/// +/// The admin's () /impersonate <user_id>. While it is on, the admin's +/// messages and button presses are handled as the target user's: the allow-list check, the chat, its workspace, session, +/// settings and jobs. Everything the target's chat sends (replies, questions, files, scheduled deliveries) goes to the +/// admin's chat instead, until /impersonate 0. The target user's own messages and button presses are blocked +/// meanwhile and answered with . In private chats the chat id is the user id, so one id maps both. +/// Kept in memory: a restart ends it. +/// +public sealed class Impersonation(TelegramOptions options) +{ + /// The reply to the impersonated user while the admin works as them. + public const string BlockedNotice = "๐Ÿ”’ An admin is working with your account right now. Please try again later."; + + private long _target; // 0 = off + + public long? AdminId => options.AdminUserId; + + /// The impersonated user, or null when off. + public long? Target => Volatile.Read(ref _target) is var target and not 0 ? target : null; + + public bool IsAdmin(long? userId) => userId is { } id && id == AdminId; + + /// True for the impersonated user themselves: their updates are blocked while the admin works as them. + public bool IsBlocked(long? userId) => userId is { } id && Target == id; + + /// The user (or private chat) id the bot acts for: the target for the admin while impersonating, else . + public long EffectiveId(long id) => IsAdmin(id) && Target is { } target ? target : id; + + /// Where chat 's output goes: the admin's chat while the admin impersonates it. + public long DeliveryChatFor(long chatId) => Target == chatId && AdminId is { } admin ? admin : chatId; + + /// Handle /impersonate's argument and return the reply for the admin. + public string Apply(string arg) + { + if (!long.TryParse(arg.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var id)) + return "Usage: /impersonate (0 stops). " + (Target is { } current ? $"You are acting as user {current}." : "You are not impersonating anyone."); + if (id == 0 || IsAdmin(id)) + return Interlocked.Exchange(ref _target, 0) is not 0 ? "๐ŸŽญ Impersonation off: you are yourself again." : "You are not impersonating anyone."; + Volatile.Write(ref _target, id); + var reply = $"๐ŸŽญ You are now acting as user {id}: the bot handles your messages as theirs, and their chat's output comes here. /impersonate 0 stops."; + return options.IsAllowed(id) ? reply : reply + $"\nโš ๏ธ User {id} is not in the allow-list, so the bot ignores your messages until you stop."; + } +} diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs index c1ab6a7..eb1572f 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs @@ -8,7 +8,7 @@ namespace Volcanic.AgenticCore.Telegram.Chats; /// One chat's . Every delivery goes through the chat renderer's queue /// (), so it can't overtake rendered output, and works while no run is active. /// -public sealed class TelegramChatChannel(long chatId, TelegramSender sender, ChatRenderer renderer) : IChatChannel +public sealed class TelegramChatChannel(TelegramSender sender, ChatRenderer renderer) : IChatChannel { private static readonly HashSet PhotoExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" }; @@ -28,12 +28,12 @@ public sealed class TelegramChatChannel(long chatId, TelegramSender sender, Chat return renderer.RunInOrderAsync(async ct => { var asPhoto = file.Kind == ChatFileKind.Photo || (file.Kind == ChatFileKind.Auto && LooksLikePhoto(file.Path)); - var result = await sender.SendFileAsync(chatId, file.Path, asPhoto, file.Caption, ct).ConfigureAwait(false); + var result = await sender.SendFileAsync(renderer.DeliveryChatId, file.Path, asPhoto, file.Caption, ct).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, ct).ConfigureAwait(false); + result = await sender.SendFileAsync(renderer.DeliveryChatId, file.Path, asPhoto: false, file.Caption, ct).ConfigureAwait(false); if (result.MessageId is { } documentId) return Delivered(false, documentId); } throw new ChatDeliveryException($"Telegram refused the file: {result.Error}"); diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramUi.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramUi.cs index b0d2182..afadcfb 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramUi.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramUi.cs @@ -9,7 +9,7 @@ namespace Volcanic.AgenticCore.Telegram.Chats; /// for one chat: choices become inline keyboards (callback data ui:<id>:<index>), /// free-text questions a ForceReply prompt answered by the chat's next plain message. /// -public sealed class TelegramUi(long chatId, TelegramSender sender, ChatRenderer renderer) : IUserInterface +public sealed class TelegramUi(TelegramSender sender, ChatRenderer renderer) : IUserInterface { public const string CallbackPrefix = "ui:"; private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMinutes(10); @@ -30,13 +30,14 @@ public sealed class TelegramUi(long chatId, TelegramSender sender, ChatRenderer { var keyboard = new InlineKeyboardMarkup(options.Select((option, i) => new[] { InlineKeyboardButton.WithCallbackData(option, $"{CallbackPrefix}{id}:{i}") })); - var messageId = await sender.SendTextAsync(chatId, title, keyboard, cancellationToken).ConfigureAwait(false); + var target = renderer.DeliveryChatId; // the edit below goes to the chat the question went to + var messageId = await sender.SendTextAsync(target, title, keyboard, cancellationToken).ConfigureAwait(false); if (messageId is null) return null; var index = await WaitAsync(tcs, requestOptions, cancellationToken).ConfigureAwait(false); var choice = int.TryParse(index, out var i) && i >= 0 && i < options.Count ? options[i] : null; var outcome = choice is null ? "โ€” no answer" : $"โ†’ {choice}"; - await sender.EditHtmlAsync(chatId, messageId.Value, TelegramFormatter.Escape($"{title}\n{outcome}"), $"{title}\n{outcome}", CancellationToken.None) + await sender.EditHtmlAsync(target, messageId.Value, TelegramFormatter.Escape($"{title}\n{outcome}"), $"{title}\n{outcome}", CancellationToken.None) .ConfigureAwait(false); return choice; } @@ -56,7 +57,7 @@ public sealed class TelegramUi(long chatId, TelegramSender sender, ChatRenderer try { var markup = new ForceReplyMarkup { InputFieldPlaceholder = placeholder }; - if (await sender.SendTextAsync(chatId, title, markup, cancellationToken).ConfigureAwait(false) is null) return null; + if (await sender.SendTextAsync(renderer.DeliveryChatId, title, markup, cancellationToken).ConfigureAwait(false) is null) return null; return await WaitAsync(tcs, requestOptions, cancellationToken).ConfigureAwait(false); } finally diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs index 606d36a..99bc3b4 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs @@ -11,7 +11,8 @@ namespace Volcanic.AgenticCore.Telegram.Chats; /// 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 : /// 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. +/// a prompt that runs in the background. The admin's /impersonate is handled first; while it is on, the admin's +/// updates are routed as the impersonated user's (see ). /// public sealed class UpdateRouter( TelegramOptions options, @@ -19,8 +20,12 @@ public sealed class UpdateRouter( ChatCommands commands, TelegramSender sender, InboundComposer composer, - ILogger logger) : IUpdateRouter + ILogger logger, + Impersonation? impersonation = null) : IUpdateRouter { + /// The id the bot acts for: the impersonated user's for the admin while impersonating, else . + private long Effective(long id) => impersonation?.EffectiveId(id) ?? id; + public async Task RouteAsync(Update update, CancellationToken cancellationToken) { switch (update) @@ -36,22 +41,32 @@ public sealed class UpdateRouter( private async Task OnCallbackAsync(CallbackQuery query, CancellationToken cancellationToken) { - if (!options.IsAllowed(query.From.Id)) + var userId = Effective(query.From.Id); + if (!options.IsAllowed(userId)) { - logger.LogWarning("Ignored a button press from user {UserId} (not allowed)", query.From.Id); + 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(message.Chat.Id) is { } chat && chat.Ui.TryResolveCallback(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 (!options.IsAllowed(message.From?.Id)) + 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", - message.From?.Id, message.From?.Username, message.Chat.Id); + userId, message.From?.Username, message.Chat.Id); return; } if (message.Chat.Type != ChatType.Private) @@ -59,6 +74,12 @@ public sealed class UpdateRouter( 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)) { @@ -66,7 +87,7 @@ public sealed class UpdateRouter( return; } - var chat = await chats.GetAsync(message.Chat.Id, cancellationToken).ConfigureAwait(false); + 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. @@ -104,4 +125,17 @@ public sealed class UpdateRouter( } }, CancellationToken.None); } + + /// The admin's /impersonate <user_id> in a private chat, checked before the allow-list. False otherwise. + private async Task 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; + } } diff --git a/hosts/Volcanic.AgenticCore.Telegram/Program.cs b/hosts/Volcanic.AgenticCore.Telegram/Program.cs index 200d676..586c0ee 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Program.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Program.cs @@ -76,6 +76,7 @@ builder.Services.AddSingleton(_ => new TelegramBotClient(opt builder.Services.AddSingleton(new ReconnectPolicy(options.ReconnectInitialDelay, options.ReconnectMaxDelay)); builder.Services.AddSingleton(); builder.Services.TryAddSingleton(TimeProvider.System); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new ChatSettingsStore( ChatSettingsStore.DefaultPath(agentDir), @@ -114,6 +115,7 @@ else logger.LogInformation("Scheduler: {State}", scheduling.Enabled ? "on, jobs in each chat's .volcanic/scheduler/jobs.json" : "off"); logger.LogInformation("Default time zone: {Zone}", TimeZoneIds.Name(options.ConfiguredTimeZone() ?? TimeZoneInfo.Local)); logger.LogInformation("Allowed users: {Users}. Workspaces under {Root}", string.Join(", ", options.AllowedUserIds), options.WorkspaceRoot); +if (options.AdminUserId is { } admin) logger.LogInformation("Admin (may /impersonate): {Admin}", admin); var polling = host.Services.GetRequiredService(); await host.RunAsync(); // disposes the host diff --git a/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs b/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs index 05dd812..d34bb71 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs @@ -30,6 +30,7 @@ public sealed class ChatRenderer : IAsyncDisposable private const int StatusArgsLength = 80; private readonly long _chatId; + private readonly Func? _deliverTo; private readonly TelegramSender _sender; private readonly TelegramOptions _options; private readonly ILogger _logger; @@ -54,15 +55,18 @@ public sealed class ChatRenderer : IAsyncDisposable private readonly List _recentTools = []; private bool _running; private int? _statusMessageId; + private long _statusChatId; private string? _statusSent; private bool _statusDirty; private bool _statusBuried; private bool Dirty => _statusDirty; - public ChatRenderer(long chatId, TelegramSender sender, TelegramOptions options, ILogger logger, TimeProvider? time = null) + /// Where output goes, read at each send; null = (see ). + public ChatRenderer(long chatId, TelegramSender sender, TelegramOptions options, ILogger logger, TimeProvider? time = null, Func? deliverTo = null) { _chatId = chatId; + _deliverTo = deliverTo; _sender = sender; _options = options; _logger = logger; @@ -70,6 +74,9 @@ public sealed class ChatRenderer : IAsyncDisposable _loop = Task.Run(RunAsync); } + /// The Telegram chat this chat's output goes to now: its own, or the admin's while the admin impersonates it. + public long DeliveryChatId => _deliverTo?.Invoke() ?? _chatId; + /// Queue an event; safe to call from any thread (this is the session subscription). public void Post(AgentSessionEvent e) => Enqueue(new EventItem(e)); @@ -119,7 +126,7 @@ public sealed class ChatRenderer : IAsyncDisposable /// Plain text (no parse mode) in order with rendered output; cut to 4096 characters by the sender. public Task SayAsync(string text, CancellationToken cancellationToken = default) => - RunInOrderAsync(ct => _sender.SendTextAsync(_chatId, text, cancellationToken: ct), cancellationToken); + RunInOrderAsync(ct => _sender.SendTextAsync(DeliveryChatId, text, cancellationToken: ct), cancellationToken); /// /// A Markdown reply rendered like an assistant message (HTML, split past 4096, each chunk retried), in order with @@ -289,15 +296,16 @@ public sealed class ChatRenderer : IAsyncDisposable if (string.IsNullOrWhiteSpace(text)) return (null, true); int? first = null; var complete = true; + var target = DeliveryChatId; foreach (var chunk in TelegramFormatter.Split(text)) { var html = TelegramFormatter.ToHtml(chunk); - var sent = await _sender.SendHtmlAsync(_chatId, html, chunk, cancellationToken: ct).ConfigureAwait(false); + var sent = await _sender.SendHtmlAsync(target, html, chunk, cancellationToken: ct).ConfigureAwait(false); foreach (var delay in ReplyRetryDelays) { if (sent is not null) break; await Task.Delay(delay, ct).ConfigureAwait(false); - sent = await _sender.SendHtmlAsync(_chatId, html, chunk, cancellationToken: ct).ConfigureAwait(false); + sent = await _sender.SendHtmlAsync(target, html, chunk, cancellationToken: ct).ConfigureAwait(false); } if (sent is null) { @@ -315,7 +323,7 @@ public sealed class ChatRenderer : IAsyncDisposable private async Task SendLineAsync(string text, CancellationToken ct) { - if (await _sender.SendHtmlAsync(_chatId, TelegramFormatter.Escape(text), text, cancellationToken: ct).ConfigureAwait(false) is not null) + if (await _sender.SendHtmlAsync(DeliveryChatId, TelegramFormatter.Escape(text), text, cancellationToken: ct).ConfigureAwait(false) is not null) BuryStatus(); } @@ -334,19 +342,22 @@ public sealed class ChatRenderer : IAsyncDisposable _statusDirty = false; if (!_running) return; var html = StatusHtml(); - if (_statusBuried && _statusMessageId is { } buried) + var target = DeliveryChatId; + // Moved to the bottom when buried, and to the other chat when the delivery chat changed (impersonation). + if ((_statusBuried || _statusChatId != target) && _statusMessageId is { } buried) { - await _sender.DeleteAsync(_chatId, buried, ct).ConfigureAwait(false); + await _sender.DeleteAsync(_statusChatId, buried, ct).ConfigureAwait(false); _statusMessageId = null; } _statusBuried = false; if (_statusMessageId is not { } id) { - _statusMessageId = await _sender.SendHtmlAsync(_chatId, html, StripTags(html), cancellationToken: ct).ConfigureAwait(false); + _statusChatId = target; + _statusMessageId = await _sender.SendHtmlAsync(target, html, StripTags(html), cancellationToken: ct).ConfigureAwait(false); _statusSent = html; } - else if (html != _statusSent && await _sender.EditHtmlAsync(_chatId, id, html, StripTags(html), ct).ConfigureAwait(false)) + else if (html != _statusSent && await _sender.EditHtmlAsync(_statusChatId, id, html, StripTags(html), ct).ConfigureAwait(false)) { _statusSent = html; } @@ -358,7 +369,7 @@ public sealed class ChatRenderer : IAsyncDisposable _statusDirty = false; _statusBuried = false; _recentTools.Clear(); - if (_statusMessageId is { } id) await _sender.DeleteAsync(_chatId, id, ct).ConfigureAwait(false); + if (_statusMessageId is { } id) await _sender.DeleteAsync(_statusChatId, id, ct).ConfigureAwait(false); _statusMessageId = null; _statusSent = null; } @@ -385,7 +396,7 @@ public sealed class ChatRenderer : IAsyncDisposable { while (!cts.IsCancellationRequested) { - await _sender.SendTypingAsync(_chatId, cts.Token).ConfigureAwait(false); + await _sender.SendTypingAsync(DeliveryChatId, cts.Token).ConfigureAwait(false); await Task.Delay(_options.TypingInterval, cts.Token).ConfigureAwait(false); } } diff --git a/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs b/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs index ce89239..9bfb8db 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs @@ -14,6 +14,12 @@ public sealed class TelegramOptions /// Telegram user ids allowed to talk to the bot. Required: the agent runs shell commands. public List AllowedUserIds { get; set; } = []; + /// + /// The Telegram user id that may run /impersonate <user_id> (see ); null + /// turns the command off. Also read from ADMIN_USER_ID. + /// + public long? AdminUserId { get; set; } + /// Each chat works in <WorkspaceRoot>/<chatId>. public string WorkspaceRoot { get; set; } = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "telegram-workspaces"); @@ -61,6 +67,8 @@ public sealed class TelegramOptions if (long.TryParse(part, out var id)) AllowedUserIds.Add(id); } + if (AdminUserId is null && long.TryParse(getEnv("ADMIN_USER_ID")?.Trim(), out var admin)) AdminUserId = admin; + // Speech: the environment fills only empty settings, and an empty variable counts as unset. Speech ??= new SpeechOptions(); if (string.IsNullOrWhiteSpace(Speech.ApiKey) && getEnv("OPENAI_API_KEY") is { } key && !string.IsNullOrWhiteSpace(key)) @@ -80,6 +88,7 @@ public sealed class TelegramOptions if (AllowedUserIds.Count == 0) errors.Add("No allowed users: set TELEGRAM_ALLOWED_USER_IDS (comma separated) or Telegram:AllowedUserIds. " + "The bot runs shell commands, so it refuses to start open to everyone."); + if (AdminUserId is <= 0) errors.Add("Telegram:AdminUserId (ADMIN_USER_ID) must be a positive Telegram user id."); if (string.IsNullOrWhiteSpace(WorkspaceRoot)) errors.Add("Telegram:WorkspaceRoot is empty."); if (string.IsNullOrWhiteSpace(Model) || !Model.Contains('/')) errors.Add("Telegram:Model must be 'provider/model-id'."); if (EditInterval <= TimeSpan.Zero || TypingInterval <= TimeSpan.Zero) errors.Add("Telegram intervals must be positive."); diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/BotSetupServiceTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/BotSetupServiceTests.cs index d9dfcb2..d01de9f 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/BotSetupServiceTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/BotSetupServiceTests.cs @@ -58,4 +58,23 @@ public sealed class BotSetupServiceTests Assert.Single(bot.Requests.OfType()); await service.StopAsync(CancellationToken.None); } + + [Fact] + public async Task The_admins_chat_menu_also_lists_impersonate() + { + var bot = new FakeBotClient(); + var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [Updates.AllowedUser], AdminUserId = 5005 }; + var service = new BotSetupService(bot, options, new TelegramSender(bot, NullLogger.Instance), NullLogger.Instance); + + await service.StartAsync(CancellationToken.None); + await service.ExecuteTask!.WaitAsync(TimeSpan.FromSeconds(5)); + await service.StopAsync(CancellationToken.None); + + var menus = bot.Requests.OfType().ToList(); + Assert.Equal(2, menus.Count); + Assert.DoesNotContain(menus[0].Commands, c => c.Command == "impersonate"); + var admin = Assert.IsType(menus[1].Scope); + Assert.Equal(5005, admin.ChatId.Identifier); + Assert.Contains(menus[1].Commands, c => c.Command == "impersonate"); + } } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/ImpersonationTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/ImpersonationTests.cs new file mode 100644 index 0000000..8c8c4b8 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/ImpersonationTests.cs @@ -0,0 +1,178 @@ +using Telegram.Bot.Types.ReplyMarkups; +using Volcanic.AgenticCore.Telegram.Chats; +using Volcanic.AgenticCore.Telegram.Tests.Support; +using Volcanic.AgenticCore.Testing; + +namespace Volcanic.AgenticCore.Telegram.Tests; + +public sealed class ImpersonationTests +{ + private const long Admin = 5005; // not in the allow-list: only /impersonate works for them + + private static ChatHost CreateHost() + { + var host = new ChatHost(); + host.Options.AdminUserId = Admin; + return host; + } + + [Fact] + public async Task The_admin_talks_as_the_impersonated_user_and_gets_the_replies() + { + await using var host = CreateHost(); + host.Faux.Enqueue(FauxTurn.Say("Hi, user 1001.")); + + await host.SendAsync("/impersonate 1001", userId: Admin); + Assert.StartsWith("๐ŸŽญ You are now acting as user 1001", host.LastReply(Admin), StringComparison.Ordinal); + await host.SendAsync("who am I?", userId: Admin); + await host.SettleAsync(Updates.AllowedUser); + + Assert.EndsWith("\nwho am I?", host.Faux.Requests.Single().Messages.Last().Text, StringComparison.Ordinal); + Assert.Equal(host.WorkspaceOf(Updates.AllowedUser), (await host.ChatAsync(Updates.AllowedUser)).Session.Cwd); + Assert.Null(host.Chats.Find(Admin)); // no chat of the admin's own + Assert.Equal("Hi, user 1001.", host.LastReply(Admin)); + Assert.DoesNotContain(host.Bot.Sent, m => m.ChatId.Identifier == Updates.AllowedUser); + } + + [Fact] + public async Task Bot_commands_run_in_the_impersonated_chat_and_answer_the_admin() + { + await using var host = CreateHost(); + + await host.SendAsync("/impersonate 1001", userId: Admin); + await host.SendAsync("/status", userId: Admin); + + Assert.Contains($"Workspace: {host.WorkspaceOf(Updates.AllowedUser)}", host.LastReply(Admin), StringComparison.Ordinal); + } + + [Fact] + public async Task Zero_ends_the_impersonation() + { + await using var host = CreateHost(); + + await host.SendAsync("/impersonate 1001", userId: Admin); + await host.SendAsync("/impersonate 0", userId: Admin); + await host.SendAsync("hello", userId: Admin); + + Assert.Equal("๐ŸŽญ Impersonation off: you are yourself again.", host.LastReply(Admin)); + Assert.Null(host.Impersonation.Target); + Assert.Null(host.Chats.Find(Updates.AllowedUser)); // the admin is not allow-listed, so "hello" was ignored + Assert.Empty(host.Faux.Requests); + } + + [Fact] + public async Task Impersonating_a_user_outside_the_allow_list_is_ignored_like_that_user() + { + await using var host = CreateHost(); + + await host.SendAsync("/impersonate 666", userId: Admin); + Assert.Contains("not in the allow-list", host.LastReply(Admin), StringComparison.Ordinal); + await host.SendAsync("hello", userId: Admin); + + Assert.Null(host.Chats.Find(Updates.Stranger)); + Assert.Empty(host.Faux.Requests); + } + + [Fact] + public async Task Other_users_cannot_impersonate() + { + await using var host = CreateHost(); + host.Faux.Enqueue(FauxTurn.Say("ok")); + + await host.SendAsync("/impersonate 5005"); + await host.SettleAsync(); + + Assert.Null(host.Impersonation.Target); + Assert.Equal("/impersonate 5005", host.Faux.Requests.Single().Messages.Last().Text); // an ordinary unknown command + } + + [Fact] + public async Task Without_an_admin_the_command_is_off() + { + await using var host = new ChatHost(); + host.Options.AllowedUserIds.Add(Admin); + host.Faux.Enqueue(FauxTurn.Say("ok")); + + await host.SendAsync("/impersonate 1001", userId: Admin); + await host.SettleAsync(Admin); + + Assert.Null(host.Impersonation.Target); + Assert.NotNull(host.Chats.Find(Admin)); + } + + [Fact] + public async Task The_impersonated_chats_questions_go_to_the_admin_and_take_their_answers() + { + await using var host = CreateHost(); + await host.SendAsync("/impersonate 1001", userId: Admin); + var chat = await host.ChatAsync(Updates.AllowedUser); + + var answer = chat.Ui.SelectAsync("Pick one", ["red", "green"], null, CancellationToken.None); + await Wait.UntilAsync(() => host.Bot.Sent.Any(s => s.ReplyMarkup is InlineKeyboardMarkup), "the keyboard"); + var question = host.Bot.Sent.Last(s => s.ReplyMarkup is InlineKeyboardMarkup); + Assert.Equal(Admin, question.ChatId.Identifier); + var green = ((InlineKeyboardMarkup)question.ReplyMarkup!).InlineKeyboard.SelectMany(row => row).Single(b => b.Text == "green"); + await host.RouteAsync(Updates.Callback(green.CallbackData!, chatId: Admin, userId: Admin)); + + Assert.Equal("green", await answer.WaitAsync(TimeSpan.FromSeconds(5))); + } + + [Fact] + public async Task Output_returns_to_the_user_when_the_impersonation_ends() + { + await using var host = CreateHost(); + host.Faux.Enqueue(FauxTurn.Say("for you")); + await host.SendAsync("/impersonate 1001", userId: Admin); + await host.SendAsync("/impersonate 0", userId: Admin); + + await host.SendAsync("hi"); + await host.SettleAsync(); + + Assert.Equal("for you", host.LastReply(Updates.AllowedUser)); + } + + [Fact] + public async Task The_impersonated_user_is_told_an_admin_is_working_and_their_message_is_dropped() + { + await using var host = CreateHost(); + await host.SendAsync("/impersonate 1001", userId: Admin); + var toAdmin = host.Bot.Sent.Count(m => m.ChatId.Identifier == Admin); + + await host.SendAsync("hi, it's me"); + + Assert.Equal(Impersonation.BlockedNotice, host.LastReply(Updates.AllowedUser)); + Assert.Equal(toAdmin, host.Bot.Sent.Count(m => m.ChatId.Identifier == Admin)); // nothing reaches the admin + Assert.Null(host.Chats.Find(Updates.AllowedUser)); + Assert.Empty(host.Faux.Requests); + } + + [Fact] + public async Task The_impersonated_users_button_presses_are_blocked() + { + await using var host = CreateHost(); + await host.SendAsync("/status"); // creates the chat + var chat = await host.ChatAsync(); + var answer = chat.Ui.SelectAsync("Pick", ["a"], new(TimeSpan.FromMilliseconds(500)), CancellationToken.None); + await Wait.UntilAsync(() => host.Bot.Sent.Any(s => s.ReplyMarkup is InlineKeyboardMarkup), "the keyboard"); + var data = ((InlineKeyboardMarkup)host.Bot.Sent.Last().ReplyMarkup!).InlineKeyboard.First().First().CallbackData!; + await host.SendAsync("/impersonate 1001", userId: Admin); + + await host.RouteAsync(Updates.Callback(data, Updates.AllowedUser)); + + Assert.Null(await answer.WaitAsync(TimeSpan.FromSeconds(5))); + var reply = Assert.Single(host.Bot.Requests.OfType()); + Assert.Equal(Impersonation.BlockedNotice, reply.Text); + } + + [Fact] + public void Admin_user_id_comes_from_the_environment_and_must_be_positive() + { + var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] }; + options.ApplyEnvironment(name => name == "ADMIN_USER_ID" ? " 42 " : null); + Assert.Equal(42, options.AdminUserId); + Assert.Empty(options.Validate()); + + options.AdminUserId = -1; + Assert.Contains(options.Validate(), e => e.Contains("ADMIN_USER_ID", StringComparison.Ordinal)); + } +} diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs index 93ef9e7..0a997ed 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs @@ -96,13 +96,17 @@ public sealed class ChatHost : IAsyncDisposable _services.GetRequiredService(), clock, NullLogger.Instance); } Zones = new ChatTimeZoneService(Settings, id => Chats!.Find(id)?.Session, clock, Scheduler); - Chats = new ChatRegistry(_services.GetRequiredService(), sender, Options, loggers, time, calendar, Settings, Zones, Scheduler); + Impersonation = new Impersonation(Options); + Chats = new ChatRegistry(_services.GetRequiredService(), sender, Options, loggers, time, calendar, Settings, Zones, Scheduler, Impersonation); if (Scheduler is not null) Delivery = new TelegramJobDelivery(Chats, Settings, Options, clock, NullLogger.Instance); var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger.Instance, speech); Composer = new InboundComposer(sender, media, NullLogger.Instance); - Router = new UpdateRouter(Options, Chats, new ChatCommands(sender, Zones, Scheduler), sender, Composer, NullLogger.Instance); + Router = new UpdateRouter(Options, Chats, new ChatCommands(sender, Zones, Scheduler), sender, Composer, NullLogger.Instance, Impersonation); } + /// Off until is set on . + public Impersonation Impersonation { get; } + /// The composer the router uses (content steps 1โ€“3). public InboundComposer Composer { get; } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/TelegramChatChannelTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/TelegramChatChannelTests.cs index ebae080..0788542 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/TelegramChatChannelTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/TelegramChatChannelTests.cs @@ -112,7 +112,7 @@ public sealed class TelegramChatChannelTests var sender = new TelegramSender(bot, NullLogger.Instance) { FileRetryDelays = [TimeSpan.Zero, TimeSpan.Zero] }; var options = new TelegramOptions { EditInterval = TimeSpan.FromMilliseconds(20), TypingInterval = TimeSpan.FromSeconds(10) }; var renderer = new ChatRenderer(1001, sender, options, NullLogger.Instance) { ReplyRetryDelays = [TimeSpan.Zero, TimeSpan.Zero] }; - return (bot, new TelegramChatChannel(1001, sender, renderer), renderer); + return (bot, new TelegramChatChannel(sender, renderer), renderer); } [Fact]