feat(telegram): Google Calendar per chat (config, secret value, menu, help)

Spec §8: extension added when either value is set, menu and /help only when both are; host code in GoogleCalendarSetup.cs.
This commit is contained in:
EugeneTes
2026-09-26 20:18:00 +02:00
parent 149b932ca1
commit a635e9761b
11 changed files with 229 additions and 19 deletions

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Telegram.Chats;
namespace Volcanic.AgenticCore.Telegram;
@@ -9,7 +10,8 @@ namespace Volcanic.AgenticCore.Telegram;
/// Best-effort start-up calls: log who the bot is and publish the command menu. Retried in the background until the
/// first success, so a host started without network still comes up.
/// </summary>
public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions options, ILogger<BotSetupService> logger) : BackgroundService
public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions options, ILogger<BotSetupService> logger,
GoogleCalendarOptions? calendar = null) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
@@ -19,7 +21,7 @@ public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions opti
try
{
var me = await bot.GetMe(stoppingToken).ConfigureAwait(false);
await bot.SetMyCommands(ChatCommands.Menu, cancellationToken: stoppingToken).ConfigureAwait(false);
await bot.SetMyCommands(GoogleCalendarSetup.MenuFor(ChatCommands.Menu, calendar), cancellationToken: stoppingToken).ConfigureAwait(false);
logger.LogInformation("Bot @{Username} ({Id}) is ready", me.Username, me.Id);
return;
}

View File

@@ -115,7 +115,7 @@ public sealed class ChatCommands(TelegramSender sender)
/sessions, /resume <id> – earlier conversations
/compact – summarise the conversation to free context
/thinking [level] – show or set the thinking level
""";
""" + GoogleCalendarSetup.HelpLines(chat.Session.Commands);
private static string Status(ChatContext chat)
{

View File

@@ -4,6 +4,7 @@ using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Rendering;
@@ -54,7 +55,8 @@ public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui
/// One <see cref="ChatContext"/> per chat, created on first use. Each chat works in <c>&lt;WorkspaceRoot&gt;/&lt;chatId&gt;</c>,
/// stores its sessions under scope <c>telegram-&lt;chatId&gt;</c> and resumes the most recent one after a restart.
/// </summary>
public sealed class ChatRegistry(IAgentSessionFactory factory, TelegramSender sender, TelegramOptions options, ILoggerFactory loggerFactory)
public sealed class ChatRegistry(IAgentSessionFactory factory, TelegramSender sender, TelegramOptions options, ILoggerFactory loggerFactory,
GoogleCalendarOptions? calendar = null)
: IAsyncDisposable
{
private readonly ConcurrentDictionary<long, Lazy<Task<ChatContext>>> _chats = new();
@@ -102,6 +104,7 @@ public sealed class ChatRegistry(IAgentSessionFactory factory, TelegramSender se
SessionId = last?.SessionId,
Model = options.Model,
Ui = ui,
ExtraExtensions = calendar is null ? null : [GoogleCalendarSetup.Registration(calendar)],
}).ConfigureAwait(false);
}
catch

View File

@@ -0,0 +1,58 @@
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Features.Extensions;
namespace Volcanic.AgenticCore.Telegram;
/// <summary>The <c>GoogleCalendar</c> configuration section. <c>GOOGLE_CLIENT_ID</c>/<c>GOOGLE_CLIENT_SECRET</c> fill empty values.</summary>
public sealed class GoogleCalendarSection
{
public const string SectionName = "GoogleCalendar";
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
}
/// <summary>Everything the Telegram host does for Google Calendar, kept out of the shared host files.</summary>
public static class GoogleCalendarSetup
{
public static readonly IReadOnlyList<BotCommand> Menu =
[
new() { Command = "calendar_connect", Description = "Connect your Google Calendar" },
new() { Command = "calendar_status", Description = "Google Calendar connection" },
new() { Command = "calendar_disconnect", Description = "Disconnect Google Calendar" },
];
/// <summary>Null when neither the client id nor the secret is set; empty values count as unset.</summary>
public static GoogleCalendarOptions? Options(GoogleCalendarSection section, Func<string, string?> getEnv)
{
ArgumentNullException.ThrowIfNull(section);
ArgumentNullException.ThrowIfNull(getEnv);
var id = NonEmpty(section.ClientId) ?? NonEmpty(getEnv("GOOGLE_CLIENT_ID"));
var secret = NonEmpty(section.ClientSecret) ?? NonEmpty(getEnv("GOOGLE_CLIENT_SECRET"));
return id is null && secret is null ? null : new GoogleCalendarOptions { ClientId = id, ClientSecret = secret, GetEnv = getEnv };
}
/// <summary>True when both the client id and the secret are set, i.e. the extension will register its tool and commands.</summary>
public static bool IsActive(GoogleCalendarOptions? options) =>
options is not null && NonEmpty(options.ClientId) is not null && NonEmpty(options.ClientSecret) is not null;
/// <summary>The bot's command menu, with the calendar commands appended when the calendar is active.</summary>
public static IReadOnlyList<BotCommand> MenuFor(IReadOnlyList<BotCommand> menu, GoogleCalendarOptions? options) =>
IsActive(options) ? [.. menu, .. Menu] : menu;
/// <summary>Extra <c>/help</c> lines when the session has the calendar commands.</summary>
public static string HelpLines(IReadOnlyList<CommandInfo> sessionCommands)
{
ArgumentNullException.ThrowIfNull(sessionCommands);
return sessionCommands.Any(c => c.Name == "calendar_connect")
? "\n/calendar_connect – connect this chat's Google Calendar\n/calendar_status, /calendar_disconnect – check or remove the connection"
: "";
}
/// <summary>A new extension instance per session over the shared connections; the session scope picks the chat's account.</summary>
public static ExtensionRegistration Registration(GoogleCalendarOptions options) =>
new(GoogleCalendarExtension.ExtensionName, _ => new GoogleCalendarExtension(options), "telegram");
private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -20,8 +20,11 @@ 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);
TelegramProcessEnvironment.Configure(processEnvironment, options, processSection, Environment.GetEnvironmentVariable, calendar);
var errors = options.Validate().Concat(processEnvironment.Validate()).ToList();
if (errors.Count > 0)
{
@@ -30,6 +33,7 @@ if (errors.Count > 0)
}
builder.Services.AddSingleton(options);
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

View File

@@ -1,3 +1,4 @@
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Features.Processes;
namespace Volcanic.AgenticCore.Telegram;
@@ -7,8 +8,9 @@ public static class TelegramProcessEnvironment
{
public const string AllowVariable = "PROCESS_ENV_ALLOW";
/// <summary>Adds the section's lists, <c>PROCESS_ENV_ALLOW</c> and the bot token to <paramref name="target"/>.</summary>
public static void Configure(ProcessEnvironmentOptions target, TelegramOptions telegram, ProcessEnvironmentSection section, Func<string, string?> getEnv)
/// <summary>Adds the section's lists, <c>PROCESS_ENV_ALLOW</c>, the bot token and the Google client secret to <paramref name="target"/>.</summary>
public static void Configure(ProcessEnvironmentOptions target, TelegramOptions telegram, ProcessEnvironmentSection section, Func<string, string?> getEnv,
GoogleCalendarOptions? calendar = null)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(telegram);
@@ -20,6 +22,7 @@ public static class TelegramProcessEnvironment
target.Allow.AddRange(allow.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
target.HardenHostProcess = section.HardenHostProcess;
if (!string.IsNullOrWhiteSpace(telegram.BotToken)) target.SecretValues.Add(telegram.BotToken);
if (!string.IsNullOrWhiteSpace(calendar?.ClientSecret)) target.SecretValues.Add(calendar.ClientSecret);
}
/// <summary>The startup log line: names only, never values.</summary>

View File

@@ -6,6 +6,7 @@
<ItemGroup>
<ProjectReference Include="../../src/Volcanic.AgenticCore/Volcanic.AgenticCore.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" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Telegram.Bot" Version="22.10.3.1" />
</ItemGroup>