feat(telegram): serve A2A from the bot, callers per chat workspace, notify_owner into the chat

This commit is contained in:
EugeneTes
2026-10-07 13:50:54 +00:00
parent 93a4504607
commit c1a8744eb7
8 changed files with 152 additions and 5 deletions

View File

@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Http.Features;
namespace Volcanic.AgenticCore.Telegram.A2A;
/// <summary>Replaces Kestrel when A2A is off, so the bot opens no port.</summary>
internal sealed class NoHttpServer : IServer
{
public IFeatureCollection Features { get; } = new FeatureCollection();
public Task StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken) where TContext : notnull => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public void Dispose() { }
}

View File

@@ -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;
/// <summary>Serves A2A from the bot: callers live in the allowed chats' workspaces, <c>notify_owner</c> goes to the chat.</summary>
public static class TelegramA2ASetup
{
/// <summary>Allowed chats' workspaces: only they can hold callers.</summary>
public static Func<IEnumerable<A2AWorkspace>> 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<IServer, NoHttpServer>();
return;
}
services.AddSingleton<IA2ACallerDirectory>(sp => new WorkspaceCallerDirectory(Workspaces(options), sp.GetRequiredService<ILogger<WorkspaceCallerDirectory>>()));
services.AddSingleton<IA2AOwnerNotifier>(sp => new TelegramOwnerNotifier(() => sp.GetRequiredService<ChatRegistry>()));
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<A2AHostOptions>().Configure<IServiceProvider>((o, sp) =>
o.TimeZoneFor = caller => sp.GetService<ChatSettingsStore>()?.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";
}

View File

@@ -0,0 +1,16 @@
using System.Globalization;
using Volcanic.AgenticCore.A2A.Server.Callers;
using Volcanic.AgenticCore.Telegram.Chats;
namespace Volcanic.AgenticCore.Telegram.A2A;
/// <summary><c>notify_owner</c> → "🤖 A2A · caller: text" in the owner's chat, through its chat channel (in order with output).</summary>
internal sealed class TelegramOwnerNotifier(Func<ChatRegistry> 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);
}
}

View File

@@ -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<TelegramOptions>() ?? 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<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);
@@ -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<IScriptEnvironment, CalendarScriptEnvironment>();
builder.Services.AddHostedService<BotSetupService>();
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<ILoggerFactory>().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

View File

@@ -4,13 +4,13 @@
<UserSecretsId>volcanic-agentic-core-telegram</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="../../src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj" />
<ProjectReference Include="../../src/Volcanic.AgenticCore.A2A.Server/Volcanic.AgenticCore.A2A.Server.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Telegram.Bot" Version="22.10.3.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -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 }
}

View File

@@ -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<WorkspaceCallerDirectory>.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));
}
}

View File

@@ -11,6 +11,7 @@
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.9.0" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="../../hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj" />
</ItemGroup>
<ItemGroup>