158 lines
5.7 KiB
C#
158 lines
5.7 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class SessionSchedulerService(
|
|
NpgsqlDataSource dataSource,
|
|
SendConfirmationHandler confirmationHandler,
|
|
SendOneHourReminderHandler oneHourReminderHandler,
|
|
SendJoinLinkHandler joinLinkHandler,
|
|
ILogger<SessionSchedulerService> 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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// T-1h trigger: process direct reminders according to the session notification mode.
|
|
/// </summary>
|
|
private async Task ProcessOneHourReminderTriggers(CancellationToken ct)
|
|
{
|
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
|
|
|
var sessionIds = await connection.QueryAsync<Guid>(
|
|
"""
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// T-24h trigger: find sessions that need confirmation requests sent.
|
|
/// Condition: status='Planned' AND scheduled_at minus 24h is in the past.
|
|
/// </summary>
|
|
private async Task ProcessConfirmationTriggers(CancellationToken ct)
|
|
{
|
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
|
|
|
var sessionIds = await connection.QueryAsync<Guid>(
|
|
"""
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task ProcessJoinLinkTriggers(CancellationToken ct)
|
|
{
|
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
|
|
|
var sessionIds = await connection.QueryAsync<Guid>(
|
|
"""
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|