feat(telegram): several admins via ADMIN_USER_IDS, each impersonating independently

ADMIN_USER_IDS (Telegram:AdminUserIds) takes a comma separated list; the older
ADMIN_USER_ID / Telegram:AdminUserId still works, also takes a list and is merged.
Empty entries and duplicates are ignored, non-numeric entries fail validation.
Impersonation is now per admin: one admin per target, an impersonating admin
can't be impersonated, and every admin's chat menu lists /impersonate.
This commit is contained in:
EugeneTes
2026-10-15 14:02:59 +00:00
parent 7fef3ce94e
commit d51438e665
16 changed files with 257 additions and 64 deletions

View File

@@ -6,8 +6,9 @@
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_ALLOWED_USER_IDS= TELEGRAM_ALLOWED_USER_IDS=
# Telegram user id that may /impersonate other users (empty = off). # Admins: Telegram user ids that may /impersonate other users, comma separated (empty = off). All admins have the
ADMIN_USER_ID= # 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. # Model every chat uses (empty = the compose default), log level, container time zone.
TELEGRAM_MODEL= TELEGRAM_MODEL=

View File

@@ -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. (`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 `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. router deletes a `/mcp_add` carrying a key and any answer to a `UiRequestOptions.Secret` question.
`ADMIN_USER_ID` (`Telegram:AdminUserId`) may `/impersonate <user_id>` (`Chats/Impersonation`, in memory, `0` stops): the Admins, `ADMIN_USER_IDS` (`Telegram:AdminUserIds`, comma separated; the older `ADMIN_USER_ID`/`Telegram:AdminUserId` is
router then handles the admin's updates as the target's, and the target chat's renderer delivers to the admin's chat merged in, see `TelegramOptions.Admins`/`ParseUserIds`), may `/impersonate <user_id>` (`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`. (`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 `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): `ASPNETCORE_HTTP_PORTS`; under `dotnet run`, `ASPNETCORE_URLS` or Kestrel's default):

View File

@@ -34,7 +34,8 @@ services:
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} # Google Calendar (calendar spec) - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} # Google Calendar (calendar spec)
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- MCP_ENCRYPTION_SECRET=${MCP_ENCRYPTION_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 - 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 - 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 - A2A__Enabled=${A2A_ENABLED:-false} # A2A server (a2a-server spec); needs a domain in Coolify

View File

@@ -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_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 | | `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` | | `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 | | `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 | | `LOG_LEVEL` | `Information` | Passed as `Logging__LogLevel__Default` | Yes |

View File

@@ -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 `<WorkspaceRoot>/<chatId>` | | Tools | All built-in tools; each chat works in its own directory `<WorkspaceRoot>/<chatId>` |
| Access | Mandatory user-id allow-list; empty list = refuse to start; other users are ignored silently | | Access | Mandatory user-id allow-list; empty list = refuse to start; other users are ignored silently |
| Chats | Private chats only | | Chats | Private chats only |
| Impersonation | Optional `AdminUserId` (`ADMIN_USER_ID`) may `/impersonate <user_id>` (`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 <user_id>` 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. 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`). - `AllowedUserIds`, required, non-empty (`Telegram__AllowedUserIds__0=123`, or `TELEGRAM_ALLOWED_USER_IDS=1,2`).
- `WorkspaceRoot`, default `~/.volcanic/telegram-workspaces`. - `WorkspaceRoot`, default `~/.volcanic/telegram-workspaces`.
- `Model`, default `claude-code/claude-opus-5-5`. - `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). At start the host logs whether the `claude` CLI was found; a missing CLI is a warning (the doctor command explains).

View File

@@ -30,7 +30,8 @@ settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section
|---|---|---| |---|---|---|
| `TELEGRAM_BOT_TOKEN` / `Telegram:BotToken` | — | Required | | `TELEGRAM_BOT_TOKEN` / `Telegram:BotToken` | — | Required |
| `TELEGRAM_ALLOWED_USER_IDS` / `Telegram:AllowedUserIds` | — | Required, comma separated. The bot refuses to start without it | | `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 `<id>` works in `<root>/<id>`; put `AGENTS.md`, `PERSONA.md`, `MEMORY.md` and `.volcanic/skills/` there | | `Telegram:WorkspaceRoot` | `~/.volcanic/telegram-workspaces` | Chat `<id>` works in `<root>/<id>`; 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: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 | | `Telegram:EditInterval` | `00:00:01.5` | Minimum time between edits of the "Working..." status message |
@@ -375,9 +376,13 @@ Watched groups live in `<agent dir>/telegram/groups.json`; each chat's triggers
### Impersonation ### Impersonation
The admin (`ADMIN_USER_ID`) can act as another user with `/impersonate <user_id>`; `/impersonate 0` (or the admin's Each admin (`ADMIN_USER_IDS`, or the older `ADMIN_USER_ID`) can act as another user with `/impersonate <user_id>`;
own id) stops. The command shows up in the admin's command menu only, and from anyone else it is an ordinary unknown `/impersonate 0` (or the admin's own id) stops. The command shows up in the admins' command menus only, and from anyone
command passed to the agent. 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: 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 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 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 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 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 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 ([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. as writable by anyone on the allow-list.

View File

@@ -32,19 +32,21 @@ public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions opti
} }
/// <summary> /// <summary>
/// The admin's chat also lists <c>/impersonate</c>. Best-effort and not retried: it fails while the admin has never /// Each admin's chat also lists <c>/impersonate</c>. Best-effort and not retried: it fails while an admin has never
/// started the bot, and the command works without the menu entry. /// started the bot, and the command works without the menu entry.
/// </summary> /// </summary>
private async Task SetAdminMenuAsync(IEnumerable<BotCommand> menu, CancellationToken stoppingToken) private async Task SetAdminMenuAsync(IEnumerable<BotCommand> menu, CancellationToken stoppingToken)
{ {
if (options.AdminUserId is not { } admin) return; foreach (var admin in options.Admins)
try
{ {
await bot.SetMyCommands([.. menu, ChatCommands.ImpersonateCommand], BotCommandScope.Chat(admin), cancellationToken: stoppingToken).ConfigureAwait(false); try
} {
catch (Exception ex) when (ex is not OperationCanceledException) await bot.SetMyCommands([.. menu, ChatCommands.ImpersonateCommand], BotCommandScope.Chat(admin), cancellationToken: stoppingToken).ConfigureAwait(false);
{ }
logger.LogWarning(ex, "Setting the admin's command menu failed"); catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Setting admin {Admin}'s command menu failed", admin);
}
} }
} }

View File

@@ -38,7 +38,7 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
new() { Command = "help", Description = "What this bot does" }, new() { Command = "help", Description = "What this bot does" },
]; ];
/// <summary>The admin's chat gets <see cref="Menu"/> plus this (see <see cref="Impersonation"/>).</summary> /// <summary>Each admin's chat gets <see cref="Menu"/> plus this (see <see cref="Impersonation"/>).</summary>
public static readonly BotCommand ImpersonateCommand = new() { Command = "impersonate", Description = "Act as another user: /impersonate <user_id>, 0 stops" }; public static readonly BotCommand ImpersonateCommand = new() { Command = "impersonate", Description = "Act as another user: /impersonate <user_id>, 0 stops" };
/// <summary>Split <c>/name@bot args</c>. Null when the text is not a command.</summary> /// <summary>Split <c>/name@bot args</c>. Null when the text is not a command.</summary>

View File

@@ -24,7 +24,7 @@ public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui
{ {
public long ChatId => chatId; public long ChatId => chatId;
/// <summary>The Telegram chat replies go to: <see cref="ChatId"/>, or the admin's while impersonated (see <see cref="Impersonation"/>).</summary> /// <summary>The Telegram chat replies go to: <see cref="ChatId"/>, or an admin's while impersonated (see <see cref="Impersonation"/>).</summary>
public long DeliveryChatId => renderer.DeliveryChatId; public long DeliveryChatId => renderer.DeliveryChatId;
public AgentSession Session => session; public AgentSession Session => session;
public TelegramUi Ui => ui; public TelegramUi Ui => ui;

View File

@@ -1,47 +1,66 @@
using System.Collections.Concurrent;
using System.Globalization; using System.Globalization;
namespace Volcanic.AgenticCore.Telegram.Chats; namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary> /// <summary>
/// The admin's (<see cref="TelegramOptions.AdminUserId"/>) <c>/impersonate &lt;user_id&gt;</c>. While it is on, the admin's /// An admin's (<see cref="TelegramOptions.Admins"/>) <c>/impersonate &lt;user_id&gt;</c>. 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, /// 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 <c>/impersonate 0</c>. The target user's own messages and button presses are blocked /// admin's chat instead, until <c>/impersonate 0</c>. The target user's own messages and button presses are blocked
/// meanwhile and answered with <see cref="BlockedNotice"/>. In private chats the chat id is the user id, so one id maps both. /// meanwhile and answered with <see cref="BlockedNotice"/>. 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.
/// </summary> /// </summary>
public sealed class Impersonation(TelegramOptions options) public sealed class Impersonation(TelegramOptions options)
{ {
/// <summary>The reply to the impersonated user while the admin works as them.</summary> /// <summary>The reply to the impersonated user while an admin works as them.</summary>
public const string BlockedNotice = "🔒 An admin is working with your account right now. Please try again later."; 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<long, long> _targets = new(); // admin → impersonated user
private readonly Lock _gate = new();
public long? AdminId => options.AdminUserId; public IReadOnlyList<long> AdminIds => options.Admins;
/// <summary>The impersonated user, or null when off.</summary> public bool IsAdmin(long? userId) => userId is { } id && options.Admins.Contains(id);
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; /// <summary>The user <paramref name="adminId"/> impersonates, or null when they don't.</summary>
public long? TargetOf(long adminId) => _targets.TryGetValue(adminId, out var target) ? target : null;
/// <summary>True for the impersonated user themselves: their updates are blocked while the admin works as them.</summary> /// <summary>True for an impersonated user themselves: their updates are blocked while an admin works as them.</summary>
public bool IsBlocked(long? userId) => userId is { } id && Target == id; public bool IsBlocked(long? userId) => userId is { } id && AdminOf(id) is not null;
/// <summary>The user (or private chat) id the bot acts for: the target for the admin while impersonating, else <paramref name="id"/>.</summary> /// <summary>The user (or private chat) id the bot acts for: the target for an impersonating admin, else <paramref name="id"/>.</summary>
public long EffectiveId(long id) => IsAdmin(id) && Target is { } target ? target : id; public long EffectiveId(long id) => TargetOf(id) ?? id;
/// <summary>Where chat <paramref name="chatId"/>'s output goes: the admin's chat while the admin impersonates it.</summary> /// <summary>Where chat <paramref name="chatId"/>'s output goes: the chat of the admin impersonating it, if any.</summary>
public long DeliveryChatFor(long chatId) => Target == chatId && AdminId is { } admin ? admin : chatId; public long DeliveryChatFor(long chatId) => AdminOf(chatId) ?? chatId;
/// <summary>Handle <c>/impersonate</c>'s argument and return the reply for the admin.</summary> private long? AdminOf(long target)
public string Apply(string arg)
{ {
if (!long.TryParse(arg.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var id)) foreach (var (admin, current) in _targets)
return "Usage: /impersonate <user_id> (0 stops). " + (Target is { } current ? $"You are acting as user {current}." : "You are not impersonating anyone."); if (current == target) return admin;
if (id == 0 || IsAdmin(id)) return null;
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."; /// <summary>Handle <paramref name="adminId"/>'s <c>/impersonate</c> argument and return the reply for them.</summary>
return options.IsAllowed(id) ? reply : reply + $"\n⚠️ User {id} is not in the allow-list, so the bot ignores your messages until you stop."; 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 <user_id> (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.";
}
} }
} }

View File

@@ -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 /// 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 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 /// 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 /// a prompt that runs in the background. An admin's <c>/impersonate</c> is handled first; while it is on, that admin's
/// updates are routed as the impersonated user's (see <see cref="Impersonation"/>). Group messages and the bot's own /// 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 /// 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. /// 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); }, CancellationToken.None);
} }
/// <summary>The admin's <c>/impersonate &lt;user_id&gt;</c> in a private chat, checked before the allow-list. False otherwise.</summary> /// <summary>An admin's <c>/impersonate &lt;user_id&gt;</c> in a private chat, checked before the allow-list. False otherwise.</summary>
private async Task<bool> TryImpersonateAsync(Message message, CancellationToken cancellationToken) private async Task<bool> TryImpersonateAsync(Message message, CancellationToken cancellationToken)
{ {
if (impersonation is null || !impersonation.IsAdmin(message.From?.Id) || message.Chat.Type != ChatType.Private if (impersonation is null || !impersonation.IsAdmin(message.From?.Id) || message.Chat.Type != ChatType.Private
|| InboundComposer.HasAttachment(message) || message.ForwardOrigin is not null || InboundComposer.HasAttachment(message) || message.ForwardOrigin is not null
|| ChatCommands.Parse(message.Text ?? "") is not { Name: "impersonate" } command) || ChatCommands.Parse(message.Text ?? "") is not { Name: "impersonate" } command)
return false; return false;
var reply = impersonation.Apply(command.Args); var admin = message.From!.Id;
logger.LogInformation("Admin {AdminId}: impersonating {Target}", message.From!.Id, impersonation.Target?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "nobody"); 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); await sender.SendTextAsync(message.Chat.Id, reply, cancellationToken: cancellationToken).ConfigureAwait(false);
return true; return true;
} }

View File

@@ -156,7 +156,7 @@ else
logger.LogInformation("Scheduler: {State}", scheduling.Enabled ? "on, jobs in each chat's .volcanic/scheduler/jobs.json" : "off"); 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("Default time zone: {Zone}", TimeZoneIds.Name(options.ConfiguredTimeZone() ?? TimeZoneInfo.Local));
logger.LogInformation("Allowed users: {Users}. Workspaces under {Root}", string.Join(", ", options.AllowedUserIds), options.WorkspaceRoot); 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). // appsettings keeps Kestrel's own "Now listening" quiet (Microsoft.Hosting.Lifetime: Warning).
if (a2a.Enabled) if (a2a.Enabled)

View File

@@ -74,7 +74,7 @@ public sealed class ChatRenderer : IAsyncDisposable
_loop = Task.Run(RunAsync); _loop = Task.Run(RunAsync);
} }
/// <summary>The Telegram chat this chat's output goes to now: its own, or the admin's while the admin impersonates it.</summary> /// <summary>The Telegram chat this chat's output goes to now: its own, or an admin's while that admin impersonates it.</summary>
public long DeliveryChatId => _deliverTo?.Invoke() ?? _chatId; public long DeliveryChatId => _deliverTo?.Invoke() ?? _chatId;
/// <summary>Queue an event; safe to call from any thread (this is the session subscription).</summary> /// <summary>Queue an event; safe to call from any thread (this is the session subscription).</summary>

View File

@@ -16,11 +16,21 @@ public sealed class TelegramOptions
public List<long> AllowedUserIds { get; set; } = []; public List<long> AllowedUserIds { get; set; } = [];
/// <summary> /// <summary>
/// The Telegram user id that may run <c>/impersonate &lt;user_id&gt;</c> (see <see cref="Chats.Impersonation"/>); null /// The Telegram user ids that may run <c>/impersonate &lt;user_id&gt;</c> (see <see cref="Chats.Impersonation"/>); all
/// turns the command off. Also read from <c>ADMIN_USER_ID</c>. /// admins have the same rights, and none turns the command off. Also read from <c>ADMIN_USER_IDS</c> and
/// <c>ADMIN_USER_ID</c> (both take a comma separated list). See <see cref="Admins"/>.
/// </summary> /// </summary>
public List<long> AdminUserIds { get; set; } = [];
/// <summary>The single admin of older configurations (<c>Telegram:AdminUserId</c>); merged into <see cref="Admins"/>.</summary>
public long? AdminUserId { get; set; } public long? AdminUserId { get; set; }
/// <summary><see cref="AdminUserId"/> and <see cref="AdminUserIds"/>, without duplicates, in that order.</summary>
public IReadOnlyList<long> Admins =>
[.. (AdminUserId is { } one ? AdminUserIds.Prepend(one) : AdminUserIds).Distinct()];
private readonly List<string> _invalidAdminIds = [];
/// <summary>Each chat works in <c>&lt;WorkspaceRoot&gt;/&lt;chatId&gt;</c>.</summary> /// <summary>Each chat works in <c>&lt;WorkspaceRoot&gt;/&lt;chatId&gt;</c>.</summary>
public string WorkspaceRoot { get; set; } = Path.Combine( public string WorkspaceRoot { get; set; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "telegram-workspaces"); 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 (string.IsNullOrWhiteSpace(BotToken)) BotToken = getEnv("TELEGRAM_BOT_TOKEN") ?? "";
if (AllowedUserIds.Count == 0 && getEnv("TELEGRAM_ALLOWED_USER_IDS") is { Length: > 0 } ids) if (AllowedUserIds.Count == 0 && getEnv("TELEGRAM_ALLOWED_USER_IDS") is { Length: > 0 } ids)
{ {
foreach (var part in ids.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries)) AllowedUserIds.AddRange(ParseUserIds(ids, out _)); // entries that aren't numbers are skipped, as always
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; // 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: the environment fills only empty settings, and an empty variable counts as unset.
Speech ??= new SpeechOptions(); Speech ??= new SpeechOptions();
@@ -102,7 +119,10 @@ public sealed class TelegramOptions
if (AllowedUserIds.Count == 0) if (AllowedUserIds.Count == 0)
errors.Add("No allowed users: set TELEGRAM_ALLOWED_USER_IDS (comma separated) or Telegram:AllowedUserIds. " + 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."); "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(WorkspaceRoot)) errors.Add("Telegram:WorkspaceRoot is empty.");
if (string.IsNullOrWhiteSpace(Model) || !Model.Contains('/')) errors.Add("Telegram:Model must be 'provider/model-id'."); 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."); 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); public bool IsAllowed(long? userId) => userId is { } id && AllowedUserIds.Contains(id);
/// <summary>
/// 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 <paramref name="invalid"/>.
/// </summary>
public static List<long> ParseUserIds(string? text, out List<string> invalid)
{
var ids = new List<long>();
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;
}
} }

View File

@@ -77,4 +77,20 @@ public sealed class BotSetupServiceTests
Assert.Equal(5005, admin.ChatId.Identifier); Assert.Equal(5005, admin.ChatId.Identifier);
Assert.Contains(menus[1].Commands, c => c.Command == "impersonate"); 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<TelegramSender>.Instance), NullLogger<BotSetupService>.Instance);
await service.StartAsync(CancellationToken.None);
await service.ExecuteTask!.WaitAsync(TimeSpan.FromSeconds(5));
await service.StopAsync(CancellationToken.None);
var admins = bot.Requests.OfType<SetMyCommandsRequest>().Skip(1).ToList();
Assert.Equal([5005L, 6006L], admins.Select(m => Assert.IsType<global::Telegram.Bot.Types.BotCommandScopeChat>(m.Scope).ChatId.Identifier!.Value));
Assert.All(admins, m => Assert.Contains(m.Commands, c => c.Command == "impersonate"));
}
} }

View File

@@ -8,6 +8,7 @@ namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class ImpersonationTests public sealed class ImpersonationTests
{ {
private const long Admin = 5005; // not in the allow-list: only /impersonate works for them private const long Admin = 5005; // not in the allow-list: only /impersonate works for them
private const long SecondAdmin = 6006;
private static ChatHost CreateHost() private static ChatHost CreateHost()
{ {
@@ -55,7 +56,7 @@ public sealed class ImpersonationTests
await host.SendAsync("hello", userId: Admin); await host.SendAsync("hello", userId: Admin);
Assert.Equal("🎭 Impersonation off: you are yourself again.", host.LastReply(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.Null(host.Chats.Find(Updates.AllowedUser)); // the admin is not allow-listed, so "hello" was ignored
Assert.Empty(host.Faux.Requests); Assert.Empty(host.Faux.Requests);
} }
@@ -82,7 +83,7 @@ public sealed class ImpersonationTests
await host.SendAsync("/impersonate 5005"); await host.SendAsync("/impersonate 5005");
await host.SettleAsync(); 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 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.SendAsync("/impersonate 1001", userId: Admin);
await host.SettleAsync(Admin); await host.SettleAsync(Admin);
Assert.Null(host.Impersonation.Target); Assert.Null(host.Impersonation.TargetOf(Admin));
Assert.NotNull(host.Chats.Find(Admin)); Assert.NotNull(host.Chats.Find(Admin));
} }
@@ -164,15 +165,120 @@ public sealed class ImpersonationTests
Assert.Equal(Impersonation.BlockedNotice, reply.Text); 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<string, string> { ["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] [Fact]
public void Admin_user_id_comes_from_the_environment_and_must_be_positive() public void Admin_user_id_comes_from_the_environment_and_must_be_positive()
{ {
var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] }; var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
options.ApplyEnvironment(name => name == "ADMIN_USER_ID" ? " 42 " : null); options.ApplyEnvironment(name => name == "ADMIN_USER_ID" ? " 42 " : null);
Assert.Equal(42, options.AdminUserId); Assert.Equal([42], options.Admins);
Assert.Empty(options.Validate()); 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)); Assert.Contains(options.Validate(), e => e.Contains("ADMIN_USER_ID", StringComparison.Ordinal));
} }
} }