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

@@ -32,19 +32,21 @@ 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
/// 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.
/// </summary>
private async Task SetAdminMenuAsync(IEnumerable<BotCommand> 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);
}
}
}

View File

@@ -38,7 +38,7 @@ 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>
/// <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" };
/// <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;
/// <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 AgentSession Session => session;
public TelegramUi Ui => ui;

View File

@@ -1,47 +1,66 @@
using System.Collections.Concurrent;
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
/// 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,
/// 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
/// 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>
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.";
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 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;
/// <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>
public bool IsBlocked(long? userId) => userId is { } id && Target == id;
/// <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 && 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>
public long EffectiveId(long id) => IsAdmin(id) && Target is { } target ? target : id;
/// <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) => TargetOf(id) ?? 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>Where chat <paramref name="chatId"/>'s output goes: the chat of the admin impersonating it, if any.</summary>
public long DeliveryChatFor(long chatId) => AdminOf(chatId) ?? chatId;
/// <summary>Handle <c>/impersonate</c>'s argument and return the reply for the admin.</summary>
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 <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.";
foreach (var (admin, current) in _targets)
if (current == target) return admin;
return null;
}
/// <summary>Handle <paramref name="adminId"/>'s <c>/impersonate</c> argument and return the reply for them.</summary>
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
/// the chat's pending dialog, bot commands run directly, everything else goes through the chat's <see cref="ChatInbox"/>:
/// the router takes a lease in update order, composes the message in the background, and the inbox turns the batch into
/// a prompt that runs in the background. The admin's <c>/impersonate</c> is handled first; while it is on, the admin's
/// 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
/// 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.
@@ -142,15 +142,16 @@ 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>
/// <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)
{
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;
}

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("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)

View File

@@ -74,7 +74,7 @@ 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>
/// <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;
/// <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; } = [];
/// <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>.
/// The Telegram user ids that may run <c>/impersonate &lt;user_id&gt;</c> (see <see cref="Chats.Impersonation"/>); all
/// 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>
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; }
/// <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>
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);
/// <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;
}
}