// Telegram front end for the agent: long polling, one agent session per private chat, Claude Code as the model; // groups are watched silently for the owners' topic triggers (Jev through OpenRouter). // // TELEGRAM_BOT_TOKEN=123:abc TELEGRAM_ALLOWED_USER_IDS=111111 dotnet run --project hosts/Volcanic.AgenticCore.Telegram using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Telegram.Bot; using Volcanic.AgenticCore.A2A.Server; using Volcanic.AgenticCore.Extensions.A2A; using Volcanic.AgenticCore.Extensions.ClaudeCode; using Volcanic.AgenticCore.Extensions.Mcp; using Volcanic.AgenticCore.Extensions.Mcp.Remote; using Volcanic.AgenticCore.Extensions.Scheduler; using Volcanic.AgenticCore.Features.Processes; using Volcanic.AgenticCore.Hosting; using Volcanic.AgenticCore.Telegram; using Volcanic.AgenticCore.Telegram.A2A; using Volcanic.AgenticCore.Telegram.Chats; using Volcanic.AgenticCore.Telegram.Inbound; using Volcanic.AgenticCore.Telegram.Polling; using Volcanic.AgenticCore.Telegram.Rendering; using Volcanic.AgenticCore.Telegram.Scheduling; using Volcanic.AgenticCore.Telegram.Speech; using Volcanic.AgenticCore.Telegram.Triggers; var builder = WebApplication.CreateBuilder(args); var options = builder.Configuration.GetSection(TelegramOptions.SectionName).Get() ?? new TelegramOptions(); options.ApplyEnvironment(Environment.GetEnvironmentVariable); var processSection = builder.Configuration.GetSection(ProcessEnvironmentSection.SectionName).Get() ?? new ProcessEnvironmentSection(); var calendar = GoogleCalendarSetup.Options( builder.Configuration.GetSection(GoogleCalendarSection.SectionName).Get() ?? new GoogleCalendarSection(), Environment.GetEnvironmentVariable); var processEnvironment = new ProcessEnvironmentOptions(); TelegramProcessEnvironment.Configure(processEnvironment, options, processSection, Environment.GetEnvironmentVariable, calendar); // Each chat's remote MCP servers; their keys are sealed with MCP_ENCRYPTION_SECRET (else the bot token). var remoteMcp = RemoteMcpSetup.Options(options, Environment.GetEnvironmentVariable, processEnvironment); var mcp = builder.Configuration.GetSection(McpOptions.SectionName).Get() ?? new McpOptions(); mcp.ApplyEnvironment(Environment.GetEnvironmentVariable); // Off: no HTTP listener at all (NoHttpServer replaces Kestrel). On: the A2A endpoints on Kestrel (ASPNETCORE_URLS / Urls). var a2a = builder.Configuration.GetSection(A2AHostOptions.SectionName).Get() ?? new A2AHostOptions(); if (string.IsNullOrWhiteSpace(a2a.PublicUrl)) a2a.PublicUrl = null; // compose passes an empty A2A__PublicUrl // The A2A client (the a2a tool in each chat, watches for long remote tasks): off unless A2AClient__Enabled=true. var a2aClient = builder.Configuration.GetSection(A2AClientSettings.SectionName).Get() ?? new A2AClientSettings(); // One agent directory for sessions, chat-settings.json and scheduler/jobs.json. var agentDir = VolcanicPaths.DefaultAgentDir; var scheduling = SchedulerSetup.Options(builder.Configuration.GetSection(SchedulerOptions.SectionName), agentDir); var errors = options.Validate().Concat(processEnvironment.Validate()).Concat(mcp.Validate()).Concat(scheduling.Validate()).ToList(); if (errors.Count > 0) { foreach (var error in errors) Console.Error.WriteLine(error); return 2; } builder.Services.AddSingleton(options); builder.Services.AddSingleton(mcp); builder.Services.AddSingleton(remoteMcp); builder.Services.AddSingleton(scheduling); builder.Services.AddSingleton(options.Speech); if (options.Speech.IsEnabled) // no key, no ISpeechToText: voice notes get the setup notice // InboundMedia (a singleton) keeps this typed client for the process lifetime, so the factory never rotates its // handler: let the handler recycle its pooled connections instead, so DNS changes are still picked up. builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromMinutes(2)) .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) .SetHandlerLifetime(Timeout.InfiniteTimeSpan); // The voice tool: ElevenLabs when configured (or chosen with TTS_PROVIDER), else OpenAI. ChatRegistry (a singleton) keeps // this client too, so the same handler setup. switch (options.Speech.ResolvedTtsProvider) { case TextToSpeechProvider.OpenAi: builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromMinutes(2)) .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) .SetHandlerLifetime(Timeout.InfiniteTimeSpan); break; case TextToSpeechProvider.ElevenLabs: builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromMinutes(2)) .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) .SetHandlerLifetime(Timeout.InfiniteTimeSpan); break; } if (calendar is not null) builder.Services.AddSingleton(calendar); // ChatRegistry and BotSetupService take it optionally builder.Services.AddSingleton(); // The extension is constructed per session through ActivatorUtilities, which injects this. LoginUi resolves // OwnerLoginChannel lazily (at call time, not here) to avoid a construction cycle: ChatRegistry depends on the // session factory, which depends on the extension, which would otherwise depend back on ChatRegistry. builder.Services.AddSingleton(sp => new ClaudeCodeProviderOptions { LoginUi = ct => sp.GetRequiredService().ResolveAsync(ct), }); builder.Services.AddAgenticCore(o => { o.AgentDir = agentDir; o.DefaultModel = options.Model; o.ProcessEnvironment = processEnvironment; o.DefaultTimeZone = options.ConfiguredTimeZone(); o.AddExtension(); o.AddExtension(); }); builder.Services.AddSingleton(_ => new TelegramBotClient(options.BotToken)); builder.Services.AddSingleton(new ReconnectPolicy(options.ReconnectInitialDelay, options.ReconnectMaxDelay)); builder.Services.AddSingleton(); builder.Services.TryAddSingleton(TimeProvider.System); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new ChatSettingsStore( ChatSettingsStore.DefaultPath(agentDir), options.ConfiguredTimeZone() ?? TimeZoneInfo.Local, sp.GetRequiredService>(), sp.GetRequiredService())); // Finds a chat's live session lazily (at call time), so ChatRegistry <-> ChatTimeZoneService is no construction cycle. builder.Services.AddSingleton(sp => new ChatTimeZoneService( sp.GetRequiredService(), chatId => sp.GetRequiredService().Find(chatId)?.Session, sp.GetRequiredService(), sp.GetService())); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Before PollingService (hosted services start in registration order): the watch hosted service points the registry at // the chats before the first chat session exists. Before the server setup too, which copies a2a: this sets a2a.OnPush // (push receiver -> watches) and the client's PushBaseUrl. TelegramA2AClientSetup.Register(builder.Services, a2aClient, a2a, A2AWatchRegistry.Shared); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); if (scheduling.Enabled) SchedulerSetup.Register(builder.Services); // the hosted service starts after PollingService if (options.Triggers.IsEnabled) TriggersSetup.Register(builder.Services, options, agentDir); // groups are ignored without a key // Watchers get a short-lived GOOGLE_ACCESS_TOKEN for their chat's calendar, never the client secret or refresh token. if (scheduling.Enabled && calendar is not null) builder.Services.AddSingleton(); builder.Services.AddHostedService(); TelegramA2ASetup.Register(builder.Services, options, a2a); await using var host = builder.Build(); TelegramA2ASetup.Map(host, a2a); var logger = host.Services.GetRequiredService().CreateLogger("Volcanic.AgenticCore.Telegram"); // Before polling and the first claude process; the spawn policy's own call later is a no-op. HostProcessHardening.Apply(processEnvironment, logger); logger.LogInformation("{ProcessEnvironment}", TelegramProcessEnvironment.Describe(processEnvironment)); logger.LogInformation("{Mcp}", mcp.DescribePlaywright()); logger.LogInformation("{RemoteMcp}", RemoteMcpSetup.Describe(Environment.GetEnvironmentVariable)); logger.LogInformation("{Speech}", options.Speech.Describe()); logger.LogInformation("{Triggers}", options.Triggers.Describe()); logger.LogInformation("{A2A}", TelegramA2ASetup.Describe(a2a)); logger.LogInformation("{A2AClient}", TelegramA2AClientSetup.Describe(a2aClient)); if (ClaudeInstallation.Locate(Environment.GetEnvironmentVariable) is { } claude) logger.LogInformation("Using Claude Code at {Path}, model {Model}", claude, options.Model); else logger.LogWarning("Claude Code (claude) was not found on PATH; requests will fail until it is installed and logged in. Send /claude-code-doctor to the bot for details."); logger.LogInformation("Scheduler: {State}", scheduling.Enabled ? "on, jobs in each chat's .volcanic/scheduler/jobs.json" : "off"); logger.LogInformation("Default time zone: {Zone}", TimeZoneIds.Name(options.ConfiguredTimeZone() ?? TimeZoneInfo.Local)); logger.LogInformation("Allowed users: {Users}. Workspaces under {Root}", string.Join(", ", options.AllowedUserIds), options.WorkspaceRoot); if (options.Admins.Count > 0) logger.LogInformation("Admins (may /impersonate): {Admins}", string.Join(", ", options.Admins)); // appsettings keeps Kestrel's own "Now listening" quiet (Microsoft.Hosting.Lifetime: Warning). if (a2a.Enabled) host.Lifetime.ApplicationStarted.Register(() => logger.LogInformation("A2A: listening on {Urls}", string.Join(", ", host.Urls))); var polling = host.Services.GetRequiredService(); try { await host.RunAsync(); // disposes the host } catch (Exception ex) when (a2a.Enabled && TelegramA2ASetup.IsBindFailure(ex)) { logger.LogCritical("A2A: could not open the HTTP listener ({Error}). Free the port, set ASPNETCORE_URLS, or set A2A__Enabled=false.", ex.Message); return 3; } return polling.FatalError is null ? 0 : 1;