forked from admin/VolcanicAgenticCore
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
178
tests/Volcanic.AgenticCore.Telegram.Tests/ImpersonationTests.cs
Normal file
178
tests/Volcanic.AgenticCore.Telegram.Tests/ImpersonationTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user