Files

67 lines
3.5 KiB
C#

using System.Globalization;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Telegram.Chats;
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);
try
{
// Idle: starts (or joins, as a follow-up) a run and returns when it settles. Busy: queued as a follow-up (never
// steering); the job then stays in flight until the run that consumed it settles.
await ChatFollowUps.RunAsync(chat, prompt, TimeZoneFor(target), time, 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;
}