Files
Playa/hosts/Volcanic.AgenticCore.Telegram/Scheduling/TelegramJobDelivery.cs
EugeneTes 28bbfbc759 feat(scheduler): store jobs per chat and import VolcanicSecretary job files
Each chat's jobs live in <workspace>/.volcanic/scheduler/jobs.json. On start the
scheduler loads every chat's file, moves the retired shared
<agentDir>/scheduler/jobs.json into them and renames it .migrated-<ts>.
A VolcanicSecretary jobs.json (bare array) in a chat's folder is converted
in place for that chat; the original is kept as .secretary-<ts>.

Also adds the VolcanicSecretary data migration plan.
2026-10-02 07:29:48 +00:00

72 lines
3.9 KiB
C#

using System.Globalization;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Inbound;
namespace Volcanic.AgenticCore.Telegram.Scheduling;
/// <summary>
/// The scheduler's view of Telegram. Targets are chat ids (invariant culture). Direct texts go through the chat's
/// <c>IChatChannel</c> (renderer queue, splitting, HTML with fallback, retries); prompts go straight to the chat's session
/// as follow-ups, never through <c>UpdateRouter</c> or the <c>ChatInbox</c>, so they are never debounced into a user's
/// message and never answer a pending question. <see cref="WorkspaceFor"/> and <see cref="TimeZoneFor"/> never create or
/// look up a session: a silent watcher tick leaves the chat untouched.
/// </summary>
public sealed class TelegramJobDelivery(ChatRegistry chats, ChatSettingsStore settings, TelegramOptions options, TimeProvider time, ILogger<TelegramJobDelivery> logger) : IJobDelivery
{
public static long ChatIdOf(string target) => long.Parse(target, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture);
public static string TargetOf(long chatId) => chatId.ToString(CultureInfo.InvariantCulture);
private static bool TryChatIdOf(string target, out long chatId) =>
long.TryParse(target, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out chatId);
public async Task SendTextAsync(string target, string markdown, CancellationToken cancellationToken)
{
var chat = await chats.GetAsync(ChatIdOf(target), cancellationToken).ConfigureAwait(false);
await chat.Channel.SendTextAsync(markdown, cancellationToken).ConfigureAwait(false);
}
public async Task RunPromptAsync(string target, string prompt, CancellationToken cancellationToken)
{
var chatId = ChatIdOf(target);
var chat = await chats.GetAsync(chatId, cancellationToken).ConfigureAwait(false);
var text = MessageContext.TimestampHeader(time.GetUtcNow(), TimeZoneFor(target)) + "\n" + prompt;
try
{
// Idle: starts (or joins, as a follow-up) a run and returns when it settles. Busy: queued as a follow-up (never
// steering) and returns at once; the job then stays in flight until the run that consumed it settles.
var queued = chat.Session.IsStreaming;
await chat.Session.PromptAsync(text, null, StreamingBehavior.FollowUp, cancellationToken).ConfigureAwait(false);
if (queued) await chat.Session.WaitForIdleAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "Scheduled prompt in chat {ChatId} failed", chatId);
chat.Report($"The scheduled job failed: {ex.Message}");
}
}
public string WorkspaceFor(string target) => chats.WorkspaceFor(ChatIdOf(target));
/// <summary>Every chat with a workspace directory (<c>&lt;WorkspaceRoot&gt;/&lt;chatId&gt;</c>).</summary>
public IEnumerable<string> KnownTargets() =>
Directory.Exists(options.WorkspaceRoot)
? Directory.EnumerateDirectories(options.WorkspaceRoot).Select(Path.GetFileName).OfType<string>().Where(name => TryChatIdOf(name, out _)).ToList()
: [];
/// <summary>Private chat id = user id: the chat is served while its user is allow-listed.</summary>
public bool IsDeliverable(string target) =>
TryChatIdOf(target, out var chatId) && options.IsAllowed(chatId);
/// <summary>An unparsable target (a hand-edited store) gets the default zone rather than an exception.</summary>
public TimeZoneInfo TimeZoneFor(string target) =>
TryChatIdOf(target, out var chatId) ? settings.TimeZoneFor(chatId) : settings.DefaultZone;
}