Streamable HTTP/SSE servers from the workspace's .volcanic/mcp/servers.json. Headers are sealed with AES-GCM; the chat and the visible fields are the associated data, so only /mcp_add|remove|tools change servers. Tools <server>__<tool> go live in the session. An unreachable server is skipped and reported in the system prompt. Keys are redacted from tool results, and Telegram deletes messages that carry one.
106 lines
5.6 KiB
C#
106 lines
5.6 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;
|
|
private volatile bool _inputSecret;
|
|
|
|
/// <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
|
|
_inputSecret = requestOptions?.Secret == true;
|
|
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) => TryResolveInput(text, out _);
|
|
|
|
/// <summary>
|
|
/// Answer a pending <see cref="InputAsync"/> question; <paramref name="secret"/> tells the caller the question asked for
|
|
/// a secret (<see cref="UiRequestOptions.Secret"/>), so the answer's message should be deleted.
|
|
/// </summary>
|
|
public bool TryResolveInput(string text, out bool secret)
|
|
{
|
|
secret = _inputSecret;
|
|
return 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);
|
|
}
|
|
}
|