using Dapper; using GmRelay.Shared.Domain; using GmRelay.Bot.Features.Confirmation.SendConfirmation; using GmRelay.Bot.Features.Reminders.SendJoinLink; using GmRelay.Bot.Features.Reminders.SendOneHourReminder; using Npgsql; namespace GmRelay.Bot.Infrastructure.Scheduling; /// /// Stateless scheduler: wakes every 60 seconds, queries PostgreSQL for actionable sessions. /// Two triggers: /// T-24h: send confirmation request with inline keyboard /// 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( NpgsqlDataSource dataSource, SendConfirmationHandler confirmationHandler, SendOneHourReminderHandler oneHourReminderHandler, SendJoinLinkHandler joinLinkHandler, ILogger logger) : BackgroundService { private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1); private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24); private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1); private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { logger.LogInformation("Session scheduler started (interval: {Interval})", TickInterval); using var timer = new PeriodicTimer(TickInterval); // Run immediately on startup, then on each tick do { try { await ProcessConfirmationTriggers(stoppingToken); await ProcessOneHourReminderTriggers(stoppingToken); await ProcessJoinLinkTriggers(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"); } /// /// T-1h trigger: process direct reminders according to the session notification mode. /// private async Task ProcessOneHourReminderTriggers(CancellationToken ct) { await using var connection = await dataSource.OpenConnectionAsync(ct); var sessionIds = await connection.QueryAsync( """ SELECT id FROM sessions WHERE status IN (@Confirmed, @ConfirmationSent) AND scheduled_at - @LeadTime <= now() AND one_hour_reminder_processed_at IS NULL """, new { Confirmed = SessionStatus.Confirmed, ConfirmationSent = SessionStatus.ConfirmationSent, LeadTime = OneHourReminderLeadTime }); 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); } } } /// /// T-24h trigger: find sessions that need confirmation requests sent. /// Condition: status='Planned' AND scheduled_at minus 24h is in the past. /// private async Task ProcessConfirmationTriggers(CancellationToken ct) { await using var connection = await dataSource.OpenConnectionAsync(ct); var sessionIds = await connection.QueryAsync( """ SELECT id FROM sessions WHERE status = @Planned AND scheduled_at - @LeadTime <= now() """, new { Planned = SessionStatus.Planned, LeadTime = ConfirmationLeadTime }); 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); } } } /// /// T-5min trigger: find confirmed sessions that need join links sent. /// Condition: status='Confirmed' AND scheduled_at minus 5min is in the past AND link not yet sent. /// private async Task ProcessJoinLinkTriggers(CancellationToken ct) { await using var connection = await dataSource.OpenConnectionAsync(ct); var sessionIds = await connection.QueryAsync( """ SELECT id FROM sessions WHERE status = @Confirmed AND scheduled_at - @LeadTime <= now() AND link_message_id IS NULL """, new { Confirmed = SessionStatus.Confirmed, LeadTime = JoinLinkLeadTime }); 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); } } } }