forked from admin/VolcanicAgenticCore
Content step 2 (InboundMedia.AddFilesAsync, InboxFiles): files land in <cwd>/inbox/<date>-<safe name>[-N], image documents within Telegram:InlineImageMaxBytes are attached inline, oversize files and failed downloads get a notice and no run. Documents, videos, audio and voice are accepted; the unsupported reply is now 'I can read text, photos, files and voice messages.' Spec: Updates helpers are named DocumentMessage/VideoMessage/VoiceMessage/AudioMessage; the getFile 'file is too big' notice reads 'over 20 MB'; the too-large inline note is a parenthesised suffix after the size; size formats spelled out.
92 lines
5.1 KiB
C#
92 lines
5.1 KiB
C#
// Telegram front end for the agent: long polling, one agent session per private chat, Claude Code as the model.
|
|
//
|
|
// TELEGRAM_BOT_TOKEN=123:abc TELEGRAM_ALLOWED_USER_IDS=111111 dotnet run --project hosts/Volcanic.AgenticCore.Telegram
|
|
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Telegram.Bot;
|
|
using Volcanic.AgenticCore.Extensions.ClaudeCode;
|
|
using Volcanic.AgenticCore.Extensions.Mcp;
|
|
using Volcanic.AgenticCore.Features.Processes;
|
|
using Volcanic.AgenticCore.Hosting;
|
|
using Volcanic.AgenticCore.Telegram;
|
|
using Volcanic.AgenticCore.Telegram.Chats;
|
|
using Volcanic.AgenticCore.Telegram.Inbound;
|
|
using Volcanic.AgenticCore.Telegram.Polling;
|
|
using Volcanic.AgenticCore.Telegram.Rendering;
|
|
using Volcanic.AgenticCore.Telegram.Speech;
|
|
|
|
var builder = Host.CreateApplicationBuilder(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);
|
|
var errors = options.Validate().Concat(processEnvironment.Validate()).Concat(mcp.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(options.Speech);
|
|
if (options.Speech.IsEnabled) // no key, no ISpeechToText: voice notes get the setup notice
|
|
builder.Services.AddHttpClient<ISpeechToText, OpenAiSpeechToText>(client => client.Timeout = TimeSpan.FromMinutes(2));
|
|
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.DefaultModel = options.Model;
|
|
o.ProcessEnvironment = processEnvironment;
|
|
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<ChatRegistry>();
|
|
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>());
|
|
builder.Services.AddHostedService<BotSetupService>();
|
|
|
|
using var host = builder.Build();
|
|
|
|
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());
|
|
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("Allowed users: {Users}. Workspaces under {Root}", string.Join(", ", options.AllowedUserIds), options.WorkspaceRoot);
|
|
|
|
var polling = host.Services.GetRequiredService<PollingService>();
|
|
await host.RunAsync(); // disposes the host
|
|
return polling.FatalError is null ? 0 : 1;
|