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.
90 lines
4.0 KiB
C#
90 lines
4.0 KiB
C#
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Telegram.Bot;
|
|
using Telegram.Bot.Types;
|
|
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
|
using Volcanic.AgenticCore.Telegram.Chats;
|
|
using Volcanic.AgenticCore.Telegram.Rendering;
|
|
|
|
namespace Volcanic.AgenticCore.Telegram;
|
|
|
|
/// <summary>
|
|
/// Best-effort start-up calls: log who the bot is and publish the command menu, retried in the background until the
|
|
/// first success so a host started without network still comes up. Then, once per process, tell the owner
|
|
/// (<c>AllowedUserIds[0]</c>) the bot is back. That message is never retried: a 403 (the owner never started the bot)
|
|
/// is logged by <see cref="TelegramSender"/> and that's all.
|
|
/// </summary>
|
|
public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions options, TelegramSender sender, ILogger<BotSetupService> logger,
|
|
GoogleCalendarOptions? calendar = null) : BackgroundService
|
|
{
|
|
public const string OnlineText = "🟢 Online.";
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
if (!await SetUpAsync(stoppingToken).ConfigureAwait(false) || options.AllowedUserIds.Count == 0) return;
|
|
try
|
|
{
|
|
await sender.SendTextAsync(options.AllowedUserIds[0], OnlineText, cancellationToken: stoppingToken).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The admin's chat also lists <c>/impersonate</c>. Best-effort and not retried: it fails while the admin has never
|
|
/// started the bot, and the command works without the menu entry.
|
|
/// </summary>
|
|
private async Task SetAdminMenuAsync(IEnumerable<BotCommand> menu, CancellationToken stoppingToken)
|
|
{
|
|
if (options.AdminUserId is not { } admin) return;
|
|
try
|
|
{
|
|
await bot.SetMyCommands([.. menu, ChatCommands.ImpersonateCommand], BotCommandScope.Chat(admin), cancellationToken: stoppingToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
logger.LogWarning(ex, "Setting the admin's command menu failed");
|
|
}
|
|
}
|
|
|
|
/// <summary>True once <c>getMe</c> and <c>setMyCommands</c> succeeded; false when stopped first.</summary>
|
|
private async Task<bool> SetUpAsync(CancellationToken stoppingToken)
|
|
{
|
|
var delay = options.ReconnectInitialDelay;
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
var me = await bot.GetMe(stoppingToken).ConfigureAwait(false);
|
|
var menu = RemoteMcpSetup.MenuFor(GoogleCalendarSetup.MenuFor(ChatCommands.Menu, calendar));
|
|
await bot.SetMyCommands(menu, cancellationToken: stoppingToken).ConfigureAwait(false);
|
|
logger.LogInformation("Bot @{Username} ({Id}) is ready", me.Username, me.Id);
|
|
if (options.Triggers.IsEnabled && !me.CanReadAllGroupMessages)
|
|
logger.LogWarning("Privacy mode is on: in groups the bot only sees commands, mentions and replies, so group triggers miss most messages. " +
|
|
"Turn it off with @BotFather (/setprivacy → Disable), then remove and re-add the bot to each group, or make it a group admin.");
|
|
await SetAdminMenuAsync(menu, stoppingToken).ConfigureAwait(false);
|
|
return true;
|
|
}
|
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
{
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogDebug(ex, "Bot setup failed; retrying in {Delay}", delay);
|
|
}
|
|
try
|
|
{
|
|
await Task.Delay(delay, stoppingToken).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return false;
|
|
}
|
|
delay = TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, options.ReconnectMaxDelay.Ticks));
|
|
}
|
|
return false;
|
|
}
|
|
}
|