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.
67 lines
4.0 KiB
C#
67 lines
4.0 KiB
C#
using System.Collections.Concurrent;
|
||
using System.Globalization;
|
||
|
||
namespace Volcanic.AgenticCore.Telegram.Chats;
|
||
|
||
/// <summary>
|
||
/// An admin's (<see cref="TelegramOptions.Admins"/>) <c>/impersonate <user_id></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 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.
|
||
/// 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 an admin works as them.</summary>
|
||
public const string BlockedNotice = "š An admin is working with your account right now. Please try again later.";
|
||
|
||
private readonly ConcurrentDictionary<long, long> _targets = new(); // admin ā impersonated user
|
||
private readonly Lock _gate = new();
|
||
|
||
public IReadOnlyList<long> AdminIds => options.Admins;
|
||
|
||
public bool IsAdmin(long? userId) => userId is { } id && options.Admins.Contains(id);
|
||
|
||
/// <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 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 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 chat of the admin impersonating it, if any.</summary>
|
||
public long DeliveryChatFor(long chatId) => AdminOf(chatId) ?? chatId;
|
||
|
||
private long? AdminOf(long target)
|
||
{
|
||
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.";
|
||
}
|
||
}
|
||
}
|