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.
94 lines
5.1 KiB
C#
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:<id>:<index></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);
|
|
}
|
|
}
|