diff --git a/.env.example b/.env.example index 4e22658..ebb6b63 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,9 @@ TELEGRAM_BOT_TOKEN= TELEGRAM_ALLOWED_USER_IDS= -# Telegram user id that may /impersonate other users (empty = off). -ADMIN_USER_ID= +# Admins: Telegram user ids that may /impersonate other users, comma separated (empty = off). All admins have the +# same rights. The older single-id ADMIN_USER_ID still works (it also takes a list); the two are merged. +ADMIN_USER_IDS= # Model every chat uses (empty = the compose default), log level, container time zone. TELEGRAM_MODEL= diff --git a/CLAUDE.md b/CLAUDE.md index 70a9924..89d6d65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,8 +150,10 @@ by `TriggerDispatcher` and delivered as follow-up prompts (`TelegramTriggerDeliv (`SnoozeQueue`) and check them as one batch when the snooze ends. `/triggers` lists them. `RemoteMcpSetup` adds `RemoteMcpExtension` to chat sessions (secret `MCP_ENCRYPTION_SECRET`, else the bot token); the router deletes a `/mcp_add` carrying a key and any answer to a `UiRequestOptions.Secret` question. -`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 +Admins, `ADMIN_USER_IDS` (`Telegram:AdminUserIds`, comma separated; the older `ADMIN_USER_ID`/`Telegram:AdminUserId` is +merged in, see `TelegramOptions.Admins`/`ParseUserIds`), may `/impersonate ` (`Chats/Impersonation`, per admin, +one admin per target, in memory, `0` stops): the router then handles that admin's updates as the target's, and the +target chat's renderer delivers to that 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`. `A2A/` (on only with `A2A__Enabled=true`) mounts `Volcanic.AgenticCore.A2A.Server` on Kestrel (port 8080 in the container via the aspnet image's `ASPNETCORE_HTTP_PORTS`; under `dotnet run`, `ASPNETCORE_URLS` or Kestrel's default): diff --git a/docker-compose.yml b/docker-compose.yml index d6da04c..9eea0b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,8 @@ services: - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} # Google Calendar (calendar spec) - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - MCP_ENCRYPTION_SECRET=${MCP_ENCRYPTION_SECRET:-} - - ADMIN_USER_ID=${ADMIN_USER_ID:-} # may /impersonate other users (telegram-bot.md) + - ADMIN_USER_IDS=${ADMIN_USER_IDS:-} # comma separated; may /impersonate other users (telegram-bot.md) + - ADMIN_USER_ID=${ADMIN_USER_ID:-} # older single-id form, merged with ADMIN_USER_IDS - 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 - A2A__Enabled=${A2A_ENABLED:-false} # A2A server (a2a-server spec); needs a domain in Coolify diff --git a/docs/docker.md b/docs/docker.md index 649c692..93bf849 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -70,7 +70,8 @@ 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 | +| `ADMIN_USER_IDS` | โ€” | Telegram user ids, comma separated, that may `/impersonate` other users ([telegram-bot.md](telegram-bot.md#impersonation)) | Yes | +| `ADMIN_USER_ID` | โ€” | Older single-admin form; also takes a list and is merged with `ADMIN_USER_IDS` | 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 ef2eb3f..829e645 100644 --- a/docs/superpowers/specs/2026-09-25-telegram-host-design.md +++ b/docs/superpowers/specs/2026-09-25-telegram-host-design.md @@ -19,7 +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 | +| Impersonation | Optional admins `AdminUserIds` (`ADMIN_USER_IDS`, comma separated; the older `AdminUserId`/`ADMIN_USER_ID` is merged in), each may `/impersonate ` independently (one admin per target, an impersonating admin can't be a target) (`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. @@ -31,7 +31,8 @@ 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`. +- `AdminUserIds` (also `ADMIN_USER_IDS`, comma separated), optional, positive: the users who may `/impersonate`, all with the + same rights. The older `AdminUserId` (`ADMIN_USER_ID`, which also takes a list) is merged in; non-numeric entries fail validation. 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 9e3e040..f98fe3b 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -30,7 +30,8 @@ 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 | +| `ADMIN_USER_IDS` / `Telegram:AdminUserIds` | โ€” (off) | Telegram user ids that may use `/impersonate` (see [Impersonation](#impersonation)), comma separated (`,`, `;` or spaces; empty entries and duplicates are ignored). All admins have the same rights. Need not be on the allow-list. Must be positive; an entry that isn't a number stops the bot at startup | +| `ADMIN_USER_ID` / `Telegram:AdminUserId` | โ€” | The older single-admin setting, still supported. The variable also takes a list; both variables are merged, as are both settings. The environment is read only when neither setting is configured | | `Telegram:WorkspaceRoot` | `~/.volcanic/telegram-workspaces` | Chat `` works in `/`; put `AGENTS.md`, `PERSONA.md`, `MEMORY.md` and `.volcanic/skills/` 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 | @@ -375,9 +376,13 @@ Watched groups live in `/telegram/groups.json`; each chat's triggers ### Impersonation -The admin (`ADMIN_USER_ID`) can act as another user with `/impersonate `; `/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. +Each admin (`ADMIN_USER_IDS`, or the older `ADMIN_USER_ID`) can act as another user with `/impersonate `; +`/impersonate 0` (or the admin's own id) stops. The command shows up in the admins' command menus only, and from anyone +else it is an ordinary unknown command passed to the agent. + +With several admins each one impersonates independently, under these rules: a user can be impersonated by one admin +at a time (a second admin is told who already is), an admin who is impersonating someone can't be impersonated, and +an admin who is being impersonated can't start `/impersonate` until the other admin stops. 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 @@ -450,7 +455,7 @@ watches (agent, task, push/subscribe/poll, age). With both halves off it replies 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. In groups the bot only classifies messages for triggers: it never runs the agent for a group member, and the messages of everyone in a watched group -(not only allowed users) are sent to OpenRouter and logged. `ADMIN_USER_ID` can act as any +(not only allowed users) are sent to OpenRouter and logged. Every admin (`ADMIN_USER_IDS`) 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 6b785e5..f1b6043 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/BotSetupService.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/BotSetupService.cs @@ -32,19 +32,21 @@ 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 + /// Each admin's chat also lists /impersonate. Best-effort and not retried: it fails while an 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 + foreach (var admin in options.Admins) { - 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"); + 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 admin {Admin}'s command menu failed", admin); + } } } diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs index 543305d..cecd563 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs @@ -38,7 +38,7 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon new() { Command = "help", Description = "What this bot does" }, ]; - /// The admin's chat gets plus this (see ). + /// Each 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. diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs index bd9839a..8ca4e3d 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs @@ -24,7 +24,7 @@ public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui { public long ChatId => chatId; - /// The Telegram chat replies go to: , or the admin's while impersonated (see ). + /// The Telegram chat replies go to: , or an admin's while impersonated (see ). public long DeliveryChatId => renderer.DeliveryChatId; public AgentSession Session => session; public TelegramUi Ui => ui; diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/Impersonation.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/Impersonation.cs index 7cd88a9..a0379a3 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/Impersonation.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/Impersonation.cs @@ -1,47 +1,66 @@ +using System.Collections.Concurrent; using System.Globalization; namespace Volcanic.AgenticCore.Telegram.Chats; /// -/// The admin's () /impersonate <user_id>. While it is on, the admin's +/// An admin's () /impersonate <user_id>. While it is on, that 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 +/// settings and jobs. Everything the target's chat sends (replies, questions, files, scheduled deliveries) goes to that /// 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. +/// Every admin impersonates independently; a user can be impersonated by one admin at a time, and an admin who is +/// impersonating someone can't be impersonated. 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. + /// The reply to the impersonated user while an 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 + private readonly ConcurrentDictionary _targets = new(); // admin โ†’ impersonated user + private readonly Lock _gate = new(); - public long? AdminId => options.AdminUserId; + public IReadOnlyList AdminIds => options.Admins; - /// 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 && options.Admins.Contains(id); - public bool IsAdmin(long? userId) => userId is { } id && id == AdminId; + /// The user impersonates, or null when they don't. + public long? TargetOf(long adminId) => _targets.TryGetValue(adminId, out var target) ? target : null; - /// 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; + /// True for an impersonated user themselves: their updates are blocked while an admin works as them. + public bool IsBlocked(long? userId) => userId is { } id && AdminOf(id) is not null; - /// 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; + /// The user (or private chat) id the bot acts for: the target for an impersonating admin, else . + public long EffectiveId(long id) => TargetOf(id) ?? 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; + /// Where chat 's output goes: the chat of the admin impersonating it, if any. + public long DeliveryChatFor(long chatId) => AdminOf(chatId) ?? chatId; - /// Handle /impersonate's argument and return the reply for the admin. - public string Apply(string arg) + private long? AdminOf(long target) { - 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."; + foreach (var (admin, current) in _targets) + if (current == target) return admin; + return null; + } + + /// Handle 's /impersonate argument and return the reply for them. + public string Apply(long adminId, string arg) + { + lock (_gate) + { + if (AdminOf(adminId) is { } other) + return $"Admin {other} is acting as you right now, so you can't impersonate anyone until they stop."; + if (!long.TryParse(arg.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var id)) + return "Usage: /impersonate (0 stops). " + (TargetOf(adminId) is { } current ? $"You are acting as user {current}." : "You are not impersonating anyone."); + if (id == 0 || id == adminId) + return _targets.TryRemove(adminId, out _) ? "๐ŸŽญ Impersonation off: you are yourself again." : "You are not impersonating anyone."; + if (AdminOf(id) is { } taken && taken != adminId) + return $"Admin {taken} is already acting as user {id}. Only one admin can impersonate a user at a time."; + if (TargetOf(id) is not null) + return $"User {id} is an admin who is impersonating someone right now; try again after they stop."; + _targets[adminId] = 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/UpdateRouter.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs index 1700bd5..a1c0a90 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/UpdateRouter.cs @@ -12,7 +12,7 @@ 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. The admin's /impersonate is handled first; while it is on, the admin's +/// a prompt that runs in the background. An admin's /impersonate is handled first; while it is on, that admin's /// updates are routed as the impersonated user's (see ). Group messages and the bot's own /// membership changes go to the 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. @@ -142,15 +142,16 @@ public sealed class UpdateRouter( }, CancellationToken.None); } - /// The admin's /impersonate <user_id> in a private chat, checked before the allow-list. False otherwise. + /// An 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"); + var admin = message.From!.Id; + var reply = impersonation.Apply(admin, command.Args); + logger.LogInformation("Admin {AdminId}: impersonating {Target}", admin, impersonation.TargetOf(admin)?.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 cf27340..477947f 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Program.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Program.cs @@ -156,7 +156,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); +if (options.Admins.Count > 0) logger.LogInformation("Admins (may /impersonate): {Admins}", string.Join(", ", options.Admins)); // appsettings keeps Kestrel's own "Now listening" quiet (Microsoft.Hosting.Lifetime: Warning). if (a2a.Enabled) diff --git a/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs b/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs index d34bb71..bd8f81a 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs @@ -74,7 +74,7 @@ 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. + /// The Telegram chat this chat's output goes to now: its own, or an admin's while that admin impersonates it. public long DeliveryChatId => _deliverTo?.Invoke() ?? _chatId; /// Queue an event; safe to call from any thread (this is the session subscription). diff --git a/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs b/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs index 653dc15..417fed7 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs @@ -16,11 +16,21 @@ public sealed class TelegramOptions 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. + /// The Telegram user ids that may run /impersonate <user_id> (see ); all + /// admins have the same rights, and none turns the command off. Also read from ADMIN_USER_IDS and + /// ADMIN_USER_ID (both take a comma separated list). See . /// + public List AdminUserIds { get; set; } = []; + + /// The single admin of older configurations (Telegram:AdminUserId); merged into . public long? AdminUserId { get; set; } + /// and , without duplicates, in that order. + public IReadOnlyList Admins => + [.. (AdminUserId is { } one ? AdminUserIds.Prepend(one) : AdminUserIds).Distinct()]; + + private readonly List _invalidAdminIds = []; + /// Each chat works in <WorkspaceRoot>/<chatId>. public string WorkspaceRoot { get; set; } = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "telegram-workspaces"); @@ -67,11 +77,18 @@ public sealed class TelegramOptions if (string.IsNullOrWhiteSpace(BotToken)) BotToken = getEnv("TELEGRAM_BOT_TOKEN") ?? ""; if (AllowedUserIds.Count == 0 && getEnv("TELEGRAM_ALLOWED_USER_IDS") is { Length: > 0 } ids) { - foreach (var part in ids.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries)) - if (long.TryParse(part, out var id)) AllowedUserIds.Add(id); + AllowedUserIds.AddRange(ParseUserIds(ids, out _)); // entries that aren't numbers are skipped, as always } - if (AdminUserId is null && long.TryParse(getEnv("ADMIN_USER_ID")?.Trim(), out var admin)) AdminUserId = admin; + // Admins: ADMIN_USER_IDS and the older ADMIN_USER_ID, both lists, merged; only when the section sets none. + if (AdminUserIds.Count == 0 && AdminUserId is null) + { + foreach (var name in (string[])["ADMIN_USER_IDS", "ADMIN_USER_ID"]) + { + AdminUserIds.AddRange(ParseUserIds(getEnv(name), out var invalid).Where(id => !AdminUserIds.Contains(id))); + _invalidAdminIds.AddRange(invalid.Select(entry => $"{name}: '{entry}'")); + } + } // Speech: the environment fills only empty settings, and an empty variable counts as unset. Speech ??= new SpeechOptions(); @@ -102,7 +119,10 @@ 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 (_invalidAdminIds.Count > 0) + errors.Add("Admin ids must be Telegram user ids separated by commas: " + string.Join(", ", _invalidAdminIds) + "."); + if (Admins.Any(id => id <= 0)) + errors.Add("Telegram:AdminUserIds (ADMIN_USER_IDS / ADMIN_USER_ID) must be positive Telegram user ids."); 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."); @@ -118,4 +138,22 @@ public sealed class TelegramOptions } public bool IsAllowed(long? userId) => userId is { } id && AllowedUserIds.Contains(id); + + /// + /// A list of user ids separated by commas, semicolons or spaces: empty entries are skipped, duplicates kept once in + /// first-seen order, and entries that aren't whole numbers go to . + /// + public static List ParseUserIds(string? text, out List invalid) + { + var ids = new List(); + invalid = []; + foreach (var part in (text ?? "").Split([',', ';', ' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + if (!long.TryParse(part, System.Globalization.NumberStyles.AllowLeadingSign, System.Globalization.CultureInfo.InvariantCulture, out var id)) + invalid.Add(part); + else if (!ids.Contains(id)) + ids.Add(id); + } + return ids; + } } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/BotSetupServiceTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/BotSetupServiceTests.cs index d01de9f..64ae937 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/BotSetupServiceTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/BotSetupServiceTests.cs @@ -77,4 +77,20 @@ public sealed class BotSetupServiceTests Assert.Equal(5005, admin.ChatId.Identifier); Assert.Contains(menus[1].Commands, c => c.Command == "impersonate"); } + + [Fact] + public async Task Every_admins_chat_menu_lists_impersonate() + { + var bot = new FakeBotClient(); + var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [Updates.AllowedUser], AdminUserIds = [5005, 6006] }; + 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 admins = bot.Requests.OfType().Skip(1).ToList(); + Assert.Equal([5005L, 6006L], admins.Select(m => Assert.IsType(m.Scope).ChatId.Identifier!.Value)); + Assert.All(admins, m => Assert.Contains(m.Commands, c => c.Command == "impersonate")); + } } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/ImpersonationTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/ImpersonationTests.cs index 8c8c4b8..3bebd40 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/ImpersonationTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/ImpersonationTests.cs @@ -8,6 +8,7 @@ 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 const long SecondAdmin = 6006; private static ChatHost CreateHost() { @@ -55,7 +56,7 @@ public sealed class ImpersonationTests 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.Impersonation.TargetOf(Admin)); Assert.Null(host.Chats.Find(Updates.AllowedUser)); // the admin is not allow-listed, so "hello" was ignored Assert.Empty(host.Faux.Requests); } @@ -82,7 +83,7 @@ public sealed class ImpersonationTests await host.SendAsync("/impersonate 5005"); await host.SettleAsync(); - Assert.Null(host.Impersonation.Target); + Assert.Null(host.Impersonation.TargetOf(Admin)); Assert.Equal("/impersonate 5005", host.Faux.Requests.Single().Messages.Last().Text); // an ordinary unknown command } @@ -96,7 +97,7 @@ public sealed class ImpersonationTests await host.SendAsync("/impersonate 1001", userId: Admin); await host.SettleAsync(Admin); - Assert.Null(host.Impersonation.Target); + Assert.Null(host.Impersonation.TargetOf(Admin)); Assert.NotNull(host.Chats.Find(Admin)); } @@ -164,15 +165,120 @@ public sealed class ImpersonationTests Assert.Equal(Impersonation.BlockedNotice, reply.Text); } + [Fact] + public async Task Every_admin_impersonates_independently() + { + await using var host = CreateHost(); + host.Options.AdminUserIds.Add(SecondAdmin); + host.Options.AllowedUserIds.Add(1002); + host.Faux.Enqueue(FauxTurn.Say("for the first admin")); + host.Faux.Enqueue(FauxTurn.Say("for the second admin")); + + await host.SendAsync("/impersonate 1001", userId: Admin); + await host.SendAsync("/impersonate 1002", userId: SecondAdmin); + Assert.StartsWith("๐ŸŽญ You are now acting as user 1002", host.LastReply(SecondAdmin), StringComparison.Ordinal); + await host.SendAsync("one", userId: Admin); + await host.SettleAsync(Updates.AllowedUser); + await host.SendAsync("two", userId: SecondAdmin); + await host.SettleAsync(1002); + + Assert.Equal("for the first admin", host.LastReply(Admin)); + Assert.Equal("for the second admin", host.LastReply(SecondAdmin)); + await host.SendAsync("/impersonate 0", userId: SecondAdmin); + Assert.Null(host.Impersonation.TargetOf(SecondAdmin)); + Assert.Equal(Updates.AllowedUser, host.Impersonation.TargetOf(Admin)); + } + + [Fact] + public async Task Only_one_admin_impersonates_a_user_at_a_time() + { + await using var host = CreateHost(); + host.Options.AdminUserIds.Add(SecondAdmin); + + await host.SendAsync("/impersonate 1001", userId: Admin); + await host.SendAsync("/impersonate 1001", userId: SecondAdmin); + + Assert.Equal($"Admin {Admin} is already acting as user 1001. Only one admin can impersonate a user at a time.", host.LastReply(SecondAdmin)); + Assert.Null(host.Impersonation.TargetOf(SecondAdmin)); + Assert.Equal(Admin, host.Impersonation.DeliveryChatFor(Updates.AllowedUser)); + } + + [Fact] + public async Task An_admin_being_impersonated_cannot_impersonate_and_an_impersonating_admin_cannot_be_impersonated() + { + await using var host = CreateHost(); + host.Options.AdminUserIds.Add(SecondAdmin); + + await host.SendAsync("/impersonate 1001", userId: Admin); + await host.SendAsync($"/impersonate {Admin}", userId: SecondAdmin); + Assert.StartsWith($"User {Admin} is an admin who is impersonating", host.LastReply(SecondAdmin), StringComparison.Ordinal); + + await host.SendAsync("/impersonate 0", userId: Admin); + await host.SendAsync($"/impersonate {Admin}", userId: SecondAdmin); + await host.SendAsync("/impersonate 1001", userId: Admin); + + Assert.StartsWith($"Admin {SecondAdmin} is acting as you right now", host.LastReply(Admin), StringComparison.Ordinal); + Assert.Null(host.Impersonation.TargetOf(Admin)); + } + + [Fact] + public void Admin_ids_come_from_ADMIN_USER_IDS_and_ADMIN_USER_ID_merged() + { + var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] }; + var env = new Dictionary { ["ADMIN_USER_IDS"] = " 42, 43 ,, 42 ", ["ADMIN_USER_ID"] = "44,43" }; + options.ApplyEnvironment(name => env.GetValueOrDefault(name)); + + Assert.Equal([42, 43, 44], options.Admins); + Assert.Empty(options.Validate()); + } + + [Fact] + public void Configured_admins_win_over_the_environment() + { + var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1], AdminUserIds = [7] }; + options.ApplyEnvironment(name => name == "ADMIN_USER_IDS" ? "42" : null); + Assert.Equal([7], options.Admins); + + var legacy = new TelegramOptions { BotToken = "t", AllowedUserIds = [1], AdminUserId = 8, AdminUserIds = [9, 8] }; + Assert.Equal([8, 9], legacy.Admins); + } + + [Fact] + public void Admin_ids_that_are_not_numbers_stop_the_host() + { + var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] }; + options.ApplyEnvironment(name => name == "ADMIN_USER_IDS" ? "42, abc" : null); + + Assert.Equal([42], options.Admins); + Assert.Contains(options.Validate(), e => e.Contains("ADMIN_USER_IDS: 'abc'", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(null, new long[0], new string[0])] + [InlineData("", new long[0], new string[0])] + [InlineData(" , ;; ", new long[0], new string[0])] + [InlineData("1", new long[] { 1 }, new string[0])] + [InlineData(" 1 ,2;3 4\t5\n6 ", new long[] { 1, 2, 3, 4, 5, 6 }, new string[0])] + [InlineData("3,1,3,2,1", new long[] { 3, 1, 2 }, new string[0])] + [InlineData("1,x,2.5,-4", new long[] { 1, -4 }, new[] { "x", "2.5" })] + public void User_id_lists_parse(string? text, long[] expected, string[] invalid) + { + Assert.Equal(expected, TelegramOptions.ParseUserIds(text, out var bad)); + Assert.Equal(invalid, bad); + } + [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.Equal([42], options.Admins); Assert.Empty(options.Validate()); - options.AdminUserId = -1; + options.AdminUserIds[0] = -1; + Assert.Contains(options.Validate(), e => e.Contains("ADMIN_USER_ID", StringComparison.Ordinal)); + options.AdminUserIds.Clear(); + options.AdminUserId = -1; // the older single-id setting Assert.Contains(options.Validate(), e => e.Contains("ADMIN_USER_ID", StringComparison.Ordinal)); } }