From c1a8744eb7bc164936791beeb43975bb923d68b9 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Wed, 7 Oct 2026 13:50:54 +0000 Subject: [PATCH] feat(telegram): serve A2A from the bot, callers per chat workspace, notify_owner into the chat --- .../A2A/NoHttpServer.cs | 13 +++++ .../A2A/TelegramA2ASetup.cs | 55 +++++++++++++++++++ .../A2A/TelegramOwnerNotifier.cs | 16 ++++++ .../Volcanic.AgenticCore.Telegram/Program.cs | 12 +++- .../Volcanic.AgenticCore.Telegram.csproj | 4 +- .../appsettings.json | 3 +- .../A2AHostTests.cs | 53 ++++++++++++++++++ ...Volcanic.AgenticCore.Telegram.Tests.csproj | 1 + 8 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 hosts/Volcanic.AgenticCore.Telegram/A2A/NoHttpServer.cs create mode 100644 hosts/Volcanic.AgenticCore.Telegram/A2A/TelegramA2ASetup.cs create mode 100644 hosts/Volcanic.AgenticCore.Telegram/A2A/TelegramOwnerNotifier.cs create mode 100644 tests/Volcanic.AgenticCore.Telegram.Tests/A2AHostTests.cs diff --git a/hosts/Volcanic.AgenticCore.Telegram/A2A/NoHttpServer.cs b/hosts/Volcanic.AgenticCore.Telegram/A2A/NoHttpServer.cs new file mode 100644 index 0000000..d10fa73 --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/A2A/NoHttpServer.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http.Features; + +namespace Volcanic.AgenticCore.Telegram.A2A; + +/// Replaces Kestrel when A2A is off, so the bot opens no port. +internal sealed class NoHttpServer : IServer +{ + public IFeatureCollection Features { get; } = new FeatureCollection(); + public Task StartAsync(IHttpApplication application, CancellationToken cancellationToken) where TContext : notnull => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public void Dispose() { } +} diff --git a/hosts/Volcanic.AgenticCore.Telegram/A2A/TelegramA2ASetup.cs b/hosts/Volcanic.AgenticCore.Telegram/A2A/TelegramA2ASetup.cs new file mode 100644 index 0000000..542dc49 --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/A2A/TelegramA2ASetup.cs @@ -0,0 +1,55 @@ +using System.Globalization; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Volcanic.AgenticCore.A2A.Server; +using Volcanic.AgenticCore.A2A.Server.Callers; +using Volcanic.AgenticCore.A2A.Server.Hosting; +using Volcanic.AgenticCore.Telegram.Chats; + +namespace Volcanic.AgenticCore.Telegram.A2A; + +/// Serves A2A from the bot: callers live in the allowed chats' workspaces, notify_owner goes to the chat. +public static class TelegramA2ASetup +{ + /// Allowed chats' workspaces: only they can hold callers. + public static Func> Workspaces(TelegramOptions options) => () => + options.AllowedUserIds.Select(id => new A2AWorkspace( + ChatRegistry.WorkspaceFor(options, id), ChatRegistry.ScopeFor(id), id.ToString(CultureInfo.InvariantCulture))); + + public static void Register(IServiceCollection services, TelegramOptions options, A2AHostOptions a2a) + { + services.AddSingleton(a2a); + if (!a2a.Enabled) + { + services.AddSingleton(); + return; + } + services.AddSingleton(sp => new WorkspaceCallerDirectory(Workspaces(options), sp.GetRequiredService>())); + services.AddSingleton(sp => new TelegramOwnerNotifier(() => sp.GetRequiredService())); + services.AddA2AServer(o => + { + o.Enabled = true; + o.PublicUrl = a2a.PublicUrl; + o.MaxHops = a2a.MaxHops; + o.SessionIdleTimeout = a2a.SessionIdleTimeout; + o.MaxFileBytes = a2a.MaxFileBytes; + o.AllowPrivatePushUrls = a2a.AllowPrivatePushUrls; + o.Card = a2a.Card; + o.Model = options.Model; + o.OnPush = a2a.OnPush; + }); + // Each session's zone is its owner's chat setting (read when the session opens). + services.AddOptions().Configure((o, sp) => + o.TimeZoneFor = caller => sp.GetService()?.TimeZoneFor(long.Parse(caller.OwnerId, CultureInfo.InvariantCulture))); + } + + public static void Map(IEndpointRouteBuilder app, A2AHostOptions a2a) + { + if (a2a.Enabled) app.MapA2AServer(); + } + + public static string Describe(A2AHostOptions a2a) => + a2a.Enabled ? $"A2A: on at {a2a.PublicUrl ?? "(request host)"}/a2a, callers in each chat's .volcanic/a2a/callers.json" : "A2A: off"; +} diff --git a/hosts/Volcanic.AgenticCore.Telegram/A2A/TelegramOwnerNotifier.cs b/hosts/Volcanic.AgenticCore.Telegram/A2A/TelegramOwnerNotifier.cs new file mode 100644 index 0000000..cf8fe44 --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/A2A/TelegramOwnerNotifier.cs @@ -0,0 +1,16 @@ +using System.Globalization; +using Volcanic.AgenticCore.A2A.Server.Callers; +using Volcanic.AgenticCore.Telegram.Chats; + +namespace Volcanic.AgenticCore.Telegram.A2A; + +/// notify_owner → "🤖 A2A · caller: text" in the owner's chat, through its chat channel (in order with output). +internal sealed class TelegramOwnerNotifier(Func chats) : IA2AOwnerNotifier +{ + public async Task NotifyAsync(A2ACaller caller, string text, CancellationToken cancellationToken) + { + var chatId = long.Parse(caller.OwnerId, CultureInfo.InvariantCulture); + var chat = await chats().GetAsync(chatId, cancellationToken).ConfigureAwait(false); + await chat.Channel.SendTextAsync($"🤖 A2A · {caller.Name}: {text}", cancellationToken).ConfigureAwait(false); + } +} diff --git a/hosts/Volcanic.AgenticCore.Telegram/Program.cs b/hosts/Volcanic.AgenticCore.Telegram/Program.cs index d47bd60..b1e5c78 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Program.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Program.cs @@ -3,6 +3,7 @@ // // 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; @@ -10,12 +11,14 @@ 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; @@ -24,7 +27,7 @@ using Volcanic.AgenticCore.Telegram.Scheduling; using Volcanic.AgenticCore.Telegram.Speech; using Volcanic.AgenticCore.Telegram.Triggers; -var builder = Host.CreateApplicationBuilder(args); +var builder = WebApplication.CreateBuilder(args); var options = builder.Configuration.GetSection(TelegramOptions.SectionName).Get() ?? new TelegramOptions(); options.ApplyEnvironment(Environment.GetEnvironmentVariable); @@ -36,6 +39,8 @@ var processEnvironment = new ProcessEnvironmentOptions(); TelegramProcessEnvironment.Configure(processEnvironment, options, processSection, Environment.GetEnvironmentVariable, calendar); 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(); // 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); @@ -102,8 +107,10 @@ if (options.Triggers.IsEnabled) TriggersSetup.Register(builder.Services, options // 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); -using var host = builder.Build(); +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. @@ -112,6 +119,7 @@ logger.LogInformation("{ProcessEnvironment}", TelegramProcessEnvironment.Describ 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 diff --git a/hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj b/hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj index d2a6f70..394c380 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj +++ b/hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj @@ -4,13 +4,13 @@ volcanic-agentic-core-telegram + + - - diff --git a/hosts/Volcanic.AgenticCore.Telegram/appsettings.json b/hosts/Volcanic.AgenticCore.Telegram/appsettings.json index 89b1bfd..9ad1e8b 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/appsettings.json +++ b/hosts/Volcanic.AgenticCore.Telegram/appsettings.json @@ -10,5 +10,6 @@ "BotToken": "", "AllowedUserIds": [], "Model": "claude-code/claude-opus-5-5" - } + }, + "A2A": { "Enabled": false, "PublicUrl": null, "MaxHops": 3, "SessionIdleTimeout": "00:30:00", "MaxFileBytes": 10485760, "AllowPrivatePushUrls": false } } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/A2AHostTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/A2AHostTests.cs new file mode 100644 index 0000000..633b03a --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/A2AHostTests.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Volcanic.AgenticCore.A2A.Server; +using Volcanic.AgenticCore.A2A.Server.Callers; +using Volcanic.AgenticCore.Telegram.A2A; +using Volcanic.AgenticCore.Telegram.Tests.Support; + +namespace Volcanic.AgenticCore.Telegram.Tests; + +public class A2AHostTests +{ + private static void WriteCallers(string workspace, string json) + { + Directory.CreateDirectory(Path.GetDirectoryName(CallersFile.PathFor(workspace))!); + File.WriteAllText(CallersFile.PathFor(workspace), json); + } + + [Fact] + public async Task CallerDirectory_OnlyAllowedChats_LiveEdit() + { + await using var host = new ChatHost(); + var directory = new WorkspaceCallerDirectory(TelegramA2ASetup.Workspaces(host.Options), NullLogger.Instance); + var allowed = host.WorkspaceOf(Updates.AllowedUser); + var stranger = Path.Combine(host.Options.WorkspaceRoot, "999"); + WriteCallers(stranger, """[ { "name": "x", "key": "k-stranger" } ]"""); + Assert.Empty(directory.Find("k-stranger")); + Assert.Empty(directory.Find("k1")); + WriteCallers(allowed, """[ { "name": "travel-bot", "key": "k1" } ]"""); + var caller = Assert.Single(directory.Find("k1")); + Assert.Equal($"telegram-{Updates.AllowedUser}", caller.ScopePrefix); + Assert.Equal(Updates.AllowedUser.ToString(), caller.OwnerId); + } + + [Fact] + public async Task NotifyOwner_ReachesOwnersChat() + { + await using var host = new ChatHost(); + var notifier = new TelegramOwnerNotifier(() => host.Chats); + var caller = new A2ACaller("travel-bot", "isolated", host.WorkspaceOf(), "telegram-1001", Updates.AllowedUser.ToString()); + await notifier.NotifyAsync(caller, "Alice booked Friday", CancellationToken.None); + Assert.Contains(host.Texts, t => t.Contains("A2A · travel-bot: Alice booked Friday")); + } + + [Fact] + public void A2aDisabled_NoEndpoints() + { + var services = new ServiceCollection(); + TelegramA2ASetup.Register(services, new TelegramOptions { BotToken = "t", AllowedUserIds = [1] }, new A2AHostOptions { Enabled = false }); + Assert.Contains(services, d => d.ServiceType == typeof(IServer) && d.ImplementationType == typeof(NoHttpServer)); + Assert.DoesNotContain(services, d => d.ServiceType == typeof(IA2ACallerDirectory)); + } +} diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/Volcanic.AgenticCore.Telegram.Tests.csproj b/tests/Volcanic.AgenticCore.Telegram.Tests/Volcanic.AgenticCore.Telegram.Tests.csproj index 736b826..4ad96d2 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/Volcanic.AgenticCore.Telegram.Tests.csproj +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/Volcanic.AgenticCore.Telegram.Tests.csproj @@ -11,6 +11,7 @@ +