Files
VolcanicAgenticCore/hosts/Volcanic.AgenticCore.Telegram/Program.cs

135 lines
8.2 KiB
C#

// 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.ClaudeCode;
using Volcanic.AgenticCore.Extensions.Mcp;
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<TelegramOptions>() ?? new TelegramOptions();
options.ApplyEnvironment(Environment.GetEnvironmentVariable);
var processSection = builder.Configuration.GetSection(ProcessEnvironmentSection.SectionName).Get<ProcessEnvironmentSection>() ?? new ProcessEnvironmentSection();
var calendar = GoogleCalendarSetup.Options(
builder.Configuration.GetSection(GoogleCalendarSection.SectionName).Get<GoogleCalendarSection>() ?? new GoogleCalendarSection(),
Environment.GetEnvironmentVariable);
var processEnvironment = new ProcessEnvironmentOptions();
TelegramProcessEnvironment.Configure(processEnvironment, options, processSection, Environment.GetEnvironmentVariable, calendar);
var mcp = builder.Configuration.GetSection(McpOptions.SectionName).Get<McpOptions>() ?? 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<A2AHostOptions>() ?? new A2AHostOptions();
// 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(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<ISpeechToText, OpenAiSpeechToText>(client => client.Timeout = TimeSpan.FromMinutes(2))
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
.SetHandlerLifetime(Timeout.InfiniteTimeSpan);
if (calendar is not null) builder.Services.AddSingleton(calendar); // ChatRegistry and BotSetupService take it optionally
builder.Services.AddSingleton<OwnerLoginChannel>();
// 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<OwnerLoginChannel>().ResolveAsync(ct),
});
builder.Services.AddAgenticCore(o =>
{
o.AgentDir = agentDir;
o.DefaultModel = options.Model;
o.ProcessEnvironment = processEnvironment;
o.DefaultTimeZone = options.ConfiguredTimeZone();
o.AddExtension<ClaudeCodeProviderExtension>();
o.AddExtension<McpExtension>();
});
builder.Services.AddSingleton<ITelegramBotClient>(_ => new TelegramBotClient(options.BotToken));
builder.Services.AddSingleton(new ReconnectPolicy(options.ReconnectInitialDelay, options.ReconnectMaxDelay));
builder.Services.AddSingleton<TelegramSender>();
builder.Services.TryAddSingleton(TimeProvider.System);
builder.Services.AddSingleton<Impersonation>();
builder.Services.AddSingleton<ChatRegistry>();
builder.Services.AddSingleton(sp => new ChatSettingsStore(
ChatSettingsStore.DefaultPath(agentDir),
options.ConfiguredTimeZone() ?? TimeZoneInfo.Local,
sp.GetRequiredService<ILogger<ChatSettingsStore>>(),
sp.GetRequiredService<TimeProvider>()));
// 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<ChatSettingsStore>(),
chatId => sp.GetRequiredService<ChatRegistry>().Find(chatId)?.Session,
sp.GetRequiredService<TimeProvider>(),
sp.GetService<JobScheduler>()));
builder.Services.AddSingleton<ChatCommands>();
builder.Services.AddSingleton<InboundMedia>();
builder.Services.AddSingleton<InboundComposer>();
builder.Services.AddSingleton<IUpdateRouter, UpdateRouter>();
builder.Services.AddSingleton<PollingService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<PollingService>());
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<IScriptEnvironment, CalendarScriptEnvironment>();
builder.Services.AddHostedService<BotSetupService>();
TelegramA2ASetup.Register(builder.Services, options, a2a);
await using var host = builder.Build();
TelegramA2ASetup.Map(host, a2a);
var logger = host.Services.GetRequiredService<ILoggerFactory>().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("{Speech}", options.Speech.Describe());
logger.LogInformation("{Triggers}", options.Triggers.Describe());
logger.LogInformation("{A2A}", TelegramA2ASetup.Describe(a2a));
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.AdminUserId is { } admin) logger.LogInformation("Admin (may /impersonate): {Admin}", admin);
var polling = host.Services.GetRequiredService<PollingService>();
await host.RunAsync(); // disposes the host
return polling.FatalError is null ? 0 : 1;