feat(telegram): scheduler delivery, hosted service, /jobs and /cancel

Spec: /jobs and /cancel explain when scheduling is off; Scheduler options validation.
This commit is contained in:
EugeneTes
2026-09-27 10:33:38 +02:00
parent 5b780fac36
commit 8dd874a83f
10 changed files with 381 additions and 10 deletions

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.Configuration;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Speech;
@@ -177,4 +178,20 @@ public sealed class FormattingAndOptionsTests
var bad = new TelegramOptions { BotToken = "t", AllowedUserIds = [1], DefaultTimeZone = "Mars/Olympus" };
Assert.Contains(bad.Validate(), e => e.StartsWith("Telegram:DefaultTimeZone: Unknown time zone \"Mars/Olympus\"", StringComparison.Ordinal));
}
[Fact]
public void Scheduler_options_default_and_validate()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Scheduler:MaxJobsPerTarget"] = "5" }).Build();
var options = Volcanic.AgenticCore.Telegram.Scheduling.SchedulerSetup.Options(config.GetSection("Scheduler"), "/agent");
Assert.True(options.Enabled);
Assert.Equal(Path.Combine("/agent", "scheduler", "jobs.json"), options.StorePath);
Assert.Equal(TimeSpan.FromSeconds(300), options.DefaultScriptTimeout);
Assert.Equal(5, options.MaxJobsPerTarget);
Assert.Empty(options.Validate());
options.MaxJobsPerTarget = 0;
Assert.Contains(options.Validate(), e => e.Contains("MaxJobsPerTarget", StringComparison.Ordinal));
}
}

View File

@@ -0,0 +1,166 @@
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Telegram.Scheduling;
using Volcanic.AgenticCore.Telegram.Tests.Support;
namespace Volcanic.AgenticCore.Telegram.Tests;
/// <summary>The scheduler through the Telegram delivery and commands, on a fake clock with a fake script runner.</summary>
public sealed class SchedulerChatTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
internal static readonly DateTimeOffset Now = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero); // Sat 14:03 Berlin
private const long OtherUser = 2002;
internal sealed class Runner : IScriptRunner
{
public int Calls;
public ScriptOutcome Outcome { get; set; } = new ScriptOutcome.Silent();
public Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref Calls);
return Task.FromResult(Outcome);
}
}
internal static async Task<(ChatHost Host, FakeTimeProvider Time)> StartedAsync(IScriptRunner? runner = null, Action<Microsoft.Extensions.DependencyInjection.IServiceCollection>? services = null)
{
var time = new FakeTimeProvider(Now);
var host = new ChatHost(core: o => o.DefaultTimeZone = Berlin, time: time, scheduler: new SchedulerOptions(), runner: runner);
host.Options.AllowedUserIds.Add(OtherUser);
await host.StartSchedulerAsync();
return (host, time);
}
internal static async Task AdvanceAsync(ChatHost host, FakeTimeProvider time, TimeSpan by)
{
time.Advance(by);
await host.Scheduler!.IdleAsync();
}
[Fact]
public async Task A_reminder_is_sent_to_its_chat_without_a_model_call_or_transcript_entry()
{
var (host, time) = await StartedAsync();
await using var _ = host;
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Dentist", JobKind.Reminder, At: "2026-09-26T14:05", Text: "Call the **dentist**"), CancellationToken.None);
await AdvanceAsync(host, time, TimeSpan.FromMinutes(2));
var chat = await host.ChatAsync();
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
var sent = Assert.Single(host.Bot.Sent);
Assert.Equal(Updates.AllowedUser, sent.ChatId.Identifier);
Assert.Equal("⏰ Call the <b>dentist</b>", sent.Text);
Assert.Empty(host.Faux.Requests);
Assert.Empty(chat.Session.Messages);
}
[Fact]
public async Task A_silent_watcher_with_a_fake_runner_creates_no_session_and_sends_nothing()
{
var runner = new Runner();
var (host, time) = await StartedAsync(runner);
await using var _ = host;
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Price", JobKind.Watcher, Cron: "* * * * *", Command: "scripts/price.sh"), CancellationToken.None);
for (var i = 0; i < 5; i++) await AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
Assert.Equal(5, runner.Calls);
Assert.Null(host.Chats.Find(Updates.AllowedUser)); // no session created or woken
Assert.Empty(host.Bot.Requests);
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task Delivery_WorkspaceFor_and_TimeZoneFor_never_create_a_session()
{
var (host, _) = await StartedAsync();
await using var __ = host;
await host.Settings.SetTimeZoneAsync(Updates.AllowedUser, "Asia/Tokyo", CancellationToken.None);
Assert.Equal(host.WorkspaceOf(), host.Delivery!.WorkspaceFor(ChatHost.Target));
Assert.Equal("Asia/Tokyo", host.Delivery.TimeZoneFor(ChatHost.Target).Id);
Assert.Null(host.Chats.Find(Updates.AllowedUser));
}
[Fact]
public async Task IsDeliverable_follows_the_allow_list()
{
var (host, _) = await StartedAsync();
await using var __ = host;
Assert.True(host.Delivery!.IsDeliverable(ChatHost.Target));
Assert.False(host.Delivery.IsDeliverable(Updates.Stranger.ToString(System.Globalization.CultureInfo.InvariantCulture)));
Assert.False(host.Delivery.IsDeliverable("not-a-chat"));
}
[Fact]
public async Task Jobs_lists_this_chats_jobs_in_its_zone()
{
var (host, _) = await StartedAsync();
await using var __ = host;
var once = await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Call the dentist", JobKind.Reminder, At: "2026-09-27T09:00", Text: "Call"), CancellationToken.None);
var cron = await host.Scheduler.CreateAsync(ChatHost.Target, new NewJob("Inbox summary", JobKind.Prompt, Cron: "0 8 * * 1-5", Text: "Summarise"), CancellationToken.None);
await host.SendAsync("/jobs");
Assert.Equal(
$"Scheduled jobs (Europe/Berlin, now 26 Sep 14:03)\n{once.Job.Id} ⏰ Call the dentist · once 27 Sep 09:00\n{cron.Job.Id} 💬 Inbox summary · 0 8 * * 1-5 · next 28 Sep 08:00",
host.LastReply());
}
[Fact]
public async Task Jobs_without_jobs_says_so()
{
var (host, _) = await StartedAsync();
await using var __ = host;
await host.SendAsync("/jobs");
Assert.Equal("No scheduled jobs.", host.LastReply());
}
[Fact]
public async Task Cancel_handles_own_unknown_and_other_chats_ids()
{
var (host, _) = await StartedAsync();
await using var __ = host;
var mine = (await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Call the dentist", JobKind.Reminder, Cron: "@daily", Text: "x"), CancellationToken.None)).Job.Id;
var theirs = (await host.Scheduler.CreateAsync("2002", new NewJob("Theirs", JobKind.Reminder, Cron: "@daily", Text: "y"), CancellationToken.None)).Job.Id;
await host.SendAsync($"/cancel {theirs}");
Assert.Equal($"No job {theirs} in this chat.", host.LastReply());
await host.SendAsync("/cancel zzzzz");
Assert.Equal("No job zzzzz in this chat.", host.LastReply());
await host.SendAsync($"/cancel {mine.ToUpperInvariant()}");
Assert.Equal($"Cancelled {mine} (Call the dentist).", host.LastReply());
Assert.Empty(host.Scheduler.List(ChatHost.Target));
Assert.Single(host.Scheduler.List("2002"));
}
[Fact]
public async Task Cancel_without_an_id_shows_usage()
{
var (host, _) = await StartedAsync();
await using var __ = host;
await host.SendAsync("/cancel");
Assert.Equal("Usage: /cancel <id> (see /jobs).", host.LastReply());
}
[Fact]
public async Task Status_shows_the_job_count()
{
var (host, _) = await StartedAsync();
await using var __ = host;
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("a", JobKind.Reminder, Cron: "@daily", Text: "x"), CancellationToken.None);
await host.SendAsync("/status");
Assert.Contains("Jobs: 1\n", host.LastReply());
}
[Fact]
public async Task Without_a_scheduler_jobs_says_scheduling_is_off()
{
await using var host = new ChatHost();
await host.SendAsync("/jobs");
Assert.Equal(SchedulerSetup.DisabledReply, host.LastReply());
}
}

View File

@@ -2,6 +2,8 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Features.Sessions;
@@ -9,6 +11,7 @@ using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Scheduling;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
@@ -37,9 +40,20 @@ public sealed class ChatHost : IAsyncDisposable
/// <summary>The host's root directory; a caller-supplied root survives disposal (e.g. for a restart).</summary>
public string Root => _root;
/// <summary>The scheduler when the host was built with <c>scheduler:</c> options; start it with <see cref="StartSchedulerAsync"/>.</summary>
public JobScheduler? Scheduler { get; }
public TelegramJobDelivery? Delivery { get; }
/// <summary><see cref="Updates.AllowedUser"/> as a scheduler target.</summary>
public const string Target = "1001";
public Task StartSchedulerAsync() => Scheduler!.StartAsync(Delivery!, CancellationToken.None);
public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? core = null, TimeProvider? time = null,
Volcanic.AgenticCore.Extensions.GoogleCalendar.GoogleCalendarOptions? calendar = null,
Volcanic.AgenticCore.Telegram.Speech.ISpeechToText? speech = null, string? root = null)
Volcanic.AgenticCore.Telegram.Speech.ISpeechToText? speech = null, string? root = null, SchedulerOptions? scheduler = null,
IScriptRunner? runner = null)
{
_ownsRoot = root is null;
_root = root ?? Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N"));
@@ -74,11 +88,19 @@ public sealed class ChatHost : IAsyncDisposable
var clock = time ?? TimeProvider.System;
var coreOptions = _services.GetRequiredService<Microsoft.Extensions.Options.IOptions<AgenticCoreOptions>>().Value;
Settings = new ChatSettingsStore(ChatSettingsStore.DefaultPath(agentDir), coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local, NullLogger<ChatSettingsStore>.Instance, clock);
Zones = new ChatTimeZoneService(Settings, id => Chats!.Find(id)?.Session, clock);
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar, Settings, Zones);
if (scheduler is not null)
{
if (string.IsNullOrWhiteSpace(scheduler.StorePath)) scheduler.StorePath = SchedulerOptions.DefaultStorePath(agentDir);
Scheduler = new JobScheduler(scheduler,
runner ?? new ScriptRunner(scheduler, _services.GetRequiredService<IProcessSpawnPolicy>(), NullLogger<ScriptRunner>.Instance, clock),
_services.GetRequiredService<ISecretRedactor>(), clock, NullLogger<JobScheduler>.Instance);
}
Zones = new ChatTimeZoneService(Settings, id => Chats!.Find(id)?.Session, clock, Scheduler);
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar, Settings, Zones, Scheduler);
if (Scheduler is not null) Delivery = new TelegramJobDelivery(Chats, Settings, Options, clock, NullLogger<TelegramJobDelivery>.Instance);
var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger<InboundMedia>.Instance, speech);
Composer = new InboundComposer(sender, media, NullLogger<InboundComposer>.Instance);
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender, Zones), sender, Composer, NullLogger<UpdateRouter>.Instance);
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender, Zones, Scheduler), sender, Composer, NullLogger<UpdateRouter>.Instance);
}
/// <summary>The composer the router uses (content steps 1–3).</summary>
@@ -113,6 +135,7 @@ public sealed class ChatHost : IAsyncDisposable
public async ValueTask DisposeAsync()
{
if (Scheduler is not null) await Scheduler.StopAsync(CancellationToken.None);
await Chats.DisposeAsync();
await _services.DisposeAsync();
if (_ownsRoot)