using GmRelay.Bot.Features.Confirmation.SendConfirmation; using GmRelay.Bot.Features.Reminders.SendJoinLink; using GmRelay.Bot.Features.Reminders.SendOneHourReminder; namespace GmRelay.Bot.Infrastructure.Scheduling; /// /// Stateless scheduler: wakes every 60 seconds, queries PostgreSQL for actionable sessions. /// Three triggers: /// T-24h: send confirmation request with inline keyboard /// T-1h: send one-hour direct reminder /// T-5min: send join link to all confirmed players /// /// If the Raspberry Pi reboots, nothing is lost — all state is in the DB. /// public sealed class SessionSchedulerService( ISessionTriggerStore triggerStore, ISendConfirmationHandler confirmationHandler, ISendOneHourReminderHandler oneHourReminderHandler, ISendJoinLinkHandler joinLinkHandler, ISystemClock clock, ILogger logger) : BackgroundService { private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { logger.LogInformation("Session scheduler started (interval: {Interval})", TickInterval); using var timer = new PeriodicTimer(TickInterval); do { try { await TickAsync(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (Exception ex) { logger.LogError(ex, "Scheduler tick failed, will retry next tick"); } } while (await timer.WaitForNextTickAsync(stoppingToken)); logger.LogInformation("Session scheduler stopped"); } /// /// Runs a single scheduler tick using the current clock time. /// Public so it can be called from integration tests with a fake clock. /// public async Task TickAsync(CancellationToken ct) { var now = clock.UtcNow; await ProcessConfirmationTriggers(now, ct); await ProcessOneHourReminderTriggers(now, ct); await ProcessJoinLinkTriggers(now, ct); } private async Task ProcessConfirmationTriggers(DateTimeOffset now, CancellationToken ct) { IReadOnlyList sessionIds; try { sessionIds = await triggerStore.GetSessionsNeedingConfirmationAsync(now, ct); } catch (Exception ex) { logger.LogError(ex, "Failed to query confirmation triggers"); return; } foreach (var sessionId in sessionIds) { try { await confirmationHandler.HandleAsync(sessionId, ct); logger.LogInformation("Confirmation sent for session {SessionId}", sessionId); } catch (Exception ex) { logger.LogError(ex, "Failed to send confirmation for session {SessionId}", sessionId); } } } private async Task ProcessOneHourReminderTriggers(DateTimeOffset now, CancellationToken ct) { IReadOnlyList sessionIds; try { sessionIds = await triggerStore.GetSessionsNeedingOneHourReminderAsync(now, ct); } catch (Exception ex) { logger.LogError(ex, "Failed to query one-hour reminder triggers"); return; } foreach (var sessionId in sessionIds) { try { await oneHourReminderHandler.HandleAsync(sessionId, ct); logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId); } catch (Exception ex) { logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId); } } } private async Task ProcessJoinLinkTriggers(DateTimeOffset now, CancellationToken ct) { IReadOnlyList sessionIds; try { sessionIds = await triggerStore.GetSessionsNeedingJoinLinkAsync(now, ct); } catch (Exception ex) { logger.LogError(ex, "Failed to query join-link triggers"); return; } foreach (var sessionId in sessionIds) { try { await joinLinkHandler.HandleAsync(sessionId, ct); logger.LogInformation("Join link sent for session {SessionId}", sessionId); } catch (Exception ex) { logger.LogError(ex, "Failed to send join link for session {SessionId}", sessionId); } } } }