feat(telegram): ADMIN_USER_ID may /impersonate another user

While on, the admin's updates are routed as the target user's (allow-list,
chat, workspace, session, settings, jobs) and the target chat's output goes
to the admin's chat. The target's own updates are dropped with a notice that
an admin is working with their account. In memory; /impersonate 0 stops.
This commit is contained in:
EugeneTes
2026-10-05 07:24:07 +00:00
parent 64ea6f7fa1
commit 92a0061fdd
20 changed files with 398 additions and 38 deletions

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Rendering;
@@ -30,6 +31,23 @@ public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions opti
}
}
/// <summary>
/// The admin's chat also lists <c>/impersonate</c>. Best-effort and not retried: it fails while the admin has never
/// started the bot, and the command works without the menu entry.
/// </summary>
private async Task SetAdminMenuAsync(IEnumerable<BotCommand> menu, CancellationToken stoppingToken)
{
if (options.AdminUserId is not { } admin) return;
try
{
await bot.SetMyCommands([.. menu, ChatCommands.ImpersonateCommand], BotCommandScope.Chat(admin), cancellationToken: stoppingToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Setting the admin's command menu failed");
}
}
/// <summary>True once <c>getMe</c> and <c>setMyCommands</c> succeeded; false when stopped first.</summary>
private async Task<bool> SetUpAsync(CancellationToken stoppingToken)
{
@@ -39,8 +57,10 @@ public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions opti
try
{
var me = await bot.GetMe(stoppingToken).ConfigureAwait(false);
await bot.SetMyCommands(GoogleCalendarSetup.MenuFor(ChatCommands.Menu, calendar), cancellationToken: stoppingToken).ConfigureAwait(false);
var menu = GoogleCalendarSetup.MenuFor(ChatCommands.Menu, calendar);
await bot.SetMyCommands(menu, cancellationToken: stoppingToken).ConfigureAwait(false);
logger.LogInformation("Bot @{Username} ({Id}) is ready", me.Username, me.Id);
await SetAdminMenuAsync(menu, stoppingToken).ConfigureAwait(false);
return true;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)

View File

@@ -26,6 +26,9 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
new() { Command = "help", Description = "What this bot does" },
];
/// <summary>The admin's chat gets <see cref="Menu"/> plus this (see <see cref="Impersonation"/>).</summary>
public static readonly BotCommand ImpersonateCommand = new() { Command = "impersonate", Description = "Act as another user: /impersonate <user_id>, 0 stops" };
/// <summary>Split <c>/name@bot args</c>. Null when the text is not a command.</summary>
public static (string Name, string Args)? Parse(string text)
{
@@ -120,7 +123,7 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
}
private Task ReplyAsync(ChatContext chat, string text, CancellationToken cancellationToken) =>
sender.SendHtmlAsync(chat.ChatId, TelegramFormatter.ToHtml(text), text, cancellationToken: cancellationToken);
sender.SendHtmlAsync(chat.DeliveryChatId, TelegramFormatter.ToHtml(text), text, cancellationToken: cancellationToken);
private static string Help(ChatContext chat) =>
$"""

View File

@@ -19,6 +19,9 @@ public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui
IChatChannel channel) : IAsyncDisposable
{
public long ChatId => chatId;
/// <summary>The Telegram chat replies go to: <see cref="ChatId"/>, or the admin's while impersonated (see <see cref="Impersonation"/>).</summary>
public long DeliveryChatId => renderer.DeliveryChatId;
public AgentSession Session => session;
public TelegramUi Ui => ui;
public ChatRenderer Renderer => renderer;
@@ -74,7 +77,8 @@ public sealed class ChatRegistry(
GoogleCalendarOptions? calendar = null,
ChatSettingsStore? settings = null,
ChatTimeZoneService? zones = null,
JobScheduler? scheduler = null) : IAsyncDisposable
JobScheduler? scheduler = null,
Impersonation? impersonation = null) : IAsyncDisposable
{
private readonly ConcurrentDictionary<long, Lazy<Task<ChatContext>>> _chats = new();
private readonly ILogger _logger = loggerFactory.CreateLogger<ChatRegistry>();
@@ -114,9 +118,10 @@ public sealed class ChatRegistry(
var last = (await factory.ListSessionsAsync(cwd, scope).ConfigureAwait(false)).FirstOrDefault();
var logger = loggerFactory.CreateLogger<ChatContext>();
var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger<ChatRenderer>());
var ui = new TelegramUi(chatId, sender, renderer);
var channel = new TelegramChatChannel(chatId, sender, renderer);
var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger<ChatRenderer>(),
deliverTo: impersonation is null ? null : () => impersonation.DeliveryChatFor(chatId));
var ui = new TelegramUi(sender, renderer);
var channel = new TelegramChatChannel(sender, renderer);
var extras = new List<ExtensionRegistration>();
if (calendar is not null) extras.Add(GoogleCalendarSetup.Registration(calendar));
if (zones is not null) extras.Add(ExtensionRegistration.FromInstance(new ChatSettingsExtension(chatId, zones)));
@@ -167,7 +172,7 @@ public sealed class ChatRegistry(
// Tell the user their message joined the running request (best-effort, fire-and-forget). If the run ends between
// this check and the steer, the message starts a new run and still shows 👀: harmless.
if (chat.Session.IsStreaming)
foreach (var part in parts) _ = sender.ReactAsync(chat.ChatId, part.MessageId, "👀");
foreach (var part in parts) _ = sender.ReactAsync(chat.DeliveryChatId, part.MessageId, "👀");
_ = chat.PromptInBackground(text, images.Count > 0 ? images : null);
return Task.CompletedTask;
}

View File

@@ -0,0 +1,47 @@
using System.Globalization;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// The admin's (<see cref="TelegramOptions.AdminUserId"/>) <c>/impersonate &lt;user_id&gt;</c>. While it is on, the admin's
/// messages and button presses are handled as the target user's: the allow-list check, the chat, its workspace, session,
/// settings and jobs. Everything the target's chat sends (replies, questions, files, scheduled deliveries) goes to the
/// admin's chat instead, until <c>/impersonate 0</c>. The target user's own messages and button presses are blocked
/// meanwhile and answered with <see cref="BlockedNotice"/>. In private chats the chat id is the user id, so one id maps both.
/// Kept in memory: a restart ends it.
/// </summary>
public sealed class Impersonation(TelegramOptions options)
{
/// <summary>The reply to the impersonated user while the admin works as them.</summary>
public const string BlockedNotice = "🔒 An admin is working with your account right now. Please try again later.";
private long _target; // 0 = off
public long? AdminId => options.AdminUserId;
/// <summary>The impersonated user, or null when off.</summary>
public long? Target => Volatile.Read(ref _target) is var target and not 0 ? target : null;
public bool IsAdmin(long? userId) => userId is { } id && id == AdminId;
/// <summary>True for the impersonated user themselves: their updates are blocked while the admin works as them.</summary>
public bool IsBlocked(long? userId) => userId is { } id && Target == id;
/// <summary>The user (or private chat) id the bot acts for: the target for the admin while impersonating, else <paramref name="id"/>.</summary>
public long EffectiveId(long id) => IsAdmin(id) && Target is { } target ? target : id;
/// <summary>Where chat <paramref name="chatId"/>'s output goes: the admin's chat while the admin impersonates it.</summary>
public long DeliveryChatFor(long chatId) => Target == chatId && AdminId is { } admin ? admin : chatId;
/// <summary>Handle <c>/impersonate</c>'s argument and return the reply for the admin.</summary>
public string Apply(string arg)
{
if (!long.TryParse(arg.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var id))
return "Usage: /impersonate <user_id> (0 stops). " + (Target is { } current ? $"You are acting as user {current}." : "You are not impersonating anyone.");
if (id == 0 || IsAdmin(id))
return Interlocked.Exchange(ref _target, 0) is not 0 ? "🎭 Impersonation off: you are yourself again." : "You are not impersonating anyone.";
Volatile.Write(ref _target, id);
var reply = $"🎭 You are now acting as user {id}: the bot handles your messages as theirs, and their chat's output comes here. /impersonate 0 stops.";
return options.IsAllowed(id) ? reply : reply + $"\n⚠️ User {id} is not in the allow-list, so the bot ignores your messages until you stop.";
}
}

View File

@@ -8,7 +8,7 @@ namespace Volcanic.AgenticCore.Telegram.Chats;
/// One chat's <see cref="IChatChannel"/>. Every delivery goes through the chat renderer's queue
/// (<see cref="ChatRenderer.RunInOrderAsync{T}"/>), so it can't overtake rendered output, and works while no run is active.
/// </summary>
public sealed class TelegramChatChannel(long chatId, TelegramSender sender, ChatRenderer renderer) : IChatChannel
public sealed class TelegramChatChannel(TelegramSender sender, ChatRenderer renderer) : IChatChannel
{
private static readonly HashSet<string> PhotoExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
@@ -28,12 +28,12 @@ public sealed class TelegramChatChannel(long chatId, TelegramSender sender, Chat
return renderer.RunInOrderAsync(async ct =>
{
var asPhoto = file.Kind == ChatFileKind.Photo || (file.Kind == ChatFileKind.Auto && LooksLikePhoto(file.Path));
var result = await sender.SendFileAsync(chatId, file.Path, asPhoto, file.Caption, ct).ConfigureAwait(false);
var result = await sender.SendFileAsync(renderer.DeliveryChatId, file.Path, asPhoto, file.Caption, ct).ConfigureAwait(false);
if (result.MessageId is { } id) return Delivered(asPhoto, id);
if (asPhoto && file.Kind == ChatFileKind.Auto && result.PhotoRejected)
{
// Telegram refuses some images as photos (dimensions, aspect ratio): send the file as a document instead.
result = await sender.SendFileAsync(chatId, file.Path, asPhoto: false, file.Caption, ct).ConfigureAwait(false);
result = await sender.SendFileAsync(renderer.DeliveryChatId, file.Path, asPhoto: false, file.Caption, ct).ConfigureAwait(false);
if (result.MessageId is { } documentId) return Delivered(false, documentId);
}
throw new ChatDeliveryException($"Telegram refused the file: {result.Error}");

View File

@@ -9,7 +9,7 @@ namespace Volcanic.AgenticCore.Telegram.Chats;
/// <see cref="IUserInterface"/> for one chat: choices become inline keyboards (callback data <c>ui:&lt;id&gt;:&lt;index&gt;</c>),
/// free-text questions a ForceReply prompt answered by the chat's next plain message.
/// </summary>
public sealed class TelegramUi(long chatId, TelegramSender sender, ChatRenderer renderer) : IUserInterface
public sealed class TelegramUi(TelegramSender sender, ChatRenderer renderer) : IUserInterface
{
public const string CallbackPrefix = "ui:";
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMinutes(10);
@@ -30,13 +30,14 @@ public sealed class TelegramUi(long chatId, TelegramSender sender, ChatRenderer
{
var keyboard = new InlineKeyboardMarkup(options.Select((option, i) =>
new[] { InlineKeyboardButton.WithCallbackData(option, $"{CallbackPrefix}{id}:{i}") }));
var messageId = await sender.SendTextAsync(chatId, title, keyboard, cancellationToken).ConfigureAwait(false);
var target = renderer.DeliveryChatId; // the edit below goes to the chat the question went to
var messageId = await sender.SendTextAsync(target, title, keyboard, cancellationToken).ConfigureAwait(false);
if (messageId is null) return null;
var index = await WaitAsync(tcs, requestOptions, cancellationToken).ConfigureAwait(false);
var choice = int.TryParse(index, out var i) && i >= 0 && i < options.Count ? options[i] : null;
var outcome = choice is null ? "— no answer" : $"→ {choice}";
await sender.EditHtmlAsync(chatId, messageId.Value, TelegramFormatter.Escape($"{title}\n{outcome}"), $"{title}\n{outcome}", CancellationToken.None)
await sender.EditHtmlAsync(target, messageId.Value, TelegramFormatter.Escape($"{title}\n{outcome}"), $"{title}\n{outcome}", CancellationToken.None)
.ConfigureAwait(false);
return choice;
}
@@ -56,7 +57,7 @@ public sealed class TelegramUi(long chatId, TelegramSender sender, ChatRenderer
try
{
var markup = new ForceReplyMarkup { InputFieldPlaceholder = placeholder };
if (await sender.SendTextAsync(chatId, title, markup, cancellationToken).ConfigureAwait(false) is null) return null;
if (await sender.SendTextAsync(renderer.DeliveryChatId, title, markup, cancellationToken).ConfigureAwait(false) is null) return null;
return await WaitAsync(tcs, requestOptions, cancellationToken).ConfigureAwait(false);
}
finally

View File

@@ -11,7 +11,8 @@ namespace Volcanic.AgenticCore.Telegram.Chats;
/// Routes an update to its chat: only allow-listed users in private chats get through; button presses and answers go to
/// the chat's pending dialog, bot commands run directly, everything else goes through the chat's <see cref="ChatInbox"/>:
/// the router takes a lease in update order, composes the message in the background, and the inbox turns the batch into
/// a prompt that runs in the background.
/// a prompt that runs in the background. The admin's <c>/impersonate</c> is handled first; while it is on, the admin's
/// updates are routed as the impersonated user's (see <see cref="Impersonation"/>).
/// </summary>
public sealed class UpdateRouter(
TelegramOptions options,
@@ -19,8 +20,12 @@ public sealed class UpdateRouter(
ChatCommands commands,
TelegramSender sender,
InboundComposer composer,
ILogger<UpdateRouter> logger) : IUpdateRouter
ILogger<UpdateRouter> logger,
Impersonation? impersonation = null) : IUpdateRouter
{
/// <summary>The id the bot acts for: the impersonated user's for the admin while impersonating, else <paramref name="id"/>.</summary>
private long Effective(long id) => impersonation?.EffectiveId(id) ?? id;
public async Task RouteAsync(Update update, CancellationToken cancellationToken)
{
switch (update)
@@ -36,22 +41,32 @@ public sealed class UpdateRouter(
private async Task OnCallbackAsync(CallbackQuery query, CancellationToken cancellationToken)
{
if (!options.IsAllowed(query.From.Id))
var userId = Effective(query.From.Id);
if (!options.IsAllowed(userId))
{
logger.LogWarning("Ignored a button press from user {UserId} (not allowed)", query.From.Id);
logger.LogWarning("Ignored a button press from user {UserId} (not allowed)", userId);
return;
}
if (impersonation?.IsBlocked(query.From.Id) == true)
{
await sender.AnswerCallbackAsync(query.Id, Impersonation.BlockedNotice, cancellationToken).ConfigureAwait(false);
return;
}
// The impersonated chat's question first, then the admin's own (asked before impersonation started).
var resolved = query.Message is { } message && query.Data is { } data
&& chats.Find(message.Chat.Id) is { } chat && chat.Ui.TryResolveCallback(data);
&& ((chats.Find(Effective(message.Chat.Id)) is { } chat && chat.Ui.TryResolveCallback(data))
|| (Effective(message.Chat.Id) != message.Chat.Id && chats.Find(message.Chat.Id) is { } own && own.Ui.TryResolveCallback(data)));
await sender.AnswerCallbackAsync(query.Id, resolved ? null : "This question has expired.", cancellationToken).ConfigureAwait(false);
}
private async Task OnMessageAsync(Message message, CancellationToken cancellationToken)
{
if (!options.IsAllowed(message.From?.Id))
if (await TryImpersonateAsync(message, cancellationToken).ConfigureAwait(false)) return;
var userId = message.From?.Id is { } from ? Effective(from) : (long?)null;
if (!options.IsAllowed(userId))
{
logger.LogWarning("Ignored a message from user {UserId} ({Username}) in chat {ChatId}: not in the allow-list",
message.From?.Id, message.From?.Username, message.Chat.Id);
userId, message.From?.Username, message.Chat.Id);
return;
}
if (message.Chat.Type != ChatType.Private)
@@ -59,6 +74,12 @@ public sealed class UpdateRouter(
logger.LogInformation("Ignored a message in non-private chat {ChatId}", message.Chat.Id);
return;
}
if (impersonation?.IsBlocked(message.From?.Id) == true)
{
// Straight to the user's own chat: its output goes to the admin meanwhile.
await sender.SendTextAsync(message.Chat.Id, Impersonation.BlockedNotice, cancellationToken: cancellationToken).ConfigureAwait(false);
return;
}
if (!InboundComposer.IsSupported(message))
{
@@ -66,7 +87,7 @@ public sealed class UpdateRouter(
return;
}
var chat = await chats.GetAsync(message.Chat.Id, cancellationToken).ConfigureAwait(false);
var chat = await chats.GetAsync(Effective(message.Chat.Id), cancellationToken).ConfigureAwait(false);
var text = message.Text ?? message.Caption ?? "";
// Commands and pending answers are checked before the inbox, and only for messages without an attachment that
// weren't forwarded: a forwarded message is content, never a command or an answer.
@@ -104,4 +125,17 @@ public sealed class UpdateRouter(
}
}, CancellationToken.None);
}
/// <summary>The admin's <c>/impersonate &lt;user_id&gt;</c> in a private chat, checked before the allow-list. False otherwise.</summary>
private async Task<bool> TryImpersonateAsync(Message message, CancellationToken cancellationToken)
{
if (impersonation is null || !impersonation.IsAdmin(message.From?.Id) || message.Chat.Type != ChatType.Private
|| InboundComposer.HasAttachment(message) || message.ForwardOrigin is not null
|| ChatCommands.Parse(message.Text ?? "") is not { Name: "impersonate" } command)
return false;
var reply = impersonation.Apply(command.Args);
logger.LogInformation("Admin {AdminId}: impersonating {Target}", message.From!.Id, impersonation.Target?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "nobody");
await sender.SendTextAsync(message.Chat.Id, reply, cancellationToken: cancellationToken).ConfigureAwait(false);
return true;
}
}

View File

@@ -76,6 +76,7 @@ builder.Services.AddSingleton<ITelegramBotClient>(_ => new TelegramBotClient(opt
builder.Services.AddSingleton(new ReconnectPolicy(options.ReconnectInitialDelay, options.ReconnectMaxDelay));
builder.Services.AddSingleton<TelegramSender>();
builder.Services.TryAddSingleton(TimeProvider.System);
builder.Services.AddSingleton<Impersonation>();
builder.Services.AddSingleton<ChatRegistry>();
builder.Services.AddSingleton(sp => new ChatSettingsStore(
ChatSettingsStore.DefaultPath(agentDir),
@@ -114,6 +115,7 @@ else
logger.LogInformation("Scheduler: {State}", scheduling.Enabled ? "on, jobs in each chat's .volcanic/scheduler/jobs.json" : "off");
logger.LogInformation("Default time zone: {Zone}", TimeZoneIds.Name(options.ConfiguredTimeZone() ?? TimeZoneInfo.Local));
logger.LogInformation("Allowed users: {Users}. Workspaces under {Root}", string.Join(", ", options.AllowedUserIds), options.WorkspaceRoot);
if (options.AdminUserId is { } admin) logger.LogInformation("Admin (may /impersonate): {Admin}", admin);
var polling = host.Services.GetRequiredService<PollingService>();
await host.RunAsync(); // disposes the host

View File

@@ -30,6 +30,7 @@ public sealed class ChatRenderer : IAsyncDisposable
private const int StatusArgsLength = 80;
private readonly long _chatId;
private readonly Func<long>? _deliverTo;
private readonly TelegramSender _sender;
private readonly TelegramOptions _options;
private readonly ILogger _logger;
@@ -54,15 +55,18 @@ public sealed class ChatRenderer : IAsyncDisposable
private readonly List<ToolLine> _recentTools = [];
private bool _running;
private int? _statusMessageId;
private long _statusChatId;
private string? _statusSent;
private bool _statusDirty;
private bool _statusBuried;
private bool Dirty => _statusDirty;
public ChatRenderer(long chatId, TelegramSender sender, TelegramOptions options, ILogger logger, TimeProvider? time = null)
/// <param name="deliverTo">Where output goes, read at each send; null = <paramref name="chatId"/> (see <see cref="Chats.Impersonation"/>).</param>
public ChatRenderer(long chatId, TelegramSender sender, TelegramOptions options, ILogger logger, TimeProvider? time = null, Func<long>? deliverTo = null)
{
_chatId = chatId;
_deliverTo = deliverTo;
_sender = sender;
_options = options;
_logger = logger;
@@ -70,6 +74,9 @@ public sealed class ChatRenderer : IAsyncDisposable
_loop = Task.Run(RunAsync);
}
/// <summary>The Telegram chat this chat's output goes to now: its own, or the admin's while the admin impersonates it.</summary>
public long DeliveryChatId => _deliverTo?.Invoke() ?? _chatId;
/// <summary>Queue an event; safe to call from any thread (this is the session subscription).</summary>
public void Post(AgentSessionEvent e) => Enqueue(new EventItem(e));
@@ -119,7 +126,7 @@ public sealed class ChatRenderer : IAsyncDisposable
/// <summary>Plain text (no parse mode) in order with rendered output; cut to 4096 characters by the sender.</summary>
public Task<int?> SayAsync(string text, CancellationToken cancellationToken = default) =>
RunInOrderAsync(ct => _sender.SendTextAsync(_chatId, text, cancellationToken: ct), cancellationToken);
RunInOrderAsync(ct => _sender.SendTextAsync(DeliveryChatId, text, cancellationToken: ct), cancellationToken);
/// <summary>
/// A Markdown reply rendered like an assistant message (HTML, split past 4096, each chunk retried), in order with
@@ -289,15 +296,16 @@ public sealed class ChatRenderer : IAsyncDisposable
if (string.IsNullOrWhiteSpace(text)) return (null, true);
int? first = null;
var complete = true;
var target = DeliveryChatId;
foreach (var chunk in TelegramFormatter.Split(text))
{
var html = TelegramFormatter.ToHtml(chunk);
var sent = await _sender.SendHtmlAsync(_chatId, html, chunk, cancellationToken: ct).ConfigureAwait(false);
var sent = await _sender.SendHtmlAsync(target, html, chunk, cancellationToken: ct).ConfigureAwait(false);
foreach (var delay in ReplyRetryDelays)
{
if (sent is not null) break;
await Task.Delay(delay, ct).ConfigureAwait(false);
sent = await _sender.SendHtmlAsync(_chatId, html, chunk, cancellationToken: ct).ConfigureAwait(false);
sent = await _sender.SendHtmlAsync(target, html, chunk, cancellationToken: ct).ConfigureAwait(false);
}
if (sent is null)
{
@@ -315,7 +323,7 @@ public sealed class ChatRenderer : IAsyncDisposable
private async Task SendLineAsync(string text, CancellationToken ct)
{
if (await _sender.SendHtmlAsync(_chatId, TelegramFormatter.Escape(text), text, cancellationToken: ct).ConfigureAwait(false) is not null)
if (await _sender.SendHtmlAsync(DeliveryChatId, TelegramFormatter.Escape(text), text, cancellationToken: ct).ConfigureAwait(false) is not null)
BuryStatus();
}
@@ -334,19 +342,22 @@ public sealed class ChatRenderer : IAsyncDisposable
_statusDirty = false;
if (!_running) return;
var html = StatusHtml();
if (_statusBuried && _statusMessageId is { } buried)
var target = DeliveryChatId;
// Moved to the bottom when buried, and to the other chat when the delivery chat changed (impersonation).
if ((_statusBuried || _statusChatId != target) && _statusMessageId is { } buried)
{
await _sender.DeleteAsync(_chatId, buried, ct).ConfigureAwait(false);
await _sender.DeleteAsync(_statusChatId, buried, ct).ConfigureAwait(false);
_statusMessageId = null;
}
_statusBuried = false;
if (_statusMessageId is not { } id)
{
_statusMessageId = await _sender.SendHtmlAsync(_chatId, html, StripTags(html), cancellationToken: ct).ConfigureAwait(false);
_statusChatId = target;
_statusMessageId = await _sender.SendHtmlAsync(target, html, StripTags(html), cancellationToken: ct).ConfigureAwait(false);
_statusSent = html;
}
else if (html != _statusSent && await _sender.EditHtmlAsync(_chatId, id, html, StripTags(html), ct).ConfigureAwait(false))
else if (html != _statusSent && await _sender.EditHtmlAsync(_statusChatId, id, html, StripTags(html), ct).ConfigureAwait(false))
{
_statusSent = html;
}
@@ -358,7 +369,7 @@ public sealed class ChatRenderer : IAsyncDisposable
_statusDirty = false;
_statusBuried = false;
_recentTools.Clear();
if (_statusMessageId is { } id) await _sender.DeleteAsync(_chatId, id, ct).ConfigureAwait(false);
if (_statusMessageId is { } id) await _sender.DeleteAsync(_statusChatId, id, ct).ConfigureAwait(false);
_statusMessageId = null;
_statusSent = null;
}
@@ -385,7 +396,7 @@ public sealed class ChatRenderer : IAsyncDisposable
{
while (!cts.IsCancellationRequested)
{
await _sender.SendTypingAsync(_chatId, cts.Token).ConfigureAwait(false);
await _sender.SendTypingAsync(DeliveryChatId, cts.Token).ConfigureAwait(false);
await Task.Delay(_options.TypingInterval, cts.Token).ConfigureAwait(false);
}
}

View File

@@ -14,6 +14,12 @@ public sealed class TelegramOptions
/// <summary>Telegram user ids allowed to talk to the bot. Required: the agent runs shell commands.</summary>
public List<long> AllowedUserIds { get; set; } = [];
/// <summary>
/// The Telegram user id that may run <c>/impersonate &lt;user_id&gt;</c> (see <see cref="Chats.Impersonation"/>); null
/// turns the command off. Also read from <c>ADMIN_USER_ID</c>.
/// </summary>
public long? AdminUserId { get; set; }
/// <summary>Each chat works in <c>&lt;WorkspaceRoot&gt;/&lt;chatId&gt;</c>.</summary>
public string WorkspaceRoot { get; set; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "telegram-workspaces");
@@ -61,6 +67,8 @@ public sealed class TelegramOptions
if (long.TryParse(part, out var id)) AllowedUserIds.Add(id);
}
if (AdminUserId is null && long.TryParse(getEnv("ADMIN_USER_ID")?.Trim(), out var admin)) AdminUserId = admin;
// Speech: the environment fills only empty settings, and an empty variable counts as unset.
Speech ??= new SpeechOptions();
if (string.IsNullOrWhiteSpace(Speech.ApiKey) && getEnv("OPENAI_API_KEY") is { } key && !string.IsNullOrWhiteSpace(key))
@@ -80,6 +88,7 @@ public sealed class TelegramOptions
if (AllowedUserIds.Count == 0)
errors.Add("No allowed users: set TELEGRAM_ALLOWED_USER_IDS (comma separated) or Telegram:AllowedUserIds. " +
"The bot runs shell commands, so it refuses to start open to everyone.");
if (AdminUserId is <= 0) errors.Add("Telegram:AdminUserId (ADMIN_USER_ID) must be a positive Telegram user id.");
if (string.IsNullOrWhiteSpace(WorkspaceRoot)) errors.Add("Telegram:WorkspaceRoot is empty.");
if (string.IsNullOrWhiteSpace(Model) || !Model.Contains('/')) errors.Add("Telegram:Model must be 'provider/model-id'.");
if (EditInterval <= TimeSpan.Zero || TypingInterval <= TimeSpan.Zero) errors.Add("Telegram intervals must be positive.");