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.
This commit is contained in:
EugeneTes
2026-10-05 07:24:07 +00:00
parent 64ea6f7fa1
commit 92a0061fdd
20 changed files with 398 additions and 38 deletions

View File

@@ -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

View File

@@ -117,7 +117,9 @@ Each chat's time zone lives in `Chats/ChatSettingsStore` (`<agentDir>/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 <user_id>` (`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

View File

@@ -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

View File

@@ -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 |

View File

@@ -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 `<WorkspaceRoot>/<chatId>` |
| 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 <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 |
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).

View File

@@ -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 `<id>` works in `<root>/<id>`; 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-<t
Copy the workspace's `scripts/` into the owner's workspace too: watcher commands such as `python scripts/check.py` run
from there. A one-off that is already due fires on the first start.
### Impersonation
The admin (`ADMIN_USER_ID`) can act as another user with `/impersonate <user_id>`; `/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.

View File

@@ -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
}
}
/// <summary>
/// The admin's chat also lists <c>/impersonate</c>. Best-effort and not retried: it fails while the admin has never
/// started the bot, and the command works without the menu entry.
/// </summary>
private async Task SetAdminMenuAsync(IEnumerable<BotCommand> 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");
}
}
/// <summary>True once <c>getMe</c> and <c>setMyCommands</c> succeeded; false when stopped first.</summary>
private async Task<bool> 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)

View File

@@ -26,6 +26,9 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
new() { Command = "help", Description = "What this bot does" },
];
/// <summary>The 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" };
/// <summary>Split <c>/name@bot args</c>. Null when the text is not a command.</summary>
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) =>
$"""

View File

@@ -19,6 +19,9 @@ public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui
IChatChannel channel) : IAsyncDisposable
{
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>
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<long, Lazy<Task<ChatContext>>> _chats = new();
private readonly ILogger _logger = loggerFactory.CreateLogger<ChatRegistry>();
@@ -114,9 +118,10 @@ public sealed class ChatRegistry(
var last = (await factory.ListSessionsAsync(cwd, scope).ConfigureAwait(false)).FirstOrDefault();
var logger = loggerFactory.CreateLogger<ChatContext>();
var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger<ChatRenderer>());
var ui = new TelegramUi(chatId, sender, renderer);
var channel = new TelegramChatChannel(chatId, sender, renderer);
var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger<ChatRenderer>(),
deliverTo: impersonation is null ? null : () => impersonation.DeliveryChatFor(chatId));
var ui = new TelegramUi(sender, renderer);
var channel = new TelegramChatChannel(sender, renderer);
var extras = new List<ExtensionRegistration>();
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;
}

View File

@@ -0,0 +1,47 @@
using System.Globalization;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// The admin's (<see cref="TelegramOptions.AdminUserId"/>) <c>/impersonate &lt;user_id&gt;</c>. 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 <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.
/// Kept in memory: a restart ends it.
/// </summary>
public sealed class Impersonation(TelegramOptions options)
{
/// <summary>The reply to the impersonated user while the admin works as them.</summary>
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;
/// <summary>The impersonated user, or null when off.</summary>
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>True for the impersonated user themselves: their updates are blocked while the admin works as them.</summary>
public bool IsBlocked(long? userId) => userId is { } id && Target == id;
/// <summary>The user (or private chat) id the bot acts for: the target for the admin while impersonating, else <paramref name="id"/>.</summary>
public long EffectiveId(long id) => IsAdmin(id) && Target is { } target ? target : id;
/// <summary>Where chat <paramref name="chatId"/>'s output goes: the admin's chat while the admin impersonates it.</summary>
public long DeliveryChatFor(long chatId) => Target == chatId && AdminId is { } admin ? admin : chatId;
/// <summary>Handle <c>/impersonate</c>'s argument and return the reply for the admin.</summary>
public string Apply(string arg)
{
if (!long.TryParse(arg.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var id))
return "Usage: /impersonate <user_id> (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.";
}
}

View File

@@ -8,7 +8,7 @@ namespace Volcanic.AgenticCore.Telegram.Chats;
/// One chat's <see cref="IChatChannel"/>. Every delivery goes through the chat renderer's queue
/// (<see cref="ChatRenderer.RunInOrderAsync{T}"/>), so it can't overtake rendered output, and works while no run is active.
/// </summary>
public sealed class TelegramChatChannel(long chatId, TelegramSender sender, ChatRenderer renderer) : IChatChannel
public sealed class TelegramChatChannel(TelegramSender sender, ChatRenderer renderer) : IChatChannel
{
private static readonly HashSet<string> 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}");

View File

@@ -9,7 +9,7 @@ namespace Volcanic.AgenticCore.Telegram.Chats;
/// <see cref="IUserInterface"/> for one chat: choices become inline keyboards (callback data <c>ui:&lt;id&gt;:&lt;index&gt;</c>),
/// free-text questions a ForceReply prompt answered by the chat's next plain message.
/// </summary>
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

View File

@@ -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 <see cref="ChatInbox"/>:
/// the router takes a lease in update order, composes the message in the background, and the inbox turns the batch into
/// a prompt that runs in the background.
/// a prompt that runs in the background. The admin's <c>/impersonate</c> is handled first; while it is on, the admin's
/// updates are routed as the impersonated user's (see <see cref="Impersonation"/>).
/// </summary>
public sealed class UpdateRouter(
TelegramOptions options,
@@ -19,8 +20,12 @@ public sealed class UpdateRouter(
ChatCommands commands,
TelegramSender sender,
InboundComposer composer,
ILogger<UpdateRouter> logger) : IUpdateRouter
ILogger<UpdateRouter> logger,
Impersonation? impersonation = null) : IUpdateRouter
{
/// <summary>The id the bot acts for: the impersonated user's for the admin while impersonating, else <paramref name="id"/>.</summary>
private long Effective(long id) => impersonation?.EffectiveId(id) ?? id;
public async Task RouteAsync(Update update, CancellationToken cancellationToken)
{
switch (update)
@@ -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);
}
/// <summary>The 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)
{
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;
}
}

View File

@@ -76,6 +76,7 @@ builder.Services.AddSingleton<ITelegramBotClient>(_ => new TelegramBotClient(opt
builder.Services.AddSingleton(new ReconnectPolicy(options.ReconnectInitialDelay, options.ReconnectMaxDelay));
builder.Services.AddSingleton<TelegramSender>();
builder.Services.TryAddSingleton(TimeProvider.System);
builder.Services.AddSingleton<Impersonation>();
builder.Services.AddSingleton<ChatRegistry>();
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<PollingService>();
await host.RunAsync(); // disposes the host

View File

@@ -30,6 +30,7 @@ public sealed class ChatRenderer : IAsyncDisposable
private const int StatusArgsLength = 80;
private readonly long _chatId;
private readonly Func<long>? _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<ToolLine> _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)
/// <param name="deliverTo">Where output goes, read at each send; null = <paramref name="chatId"/> (see <see cref="Chats.Impersonation"/>).</param>
public ChatRenderer(long chatId, TelegramSender sender, TelegramOptions options, ILogger logger, TimeProvider? time = null, Func<long>? 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);
}
/// <summary>The Telegram chat this chat's output goes to now: its own, or the admin's while the admin impersonates it.</summary>
public long DeliveryChatId => _deliverTo?.Invoke() ?? _chatId;
/// <summary>Queue an event; safe to call from any thread (this is the session subscription).</summary>
public void Post(AgentSessionEvent e) => Enqueue(new EventItem(e));
@@ -119,7 +126,7 @@ public sealed class ChatRenderer : IAsyncDisposable
/// <summary>Plain text (no parse mode) in order with rendered output; cut to 4096 characters by the sender.</summary>
public Task<int?> SayAsync(string text, CancellationToken cancellationToken = default) =>
RunInOrderAsync(ct => _sender.SendTextAsync(_chatId, text, cancellationToken: ct), cancellationToken);
RunInOrderAsync(ct => _sender.SendTextAsync(DeliveryChatId, text, cancellationToken: ct), cancellationToken);
/// <summary>
/// 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);
}
}

View File

@@ -14,6 +14,12 @@ public sealed class TelegramOptions
/// <summary>Telegram user ids allowed to talk to the bot. Required: the agent runs shell commands.</summary>
public List<long> AllowedUserIds { get; set; } = [];
/// <summary>
/// The Telegram user id that may run <c>/impersonate &lt;user_id&gt;</c> (see <see cref="Chats.Impersonation"/>); null
/// turns the command off. Also read from <c>ADMIN_USER_ID</c>.
/// </summary>
public long? AdminUserId { get; set; }
/// <summary>Each chat works in <c>&lt;WorkspaceRoot&gt;/&lt;chatId&gt;</c>.</summary>
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.");

View File

@@ -58,4 +58,23 @@ public sealed class BotSetupServiceTests
Assert.Single(bot.Requests.OfType<GetMeRequest>());
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<TelegramSender>.Instance), NullLogger<BotSetupService>.Instance);
await service.StartAsync(CancellationToken.None);
await service.ExecuteTask!.WaitAsync(TimeSpan.FromSeconds(5));
await service.StopAsync(CancellationToken.None);
var menus = bot.Requests.OfType<SetMyCommandsRequest>().ToList();
Assert.Equal(2, menus.Count);
Assert.DoesNotContain(menus[0].Commands, c => c.Command == "impersonate");
var admin = Assert.IsType<global::Telegram.Bot.Types.BotCommandScopeChat>(menus[1].Scope);
Assert.Equal(5005, admin.ChatId.Identifier);
Assert.Contains(menus[1].Commands, c => c.Command == "impersonate");
}
}

View File

@@ -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<global::Telegram.Bot.Requests.AnswerCallbackQueryRequest>());
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));
}
}

View File

@@ -96,13 +96,17 @@ public sealed class ChatHost : IAsyncDisposable
_services.GetRequiredService<ISecretRedactor>(), clock, NullLogger<JobScheduler>.Instance);
}
Zones = new ChatTimeZoneService(Settings, id => Chats!.Find(id)?.Session, clock, Scheduler);
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar, Settings, Zones, Scheduler);
Impersonation = new Impersonation(Options);
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar, Settings, Zones, Scheduler, Impersonation);
if (Scheduler is not null) Delivery = new TelegramJobDelivery(Chats, Settings, Options, clock, NullLogger<TelegramJobDelivery>.Instance);
var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger<InboundMedia>.Instance, speech);
Composer = new InboundComposer(sender, media, NullLogger<InboundComposer>.Instance);
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender, Zones, Scheduler), sender, Composer, NullLogger<UpdateRouter>.Instance);
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender, Zones, Scheduler), sender, Composer, NullLogger<UpdateRouter>.Instance, Impersonation);
}
/// <summary>Off until <see cref="TelegramOptions.AdminUserId"/> is set on <see cref="Options"/>.</summary>
public Impersonation Impersonation { get; }
/// <summary>The composer the router uses (content steps 1–3).</summary>
public InboundComposer Composer { get; }

View File

@@ -112,7 +112,7 @@ public sealed class TelegramChatChannelTests
var sender = new TelegramSender(bot, NullLogger<TelegramSender>.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]