using System.Collections.Concurrent; using Telegram.Bot.Types.ReplyMarkups; using Volcanic.AgenticCore.Abstractions.Ui; using Volcanic.AgenticCore.Telegram.Rendering; namespace Volcanic.AgenticCore.Telegram.Chats; /// /// for one chat: choices become inline keyboards (callback data ui:<id>:<index>), /// free-text questions a ForceReply prompt answered by the chat's next plain message. /// 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> _choices = new(); private TaskCompletionSource? _input; /// True while an question waits for its answer. public bool IsWaitingForInput => Volatile.Read(ref _input) is { Task.IsCompleted: false }; public async Task SelectAsync(string title, IReadOnlyList options, UiRequestOptions? requestOptions, CancellationToken cancellationToken) { if (options.Count == 0) return null; var id = Guid.NewGuid().ToString("N")[..8]; var tcs = new TaskCompletionSource(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 ConfirmAsync(string title, string message, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => await SelectAsync($"{title}\n{message}", ["Yes", "No"], requestOptions, cancellationToken).ConfigureAwait(false) == "Yes"; public async Task InputAsync(string title, string? placeholder, UiRequestOptions? requestOptions, CancellationToken cancellationToken) { var tcs = new TaskCompletionSource(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)); /// Status lines are not shown in Telegram; tool and retry progress is rendered from session events. public void SetStatus(string key, string? text) { } /// Resolve a pending choice from callback data. False when it is not ours or no longer pending. 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]); } /// Answer a pending question. False when none is pending. public bool TryResolveInput(string text) => Interlocked.Exchange(ref _input, null) is { } tcs && tcs.TrySetResult(text); private static async Task WaitAsync(TaskCompletionSource 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); } }