feat(telegram): serve A2A from the bot, callers per chat workspace, notify_owner into the chat
This commit is contained in:
13
hosts/Volcanic.AgenticCore.Telegram/A2A/NoHttpServer.cs
Normal file
13
hosts/Volcanic.AgenticCore.Telegram/A2A/NoHttpServer.cs
Normal 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() { }
|
||||
}
|
||||
55
hosts/Volcanic.AgenticCore.Telegram/A2A/TelegramA2ASetup.cs
Normal file
55
hosts/Volcanic.AgenticCore.Telegram/A2A/TelegramA2ASetup.cs
Normal 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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
53
tests/Volcanic.AgenticCore.Telegram.Tests/A2AHostTests.cs
Normal file
53
tests/Volcanic.AgenticCore.Telegram.Tests/A2AHostTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user