Initial commit: GM-Relay Telegram Bot

This commit is contained in:
2026-04-13 13:52:49 +03:00
commit 9db4bee2f6
51 changed files with 3407 additions and 0 deletions
@@ -0,0 +1,40 @@
using System.Reflection;
using DbUp;
namespace GmRelay.Bot.Infrastructure.Database;
/// <summary>
/// Runs embedded SQL migrations via DbUp on application startup.
/// Scripts are embedded as resources from the Migrations/ folder.
/// NOTE: We read the connection string from IConfiguration directly,
/// because NpgsqlDataSource.ConnectionString strips the password by default.
/// </summary>
public sealed class DbMigrator(IConfiguration configuration, ILogger<DbMigrator> logger)
{
public void MigrateUp()
{
var connectionString = configuration.GetConnectionString("gmrelaydb")
?? throw new InvalidOperationException("ConnectionStrings:gmrelaydb is required.");
EnsureDatabase.For.PostgresqlDatabase(connectionString);
var upgrader = DeployChanges.To
.PostgresqlDatabase(connectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly(), s => s.Contains(".Migrations."))
.WithTransactionPerScript()
.LogToConsole()
.Build();
var result = upgrader.PerformUpgrade();
if (!result.Successful)
{
var ex = result.Error;
logger.LogCritical(ex, "Database migration failed");
throw ex;
}
var count = result.Scripts.Count();
logger.LogInformation("Database migrations applied successfully. {Count} scripts executed", count);
}
}
@@ -0,0 +1,117 @@
using Dapper;
using GmRelay.Bot.Domain;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Reminders.SendJoinLink;
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,
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 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 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-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);
}
}
}
}
@@ -0,0 +1,73 @@
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace GmRelay.Bot.Infrastructure.Telegram;
/// <summary>
/// Long polling loop for Telegram Bot API.
/// Stateless — all state is in PostgreSQL. Safe to restart at any time.
/// </summary>
public sealed class TelegramBotService(
ITelegramBotClient bot,
UpdateRouter router,
ILogger<TelegramBotService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Telegram bot polling started");
// Skip any pending updates from before this startup
try
{
var pending = await bot.GetUpdates(offset: -1, limit: 1, cancellationToken: stoppingToken);
if (pending.Length > 0)
{
logger.LogInformation("Skipped {Count} pending update(s)", pending[^1].Id);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to clear pending updates, continuing anyway");
}
var offset = 0;
while (!stoppingToken.IsCancellationRequested)
{
try
{
var updates = await bot.GetUpdates(
offset: offset,
timeout: 30,
allowedUpdates: [UpdateType.Message, UpdateType.CallbackQuery],
cancellationToken: stoppingToken);
foreach (var update in updates)
{
try
{
await router.RouteAsync(update, stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Error handling update {UpdateId}", update.Id);
}
offset = update.Id + 1;
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Polling error, retrying in 5s");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
logger.LogInformation("Telegram bot polling stopped");
}
}
@@ -0,0 +1,207 @@
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.ListSessions;
using GmRelay.Bot.Features.Sessions.ExportCalendar;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace GmRelay.Bot.Infrastructure.Telegram;
/// <summary>
/// Routes incoming Telegram updates to the appropriate feature handler.
/// No reflection — all routing is explicit (AOT-safe).
/// </summary>
public sealed class UpdateRouter(
HandleRsvpHandler rsvpHandler,
CreateSessionHandler createSessionHandler,
JoinSessionHandler joinSessionHandler,
CancelSessionHandler cancelSessionHandler,
DeleteSessionHandler deleteSessionHandler,
ListSessionsHandler listSessionsHandler,
ExportCalendarHandler exportCalendarHandler,
InitiateRescheduleHandler initiateRescheduleHandler,
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
HandleRescheduleVoteHandler rescheduleVoteHandler,
ITelegramBotClient bot,
ILogger<UpdateRouter> logger)
{
public async Task RouteAsync(Update update, CancellationToken ct)
{
switch (update)
{
case { CallbackQuery: { } query }:
await HandleCallbackQueryAsync(query, ct);
break;
case { Message: { Text: { } text } message } when text.StartsWith('/'):
await HandleCommandAsync(message, text, ct);
break;
// Non-command text messages — check for reschedule time input
case { Message: { Text: { } } message } when !message.Text!.StartsWith('/'):
await rescheduleTimeInputHandler.TryHandleAsync(message, ct);
break;
}
}
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
{
if (query.Data is not { } data || query.Message is not { } message)
return;
var parts = data.Split(':', 3);
var action = parts[0];
if (action == "join_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var joinSessionId))
{
var command = new JoinSessionCommand(
SessionId: joinSessionId,
TelegramUserId: query.From.Id,
DisplayName: query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
TelegramUsername: query.From.Username,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
await joinSessionHandler.HandleAsync(command, ct);
return;
}
if (action == "cancel_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var cancelSessionId))
{
var command = new CancelSessionCommand(
SessionId: cancelSessionId,
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
await cancelSessionHandler.HandleAsync(command, ct);
return;
}
if (action == "delete_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var deleteSessionId))
{
var command = new DeleteSessionCommand(
SessionId: deleteSessionId,
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
await deleteSessionHandler.HandleAsync(command, ct);
return;
}
if (action == "reschedule_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var rescheduleSessionId))
{
var command = new InitiateRescheduleCommand(
SessionId: rescheduleSessionId,
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
await initiateRescheduleHandler.HandleAsync(command, ct);
return;
}
if (action == "reschedule_vote" && parts.Length >= 3 && Guid.TryParse(parts[2], out var proposalId))
{
var vote = parts[1]; // "yes" or "no"
if (vote is not ("yes" or "no"))
return;
var command = new HandleRescheduleVoteCommand(
ProposalId: proposalId,
Vote: vote,
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
await rescheduleVoteHandler.HandleAsync(command, ct);
return;
}
if (action == "rsvp")
{
if (parts.Length < 3 || !Guid.TryParse(parts[2], out var sessionId))
return;
var status = parts[1] switch
{
"confirm" => Domain.RsvpStatus.Confirmed,
"decline" => Domain.RsvpStatus.Declined,
_ => (string?)null
};
if (status is null)
return;
var command = new HandleRsvpCommand(
SessionId: sessionId,
TelegramUserId: query.From.Id,
Status: status,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
await rsvpHandler.HandleAsync(command, ct);
}
}
private async Task HandleCommandAsync(Message message, string text, CancellationToken ct)
{
// Извлекаем команду: берём первую строку, первое слово, убираем @BotUsername
var firstLine = text.Split('\n')[0].Trim();
var command = firstLine.Split(' ')[0].Split('@')[0].ToLowerInvariant();
switch (command)
{
case "/start":
await bot.SendMessage(
chatId: message.Chat.Id,
text: "GM-Relay Bot ready. Use /help for commands.",
cancellationToken: ct);
break;
case "/newsession":
await createSessionHandler.HandleAsync(message, ct);
break;
case "/listsessions":
await listSessionsHandler.HandleAsync(message, ct);
break;
case "/exportcalendar":
await exportCalendarHandler.HandleAsync(message, ct);
break;
case "/help":
await bot.SendMessage(
chatId: message.Chat.Id,
text: """
GM-Relay бот для управления игровыми сессиями.
/newsession
Название: My Game
Время: 15.05.2026 19:30
Ссылка: https://link
/listsessions список предстоящих сессий
/help эта справка
""",
cancellationToken: ct);
break;
// TODO: /listsessions — will be implemented as features
default:
logger.LogDebug("Unknown command: {Command}", command);
break;
}
}
}