forked from admin/VolcanicAgenticCore
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.
72 lines
3.9 KiB
C#
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><WorkspaceRoot>/<chatId></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;
|
|
}
|