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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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><WorkspaceRoot>/<chatId></c>,
|
||||
/// stores its sessions under scope <c>telegram-<chatId></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
|
||||
|
||||
58
hosts/Volcanic.AgenticCore.Telegram/GoogleCalendarSetup.cs
Normal file
58
hosts/Volcanic.AgenticCore.Telegram/GoogleCalendarSetup.cs
Normal 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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user