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:
@@ -6,6 +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).
|
||||||
|
ADMIN_USER_ID=
|
||||||
|
|
||||||
# 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=
|
||||||
LOG_LEVEL=Information
|
LOG_LEVEL=Information
|
||||||
|
|||||||
@@ -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
|
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)
|
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.
|
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
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ services:
|
|||||||
- STT_MODEL=${STT_MODEL:-}
|
- STT_MODEL=${STT_MODEL:-}
|
||||||
- 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:-}
|
||||||
|
- 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
|
- 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
|
||||||
# Playwright MCP preset (MCP spec): the preinstalled binary, no npx download. Set PLAYWRIGHT_ENABLED=false for an
|
# Playwright MCP preset (MCP spec): the preinstalled binary, no npx download. Set PLAYWRIGHT_ENABLED=false for an
|
||||||
|
|||||||
@@ -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_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 |
|
||||||
| `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 |
|
||||||
|
|||||||
@@ -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>` |
|
| 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 |
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -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`).
|
- `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`.
|
||||||
|
|
||||||
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).
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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 |
|
||||||
| `Telegram:WorkspaceRoot` | `~/.volcanic/telegram-workspaces` | Chat `<id>` works in `<root>/<id>`; put `AGENTS.md`, `PERSONA.md`, `MEMORY.md` there |
|
| `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: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 |
|
||||||
@@ -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
|
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.
|
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
|
### Google Calendar
|
||||||
|
|
||||||
Each chat connects its own Google account with `/calendar_connect`, and the agent's `calendar` tool only uses that
|
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
|
## Security
|
||||||
|
|
||||||
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. 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
|
([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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||||
using Volcanic.AgenticCore.Telegram.Chats;
|
using Volcanic.AgenticCore.Telegram.Chats;
|
||||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
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>
|
/// <summary>True once <c>getMe</c> and <c>setMyCommands</c> succeeded; false when stopped first.</summary>
|
||||||
private async Task<bool> SetUpAsync(CancellationToken stoppingToken)
|
private async Task<bool> SetUpAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
@@ -39,8 +57,10 @@ public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions opti
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var me = await bot.GetMe(stoppingToken).ConfigureAwait(false);
|
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);
|
logger.LogInformation("Bot @{Username} ({Id}) is ready", me.Username, me.Id);
|
||||||
|
await SetAdminMenuAsync(menu, stoppingToken).ConfigureAwait(false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ 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>
|
||||||
|
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>
|
||||||
public static (string Name, string Args)? Parse(string text)
|
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) =>
|
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) =>
|
private static string Help(ChatContext chat) =>
|
||||||
$"""
|
$"""
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui
|
|||||||
IChatChannel channel) : IAsyncDisposable
|
IChatChannel channel) : IAsyncDisposable
|
||||||
{
|
{
|
||||||
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>
|
||||||
|
public long DeliveryChatId => renderer.DeliveryChatId;
|
||||||
public AgentSession Session => session;
|
public AgentSession Session => session;
|
||||||
public TelegramUi Ui => ui;
|
public TelegramUi Ui => ui;
|
||||||
public ChatRenderer Renderer => renderer;
|
public ChatRenderer Renderer => renderer;
|
||||||
@@ -74,7 +77,8 @@ public sealed class ChatRegistry(
|
|||||||
GoogleCalendarOptions? calendar = null,
|
GoogleCalendarOptions? calendar = null,
|
||||||
ChatSettingsStore? settings = null,
|
ChatSettingsStore? settings = null,
|
||||||
ChatTimeZoneService? zones = 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 ConcurrentDictionary<long, Lazy<Task<ChatContext>>> _chats = new();
|
||||||
private readonly ILogger _logger = loggerFactory.CreateLogger<ChatRegistry>();
|
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 last = (await factory.ListSessionsAsync(cwd, scope).ConfigureAwait(false)).FirstOrDefault();
|
||||||
|
|
||||||
var logger = loggerFactory.CreateLogger<ChatContext>();
|
var logger = loggerFactory.CreateLogger<ChatContext>();
|
||||||
var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger<ChatRenderer>());
|
var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger<ChatRenderer>(),
|
||||||
var ui = new TelegramUi(chatId, sender, renderer);
|
deliverTo: impersonation is null ? null : () => impersonation.DeliveryChatFor(chatId));
|
||||||
var channel = new TelegramChatChannel(chatId, sender, renderer);
|
var ui = new TelegramUi(sender, renderer);
|
||||||
|
var channel = new TelegramChatChannel(sender, renderer);
|
||||||
var extras = new List<ExtensionRegistration>();
|
var extras = new List<ExtensionRegistration>();
|
||||||
if (calendar is not null) extras.Add(GoogleCalendarSetup.Registration(calendar));
|
if (calendar is not null) extras.Add(GoogleCalendarSetup.Registration(calendar));
|
||||||
if (zones is not null) extras.Add(ExtensionRegistration.FromInstance(new ChatSettingsExtension(chatId, zones)));
|
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
|
// 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.
|
// this check and the steer, the message starts a new run and still shows 👀: harmless.
|
||||||
if (chat.Session.IsStreaming)
|
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);
|
_ = chat.PromptInBackground(text, images.Count > 0 ? images : null);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
47
hosts/Volcanic.AgenticCore.Telegram/Chats/Impersonation.cs
Normal file
47
hosts/Volcanic.AgenticCore.Telegram/Chats/Impersonation.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace Volcanic.AgenticCore.Telegram.Chats;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The admin's (<see cref="TelegramOptions.AdminUserId"/>) <c>/impersonate <user_id></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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ namespace Volcanic.AgenticCore.Telegram.Chats;
|
|||||||
/// One chat's <see cref="IChatChannel"/>. Every delivery goes through the chat renderer's queue
|
/// 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.
|
/// (<see cref="ChatRenderer.RunInOrderAsync{T}"/>), so it can't overtake rendered output, and works while no run is active.
|
||||||
/// </summary>
|
/// </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" };
|
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 =>
|
return renderer.RunInOrderAsync(async ct =>
|
||||||
{
|
{
|
||||||
var asPhoto = file.Kind == ChatFileKind.Photo || (file.Kind == ChatFileKind.Auto && LooksLikePhoto(file.Path));
|
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 (result.MessageId is { } id) return Delivered(asPhoto, id);
|
||||||
if (asPhoto && file.Kind == ChatFileKind.Auto && result.PhotoRejected)
|
if (asPhoto && file.Kind == ChatFileKind.Auto && result.PhotoRejected)
|
||||||
{
|
{
|
||||||
// Telegram refuses some images as photos (dimensions, aspect ratio): send the file as a document instead.
|
// 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);
|
if (result.MessageId is { } documentId) return Delivered(false, documentId);
|
||||||
}
|
}
|
||||||
throw new ChatDeliveryException($"Telegram refused the file: {result.Error}");
|
throw new ChatDeliveryException($"Telegram refused the file: {result.Error}");
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace Volcanic.AgenticCore.Telegram.Chats;
|
|||||||
/// <see cref="IUserInterface"/> for one chat: choices become inline keyboards (callback data <c>ui:<id>:<index></c>),
|
/// <see cref="IUserInterface"/> for one chat: choices become inline keyboards (callback data <c>ui:<id>:<index></c>),
|
||||||
/// free-text questions a ForceReply prompt answered by the chat's next plain message.
|
/// free-text questions a ForceReply prompt answered by the chat's next plain message.
|
||||||
/// </summary>
|
/// </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:";
|
public const string CallbackPrefix = "ui:";
|
||||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMinutes(10);
|
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) =>
|
var keyboard = new InlineKeyboardMarkup(options.Select((option, i) =>
|
||||||
new[] { InlineKeyboardButton.WithCallbackData(option, $"{CallbackPrefix}{id}:{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;
|
if (messageId is null) return null;
|
||||||
|
|
||||||
var index = await WaitAsync(tcs, requestOptions, cancellationToken).ConfigureAwait(false);
|
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 choice = int.TryParse(index, out var i) && i >= 0 && i < options.Count ? options[i] : null;
|
||||||
var outcome = choice is null ? "— no answer" : $"→ {choice}";
|
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);
|
.ConfigureAwait(false);
|
||||||
return choice;
|
return choice;
|
||||||
}
|
}
|
||||||
@@ -56,7 +57,7 @@ public sealed class TelegramUi(long chatId, TelegramSender sender, ChatRenderer
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var markup = new ForceReplyMarkup { InputFieldPlaceholder = placeholder };
|
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);
|
return await WaitAsync(tcs, requestOptions, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -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
|
/// 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.
|
/// 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>
|
/// </summary>
|
||||||
public sealed class UpdateRouter(
|
public sealed class UpdateRouter(
|
||||||
TelegramOptions options,
|
TelegramOptions options,
|
||||||
@@ -19,8 +20,12 @@ public sealed class UpdateRouter(
|
|||||||
ChatCommands commands,
|
ChatCommands commands,
|
||||||
TelegramSender sender,
|
TelegramSender sender,
|
||||||
InboundComposer composer,
|
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)
|
public async Task RouteAsync(Update update, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
switch (update)
|
switch (update)
|
||||||
@@ -36,22 +41,32 @@ public sealed class UpdateRouter(
|
|||||||
|
|
||||||
private async Task OnCallbackAsync(CallbackQuery query, CancellationToken cancellationToken)
|
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;
|
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
|
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);
|
await sender.AnswerCallbackAsync(query.Id, resolved ? null : "This question has expired.", cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnMessageAsync(Message message, CancellationToken cancellationToken)
|
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",
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (message.Chat.Type != ChatType.Private)
|
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);
|
logger.LogInformation("Ignored a message in non-private chat {ChatId}", message.Chat.Id);
|
||||||
return;
|
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))
|
if (!InboundComposer.IsSupported(message))
|
||||||
{
|
{
|
||||||
@@ -66,7 +87,7 @@ public sealed class UpdateRouter(
|
|||||||
return;
|
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 ?? "";
|
var text = message.Text ?? message.Caption ?? "";
|
||||||
// Commands and pending answers are checked before the inbox, and only for messages without an attachment that
|
// 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.
|
// weren't forwarded: a forwarded message is content, never a command or an answer.
|
||||||
@@ -104,4 +125,17 @@ public sealed class UpdateRouter(
|
|||||||
}
|
}
|
||||||
}, CancellationToken.None);
|
}, CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The admin's <c>/impersonate <user_id></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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ builder.Services.AddSingleton<ITelegramBotClient>(_ => new TelegramBotClient(opt
|
|||||||
builder.Services.AddSingleton(new ReconnectPolicy(options.ReconnectInitialDelay, options.ReconnectMaxDelay));
|
builder.Services.AddSingleton(new ReconnectPolicy(options.ReconnectInitialDelay, options.ReconnectMaxDelay));
|
||||||
builder.Services.AddSingleton<TelegramSender>();
|
builder.Services.AddSingleton<TelegramSender>();
|
||||||
builder.Services.TryAddSingleton(TimeProvider.System);
|
builder.Services.TryAddSingleton(TimeProvider.System);
|
||||||
|
builder.Services.AddSingleton<Impersonation>();
|
||||||
builder.Services.AddSingleton<ChatRegistry>();
|
builder.Services.AddSingleton<ChatRegistry>();
|
||||||
builder.Services.AddSingleton(sp => new ChatSettingsStore(
|
builder.Services.AddSingleton(sp => new ChatSettingsStore(
|
||||||
ChatSettingsStore.DefaultPath(agentDir),
|
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("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);
|
||||||
|
|
||||||
var polling = host.Services.GetRequiredService<PollingService>();
|
var polling = host.Services.GetRequiredService<PollingService>();
|
||||||
await host.RunAsync(); // disposes the host
|
await host.RunAsync(); // disposes the host
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public sealed class ChatRenderer : IAsyncDisposable
|
|||||||
private const int StatusArgsLength = 80;
|
private const int StatusArgsLength = 80;
|
||||||
|
|
||||||
private readonly long _chatId;
|
private readonly long _chatId;
|
||||||
|
private readonly Func<long>? _deliverTo;
|
||||||
private readonly TelegramSender _sender;
|
private readonly TelegramSender _sender;
|
||||||
private readonly TelegramOptions _options;
|
private readonly TelegramOptions _options;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
@@ -54,15 +55,18 @@ public sealed class ChatRenderer : IAsyncDisposable
|
|||||||
private readonly List<ToolLine> _recentTools = [];
|
private readonly List<ToolLine> _recentTools = [];
|
||||||
private bool _running;
|
private bool _running;
|
||||||
private int? _statusMessageId;
|
private int? _statusMessageId;
|
||||||
|
private long _statusChatId;
|
||||||
private string? _statusSent;
|
private string? _statusSent;
|
||||||
private bool _statusDirty;
|
private bool _statusDirty;
|
||||||
private bool _statusBuried;
|
private bool _statusBuried;
|
||||||
|
|
||||||
private bool Dirty => _statusDirty;
|
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;
|
_chatId = chatId;
|
||||||
|
_deliverTo = deliverTo;
|
||||||
_sender = sender;
|
_sender = sender;
|
||||||
_options = options;
|
_options = options;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -70,6 +74,9 @@ 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>
|
||||||
|
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>
|
||||||
public void Post(AgentSessionEvent e) => Enqueue(new EventItem(e));
|
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>
|
/// <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) =>
|
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>
|
/// <summary>
|
||||||
/// A Markdown reply rendered like an assistant message (HTML, split past 4096, each chunk retried), in order with
|
/// 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);
|
if (string.IsNullOrWhiteSpace(text)) return (null, true);
|
||||||
int? first = null;
|
int? first = null;
|
||||||
var complete = true;
|
var complete = true;
|
||||||
|
var target = DeliveryChatId;
|
||||||
foreach (var chunk in TelegramFormatter.Split(text))
|
foreach (var chunk in TelegramFormatter.Split(text))
|
||||||
{
|
{
|
||||||
var html = TelegramFormatter.ToHtml(chunk);
|
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)
|
foreach (var delay in ReplyRetryDelays)
|
||||||
{
|
{
|
||||||
if (sent is not null) break;
|
if (sent is not null) break;
|
||||||
await Task.Delay(delay, ct).ConfigureAwait(false);
|
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)
|
if (sent is null)
|
||||||
{
|
{
|
||||||
@@ -315,7 +323,7 @@ public sealed class ChatRenderer : IAsyncDisposable
|
|||||||
|
|
||||||
private async Task SendLineAsync(string text, CancellationToken ct)
|
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();
|
BuryStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,19 +342,22 @@ public sealed class ChatRenderer : IAsyncDisposable
|
|||||||
_statusDirty = false;
|
_statusDirty = false;
|
||||||
if (!_running) return;
|
if (!_running) return;
|
||||||
var html = StatusHtml();
|
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;
|
_statusMessageId = null;
|
||||||
}
|
}
|
||||||
_statusBuried = false;
|
_statusBuried = false;
|
||||||
|
|
||||||
if (_statusMessageId is not { } id)
|
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;
|
_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;
|
_statusSent = html;
|
||||||
}
|
}
|
||||||
@@ -358,7 +369,7 @@ public sealed class ChatRenderer : IAsyncDisposable
|
|||||||
_statusDirty = false;
|
_statusDirty = false;
|
||||||
_statusBuried = false;
|
_statusBuried = false;
|
||||||
_recentTools.Clear();
|
_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;
|
_statusMessageId = null;
|
||||||
_statusSent = null;
|
_statusSent = null;
|
||||||
}
|
}
|
||||||
@@ -385,7 +396,7 @@ public sealed class ChatRenderer : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
while (!cts.IsCancellationRequested)
|
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);
|
await Task.Delay(_options.TypingInterval, cts.Token).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
/// <summary>Telegram user ids allowed to talk to the bot. Required: the agent runs shell commands.</summary>
|
||||||
public List<long> AllowedUserIds { get; set; } = [];
|
public List<long> AllowedUserIds { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Telegram user id that may run <c>/impersonate <user_id></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><WorkspaceRoot>/<chatId></c>.</summary>
|
/// <summary>Each chat works in <c><WorkspaceRoot>/<chatId></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");
|
||||||
@@ -61,6 +67,8 @@ public sealed class TelegramOptions
|
|||||||
if (long.TryParse(part, out var id)) AllowedUserIds.Add(id);
|
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: the environment fills only empty settings, and an empty variable counts as unset.
|
||||||
Speech ??= new SpeechOptions();
|
Speech ??= new SpeechOptions();
|
||||||
if (string.IsNullOrWhiteSpace(Speech.ApiKey) && getEnv("OPENAI_API_KEY") is { } key && !string.IsNullOrWhiteSpace(key))
|
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)
|
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 (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.");
|
||||||
|
|||||||
@@ -58,4 +58,23 @@ public sealed class BotSetupServiceTests
|
|||||||
Assert.Single(bot.Requests.OfType<GetMeRequest>());
|
Assert.Single(bot.Requests.OfType<GetMeRequest>());
|
||||||
await service.StopAsync(CancellationToken.None);
|
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);
|
_services.GetRequiredService<ISecretRedactor>(), clock, NullLogger<JobScheduler>.Instance);
|
||||||
}
|
}
|
||||||
Zones = new ChatTimeZoneService(Settings, id => Chats!.Find(id)?.Session, clock, Scheduler);
|
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);
|
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);
|
var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger<InboundMedia>.Instance, speech);
|
||||||
Composer = new InboundComposer(sender, media, NullLogger<InboundComposer>.Instance);
|
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>
|
/// <summary>The composer the router uses (content steps 1–3).</summary>
|
||||||
public InboundComposer Composer { get; }
|
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 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 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] };
|
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]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user