Files
VolcanicAgenticCore/hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramUi.cs
EugeneTes 92a0061fdd 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.
2026-10-05 07:24:07 +00:00

94 lines
5.1 KiB
C#

using System.Collections.Concurrent;
using Telegram.Bot.Types.ReplyMarkups;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Telegram.Rendering;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// <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(TelegramSender sender, ChatRenderer renderer) : IUserInterface
{
public const string CallbackPrefix = "ui:";
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMinutes(10);
private readonly ConcurrentDictionary<string, TaskCompletionSource<string?>> _choices = new();
private TaskCompletionSource<string?>? _input;
/// <summary>True while an <see cref="InputAsync"/> question waits for its answer.</summary>
public bool IsWaitingForInput => Volatile.Read(ref _input) is { Task.IsCompleted: false };
public async Task<string?> SelectAsync(string title, IReadOnlyList<string> options, UiRequestOptions? requestOptions, CancellationToken cancellationToken)
{
if (options.Count == 0) return null;
var id = Guid.NewGuid().ToString("N")[..8];
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
_choices[id] = tcs;
try
{
var keyboard = new InlineKeyboardMarkup(options.Select((option, i) =>
new[] { InlineKeyboardButton.WithCallbackData(option, $"{CallbackPrefix}{id}:{i}") }));
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(target, messageId.Value, TelegramFormatter.Escape($"{title}\n{outcome}"), $"{title}\n{outcome}", CancellationToken.None)
.ConfigureAwait(false);
return choice;
}
finally
{
_choices.TryRemove(id, out _);
}
}
public async Task<bool> ConfirmAsync(string title, string message, UiRequestOptions? requestOptions, CancellationToken cancellationToken) =>
await SelectAsync($"{title}\n{message}", ["Yes", "No"], requestOptions, cancellationToken).ConfigureAwait(false) == "Yes";
public async Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? requestOptions, CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
Interlocked.Exchange(ref _input, tcs)?.TrySetResult(null); // one question at a time: a newer one cancels the older
try
{
var markup = new ForceReplyMarkup { InputFieldPlaceholder = placeholder };
if (await sender.SendTextAsync(renderer.DeliveryChatId, title, markup, cancellationToken).ConfigureAwait(false) is null) return null;
return await WaitAsync(tcs, requestOptions, cancellationToken).ConfigureAwait(false);
}
finally
{
Interlocked.CompareExchange(ref _input, null, tcs);
}
}
public void Notify(string message, NotifyKind kind = NotifyKind.Info) =>
renderer.Post(new Abstractions.Events.AgentSessionEvent.Notification(message, kind));
/// <summary>Status lines are not shown in Telegram; tool and retry progress is rendered from session events.</summary>
public void SetStatus(string key, string? text) { }
/// <summary>Resolve a pending choice from callback data. False when it is not ours or no longer pending.</summary>
public bool TryResolveCallback(string data)
{
if (!data.StartsWith(CallbackPrefix, StringComparison.Ordinal)) return false;
var parts = data[CallbackPrefix.Length..].Split(':');
return parts.Length == 2 && _choices.TryGetValue(parts[0], out var tcs) && tcs.TrySetResult(parts[1]);
}
/// <summary>Answer a pending <see cref="InputAsync"/> question. False when none is pending.</summary>
public bool TryResolveInput(string text) => Interlocked.Exchange(ref _input, null) is { } tcs && tcs.TrySetResult(text);
private static async Task<string?> WaitAsync(TaskCompletionSource<string?> tcs, UiRequestOptions? requestOptions, CancellationToken cancellationToken)
{
using var timeout = new CancellationTokenSource(requestOptions?.Timeout ?? DefaultTimeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, cancellationToken);
await using var registration = linked.Token.Register(() => tcs.TrySetResult(null)).ConfigureAwait(false);
return await tcs.Task.ConfigureAwait(false);
}
}