Files
EugeneTes 891a644590 feat(mcp): remote MCP servers per chat with sealed keys and owner-only commands
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.
2026-10-09 21:11:01 +00:00

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:&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;
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);
}
}