diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index b338564..25c159d 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.1.2 + VERSION: 1.1.3 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 34dac15..d2af466 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.1.2 + 1.1.3 net10.0 preview enable diff --git a/compose.yaml b/compose.yaml index f7d0471..39adb96 100644 --- a/compose.yaml +++ b/compose.yaml @@ -18,7 +18,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.2 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.3 container_name: gmrelay_bot restart: always network_mode: host @@ -30,7 +30,7 @@ services: - "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}" web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.2 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.3 container_name: gmrelay_web restart: always network_mode: host diff --git a/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs b/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs index 01b5b49..578eb39 100644 --- a/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs +++ b/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs @@ -1,14 +1,11 @@ using Dapper; using GmRelay.Shared.Domain; -using GmRelay.Bot.Features.Confirmation.SendConfirmation; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Bot.Features.Confirmation.HandleRsvp; -// ── Command ────────────────────────────────────────────────────────── - public sealed record HandleRsvpCommand( Guid SessionId, long TelegramUserId, @@ -17,13 +14,12 @@ public sealed record HandleRsvpCommand( long ChatId, int MessageId); -// ── DTOs ───────────────────────────────────────────────────────────── - internal sealed record RsvpCounts(int Total, int Confirmed, int Declined); internal sealed record SessionContext( string Title, DateTime ScheduledAt, + string Status, long GmTelegramId, long TelegramChatId); @@ -33,21 +29,6 @@ internal sealed record ParticipantRsvp( string? TelegramUsername, string RsvpStatus); -// ── Handler ────────────────────────────────────────────────────────── - -/// -/// Handles the "Буду" / "Не смогу" callback query. -/// -/// Flow: -/// 1. Validate that the user is a participant in this session -/// 2. Record or update their RSVP (idempotent) -/// 3. If declined → alert GM privately, revert session if was Confirmed -/// 4. If all non-GM players confirmed → mark session Confirmed, notify group + GM -/// 5. Update the inline keyboard to show current RSVP status -/// -/// Concurrency: two simultaneous clicks on different rows don't conflict (MVCC). -/// The last EditMessage wins, which is fine — both reflect up-to-date state. -/// public sealed class HandleRsvpHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, @@ -58,12 +39,11 @@ public sealed class HandleRsvpHandler( await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); - // ── 1. Validate participant ────────────────────────────────── - var participantExists = await connection.ExecuteScalarAsync( """ SELECT EXISTS ( - SELECT 1 FROM session_participants sp + SELECT 1 + FROM session_participants sp JOIN players p ON p.id = sp.player_id WHERE sp.session_id = @SessionId AND p.telegram_id = @TelegramUserId @@ -82,8 +62,6 @@ public sealed class HandleRsvpHandler( return; } - // ── 2. Record RSVP (idempotent) ───────────────────────────── - var updated = await connection.ExecuteAsync( """ UPDATE session_participants @@ -98,7 +76,6 @@ public sealed class HandleRsvpHandler( if (updated == 0) { - // Already in this state — just dismiss the loading spinner var alreadyText = command.Status == RsvpStatus.Confirmed ? "Вы уже подтвердили участие." : "Вы уже отказались от участия."; @@ -110,11 +87,11 @@ public sealed class HandleRsvpHandler( return; } - // ── 3. Load session context ───────────────────────────────── - var session = await connection.QuerySingleAsync( """ - SELECT s.title, s.scheduled_at AS ScheduledAt, + SELECT s.title, + s.scheduled_at AS ScheduledAt, + s.status AS Status, g.gm_telegram_id AS GmTelegramId, g.telegram_chat_id AS TelegramChatId FROM sessions s @@ -124,26 +101,27 @@ public sealed class HandleRsvpHandler( new { command.SessionId }, transaction); - // ── 4. Handle decline ─────────────────────────────────────── - if (command.Status == RsvpStatus.Declined) { - // Revert session to ConfirmationSent if it was Confirmed - await connection.ExecuteAsync( - """ - UPDATE sessions - SET status = @ConfirmationSent, updated_at = now() - WHERE id = @SessionId AND status = @Confirmed - """, - new - { - command.SessionId, - ConfirmationSent = SessionStatus.ConfirmationSent, - Confirmed = SessionStatus.Confirmed - }, - transaction); + var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0); + + if (decision.ShouldRevertSessionToConfirmationSent) + { + await connection.ExecuteAsync( + """ + UPDATE sessions + SET status = @ConfirmationSent, updated_at = now() + WHERE id = @SessionId AND status = @Confirmed + """, + new + { + command.SessionId, + ConfirmationSent = SessionStatus.ConfirmationSent, + Confirmed = SessionStatus.Confirmed + }, + transaction); + } - // Alert GM immediately via private message var declinedPlayer = await connection.QuerySingleAsync( "SELECT display_name FROM players WHERE telegram_id = @TelegramUserId", new { command.TelegramUserId }, @@ -151,7 +129,6 @@ public sealed class HandleRsvpHandler( await transaction.CommitAsync(ct); - // Send alert outside transaction (network call) try { await bot.SendMessage( @@ -161,24 +138,22 @@ public sealed class HandleRsvpHandler( } catch (Exception ex) { - logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", - command.SessionId); + logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", command.SessionId); } await bot.AnswerCallbackQuery( callbackQueryId: command.CallbackQueryId, - text: "Вы отказались от участия.", + text: decision.CallbackText, cancellationToken: ct); } - // ── 5. Handle confirm — check if ALL confirmed ────────────── else { var counts = await connection.QuerySingleAsync( """ SELECT - count(*) AS Total, - count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed, - count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined + count(*) AS Total, + count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed, + count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined FROM session_participants WHERE session_id = @SessionId AND is_gm = false """, @@ -190,9 +165,9 @@ public sealed class HandleRsvpHandler( }, transaction); - var allConfirmed = counts.Confirmed == counts.Total; + var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed); - if (allConfirmed) + if (decision.ShouldMarkSessionConfirmed) { await connection.ExecuteAsync( """ @@ -206,9 +181,8 @@ public sealed class HandleRsvpHandler( await transaction.CommitAsync(ct); - if (allConfirmed) + if (decision.ShouldNotifyGroup) { - // Notify group try { await bot.SendMessage( @@ -218,11 +192,12 @@ public sealed class HandleRsvpHandler( } catch (Exception ex) { - logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", - command.SessionId); + logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", command.SessionId); } + } - // Notify GM privately + if (decision.ShouldNotifyGm) + { try { await bot.SendMessage( @@ -232,27 +207,20 @@ public sealed class HandleRsvpHandler( } catch (Exception ex) { - logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", - command.SessionId); + logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", command.SessionId); } } await bot.AnswerCallbackQuery( callbackQueryId: command.CallbackQueryId, - text: "Вы подтвердили участие!", + text: decision.CallbackText, cancellationToken: ct); } - // ── 6. Update inline keyboard message ─────────────────────── - await UpdateConfirmationMessage(command, session, ct); } - /// - /// Re-renders the confirmation message with current RSVP statuses. - /// - private async Task UpdateConfirmationMessage( - HandleRsvpCommand command, SessionContext session, CancellationToken ct) + private async Task UpdateConfirmationMessage(HandleRsvpCommand command, SessionContext session, CancellationToken ct) { try { @@ -260,10 +228,10 @@ public sealed class HandleRsvpHandler( var participants = (await connection.QueryAsync( """ - SELECT p.telegram_id AS TelegramId, - p.display_name AS DisplayName, + SELECT p.telegram_id AS TelegramId, + p.display_name AS DisplayName, p.telegram_username AS TelegramUsername, - sp.rsvp_status AS RsvpStatus + sp.rsvp_status AS RsvpStatus FROM session_participants sp JOIN players p ON p.id = sp.player_id WHERE sp.session_id = @SessionId AND sp.is_gm = false @@ -279,34 +247,47 @@ public sealed class HandleRsvpHandler( { $"🎲 Подтвердите участие в «{session.Title}»", $"📅 {session.ScheduledAt.FormatMoscow()} (МСК)", - "" + string.Empty }; - foreach (var p in confirmed) - lines.Add($" ✅ {FormatName(p)}"); - foreach (var p in declined) - lines.Add($" ❌ ~~{FormatName(p)}~~"); - foreach (var p in pending) - lines.Add($" ⏳ {FormatName(p)}"); + foreach (var participant in confirmed) + { + lines.Add($" ✅ {FormatName(participant)}"); + } - lines.Add(""); + foreach (var participant in declined) + { + lines.Add($" ❌ ~~{FormatName(participant)}~~"); + } + + foreach (var participant in pending) + { + lines.Add($" ⏳ {FormatName(participant)}"); + } + + lines.Add(string.Empty); if (confirmed.Count == participants.Count) + { lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})"); + } else if (declined.Count > 0) + { lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)"); + } else + { lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})"); + } var text = string.Join("\n", lines); - // Keep buttons unless everyone confirmed var replyMarkup = confirmed.Count == participants.Count ? null : new InlineKeyboardMarkup([ [ - InlineKeyboardButton.WithCallbackData("\u2705 Буду", $"rsvp:confirm:{command.SessionId}"), - InlineKeyboardButton.WithCallbackData("\u274c Не смогу", $"rsvp:decline:{command.SessionId}") + InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{command.SessionId}"), + InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{command.SessionId}") ] ]); @@ -319,12 +300,10 @@ public sealed class HandleRsvpHandler( } catch (Exception ex) { - // EditMessage can fail if message is too old or unchanged — non-critical - logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", - command.SessionId); + logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId); } } - private static string FormatName(ParticipantRsvp p) => - p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName; + private static string FormatName(ParticipantRsvp participant) => + participant.TelegramUsername is not null ? $"@{participant.TelegramUsername}" : participant.DisplayName; } diff --git a/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/RsvpFlowRules.cs b/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/RsvpFlowRules.cs new file mode 100644 index 0000000..309f5ca --- /dev/null +++ b/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/RsvpFlowRules.cs @@ -0,0 +1,42 @@ +using GmRelay.Shared.Domain; + +namespace GmRelay.Bot.Features.Confirmation.HandleRsvp; + +internal sealed record RsvpFlowDecision( + string CallbackText, + bool ShouldAlertGm, + bool ShouldRevertSessionToConfirmationSent, + bool ShouldMarkSessionConfirmed, + bool ShouldNotifyGroup, + bool ShouldNotifyGm); + +internal static class RsvpFlowRules +{ + public static RsvpFlowDecision Evaluate( + string requestedStatus, + string currentSessionStatus, + int totalParticipants, + int confirmedParticipants) + { + if (requestedStatus == RsvpStatus.Declined) + { + return new RsvpFlowDecision( + CallbackText: "\u0412\u044b \u043e\u0442\u043a\u0430\u0437\u0430\u043b\u0438\u0441\u044c \u043e\u0442 \u0443\u0447\u0430\u0441\u0442\u0438\u044f.", + ShouldAlertGm: true, + ShouldRevertSessionToConfirmationSent: currentSessionStatus == SessionStatus.Confirmed, + ShouldMarkSessionConfirmed: false, + ShouldNotifyGroup: false, + ShouldNotifyGm: false); + } + + var everyoneConfirmed = confirmedParticipants == totalParticipants; + + return new RsvpFlowDecision( + CallbackText: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u0443\u0447\u0430\u0441\u0442\u0438\u0435!", + ShouldAlertGm: false, + ShouldRevertSessionToConfirmationSent: false, + ShouldMarkSessionConfirmed: everyoneConfirmed, + ShouldNotifyGroup: everyoneConfirmed, + ShouldNotifyGm: everyoneConfirmed); + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index bde9f92..17dda09 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -1,11 +1,8 @@ -using System.Text.RegularExpressions; using Dapper; -using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types; -using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Bot.Features.Sessions.CreateSession; @@ -16,37 +13,25 @@ public sealed class CreateSessionHandler( { public async Task HandleAsync(Message message, CancellationToken cancellationToken) { - var text = message.Text ?? ""; - - string? title = null; - string? link = null; - var scheduledTimes = new List(); + var parseResult = NewSessionCommandParser.Parse(message.Text, DateTimeOffset.UtcNow); - foreach (var line in text.Split('\n')) + foreach (var timeInput in parseResult.PastTimeInputs) { - var trimmed = line.Trim(); - if (trimmed.StartsWith("Название:", StringComparison.OrdinalIgnoreCase)) - title = trimmed["Название:".Length..].Trim(); - else if (trimmed.StartsWith("Ссылка:", StringComparison.OrdinalIgnoreCase)) - link = trimmed["Ссылка:".Length..].Trim(); - else if (trimmed.StartsWith("Время:", StringComparison.OrdinalIgnoreCase)) - { - var timeStr = trimmed["Время:".Length..].Trim(); - if (MoscowTime.TryParseMoscow(timeStr, out var scheduledAt)) - { - if (scheduledAt > DateTimeOffset.UtcNow) - scheduledTimes.Add(scheduledAt); - else - await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Дата {timeStr} находится в прошлом и будет пропущена.", cancellationToken: cancellationToken); - } - else - { - await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Некорректный формат времени '{timeStr}'. Пропущено.", cancellationToken: cancellationToken); - } - } + await botClient.SendMessage( + message.Chat.Id, + $"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.", + cancellationToken: cancellationToken); } - if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(link) || scheduledTimes.Count == 0) + foreach (var timeInput in parseResult.InvalidTimeInputs) + { + await botClient.SendMessage( + message.Chat.Id, + $"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.", + cancellationToken: cancellationToken); + } + + if (!parseResult.IsValid) { await botClient.SendMessage( chatId: message.Chat.Id, @@ -55,10 +40,12 @@ public sealed class CreateSessionHandler( return; } + var title = parseResult.Title!; + var link = parseResult.Link!; var gmId = message.From!.Id; - var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"); + var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}"); var gmUsername = message.From.Username; - + var chatId = message.Chat.Id; var chatTitle = message.Chat.Title ?? "Private Chat"; @@ -67,20 +54,24 @@ public sealed class CreateSessionHandler( try { - // 1. Убеждаемся, что GM зарегистрирован await connection.ExecuteAsync( - @"INSERT INTO players (telegram_id, display_name, telegram_username) - VALUES (@TgId, @Name, @Username) - ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = EXCLUDED.telegram_username;", + """ + INSERT INTO players (telegram_id, display_name, telegram_username) + VALUES (@TgId, @Name, @Username) + ON CONFLICT (telegram_id) DO UPDATE + SET display_name = EXCLUDED.display_name, + telegram_username = EXCLUDED.telegram_username; + """, new { TgId = gmId, Name = gmName, Username = gmUsername }, transaction); - // 2. Убеждаемся, что Группа зарегистрирована var groupId = await connection.ExecuteScalarAsync( - @"INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id) - VALUES (@ChatId, @ChatName, @GmId) - ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name - RETURNING id;", + """ + INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id) + VALUES (@ChatId, @ChatName, @GmId) + ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name + RETURNING id; + """, new { ChatId = chatId, ChatName = chatTitle, GmId = gmId }, transaction); @@ -94,29 +85,36 @@ public sealed class CreateSessionHandler( messageThreadId = topic.MessageThreadId; } - // 3. Создаем сессии в цикле с общим batch_id var batchId = Guid.NewGuid(); var sessions = new List(); - foreach (var dt in scheduledTimes.OrderBy(d => d)) + foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value)) { var sessionId = await connection.ExecuteScalarAsync( - @"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id) - VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, 'Planned', @ThreadId) - RETURNING id;", - new { BatchId = batchId, GroupId = groupId, Title = title, Link = link, ScheduledAt = dt, ThreadId = messageThreadId }, + """ + INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id) + VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, 'Planned', @ThreadId) + RETURNING id; + """, + new + { + BatchId = batchId, + GroupId = groupId, + Title = title, + Link = link, + ScheduledAt = scheduledAt, + ThreadId = messageThreadId + }, transaction); - sessions.Add(new SessionBatchDto(sessionId, dt.UtcDateTime, "Planned")); + sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, "Planned")); } await transaction.CommitAsync(cancellationToken); logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId); - // 4. Отправляем сообщение в чат var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty()); - var batchMessage = await botClient.SendMessage( chatId: chatId, messageThreadId: messageThreadId, @@ -125,12 +123,10 @@ public sealed class CreateSessionHandler( replyMarkup: renderResult.Markup, cancellationToken: cancellationToken); - // 4b. Сохраняем message_id батч-сообщения для дальнейшего редактирования await connection.ExecuteAsync( "UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId", new { MsgId = batchMessage.MessageId, BatchId = batchId }); - // 5. Удаляем исходное сообщение с командой /newsession, чтобы не спамить try { await botClient.DeleteMessage( diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs new file mode 100644 index 0000000..8bb593f --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/NewSessionCommandParser.cs @@ -0,0 +1,69 @@ +using GmRelay.Shared.Domain; + +namespace GmRelay.Bot.Features.Sessions.CreateSession; + +internal sealed record NewSessionParseResult( + string? Title, + string? Link, + IReadOnlyList ScheduledTimes, + IReadOnlyList PastTimeInputs, + IReadOnlyList InvalidTimeInputs) +{ + public bool IsValid => + !string.IsNullOrWhiteSpace(Title) && + !string.IsNullOrWhiteSpace(Link) && + ScheduledTimes.Count > 0; +} + +internal static class NewSessionCommandParser +{ + private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:"; + private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:"; + private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:"; + + public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc) + { + string? title = null; + string? link = null; + var scheduledTimes = new List(); + var pastTimeInputs = new List(); + var invalidTimeInputs = new List(); + + foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries)) + { + if (line.StartsWith(TitlePrefix, StringComparison.OrdinalIgnoreCase)) + { + title = line[TitlePrefix.Length..].Trim(); + continue; + } + + if (line.StartsWith(LinkPrefix, StringComparison.OrdinalIgnoreCase)) + { + link = line[LinkPrefix.Length..].Trim(); + continue; + } + + if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var timeInput = line[TimePrefix.Length..].Trim(); + if (!MoscowTime.TryParseMoscow(timeInput, out var scheduledAt)) + { + invalidTimeInputs.Add(timeInput); + continue; + } + + if (scheduledAt <= nowUtc) + { + pastTimeInputs.Add(timeInput); + continue; + } + + scheduledTimes.Add(scheduledAt); + } + + return new NewSessionParseResult(title, link, scheduledTimes, pastTimeInputs, invalidTimeInputs); + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs index d18084f..06429f6 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs @@ -1,25 +1,20 @@ using Dapper; using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; -using GmRelay.Bot.Features.Sessions.CreateSession; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Bot.Features.Sessions.RescheduleSession; -// ── Command ────────────────────────────────────────────────────────── - public sealed record HandleRescheduleVoteCommand( Guid ProposalId, - string Vote, // "yes" or "no" + string Vote, long TelegramUserId, string CallbackQueryId, long ChatId, int MessageId); -// ── DTOs ───────────────────────────────────────────────────────────── - internal sealed record VoteProposalDto( Guid Id, Guid SessionId, @@ -32,17 +27,6 @@ internal sealed record VoteProposalDto( int? ConfirmationMessageId, int? BatchMessageId); -internal sealed record VoteCountDto(int Total, int Approved); - -// ── Handler ────────────────────────────────────────────────────────── - -/// -/// Handles "✅ Согласен" / "❌ Против" votes on a reschedule proposal. -/// -/// If anyone votes no → proposal rejected, old time stays. -/// If all vote yes → session time updated, batch message re-rendered, -/// session status reset to Planned so confirmation triggers work correctly. -/// public sealed class HandleRescheduleVoteHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, @@ -53,12 +37,15 @@ public sealed class HandleRescheduleVoteHandler( await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); - // 1. Load proposal + session info var proposal = await connection.QuerySingleOrDefaultAsync( """ - SELECT rp.id AS Id, rp.session_id AS SessionId, rp.proposed_at AS ProposedAt, - s.title AS Title, s.scheduled_at AS CurrentScheduledAt, - s.batch_id AS BatchId, s.status AS SessionStatus, + SELECT rp.id AS Id, + rp.session_id AS SessionId, + rp.proposed_at AS ProposedAt, + s.title AS Title, + s.scheduled_at AS CurrentScheduledAt, + s.batch_id AS BatchId, + s.status AS SessionStatus, s.confirmation_message_id AS ConfirmationMessageId, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId @@ -72,12 +59,13 @@ public sealed class HandleRescheduleVoteHandler( if (proposal is null) { - await bot.AnswerCallbackQuery(command.CallbackQueryId, - "Голосование уже завершено или не найдено.", cancellationToken: ct); + await bot.AnswerCallbackQuery( + command.CallbackQueryId, + "Голосование уже завершено или не найдено.", + cancellationToken: ct); return; } - // 2. Verify voter is a participant of this session var playerId = await connection.ExecuteScalarAsync( """ SELECT p.id @@ -92,23 +80,52 @@ public sealed class HandleRescheduleVoteHandler( if (playerId is null) { - await bot.AnswerCallbackQuery(command.CallbackQueryId, - "Вы не являетесь участником этой сессии.", cancellationToken: ct); + await bot.AnswerCallbackQuery( + command.CallbackQueryId, + "Вы не являетесь участником этой сессии.", + cancellationToken: ct); return; } - // 3. Record vote (upsert) - var inserted = await connection.ExecuteAsync( + await connection.ExecuteAsync( """ INSERT INTO reschedule_votes (proposal_id, player_id, vote) VALUES (@ProposalId, @PlayerId, @Vote) - ON CONFLICT (proposal_id, player_id) DO UPDATE SET vote = EXCLUDED.vote, voted_at = now() + ON CONFLICT (proposal_id, player_id) DO UPDATE + SET vote = EXCLUDED.vote, + voted_at = now() """, new { command.ProposalId, PlayerId = playerId.Value, command.Vote }, transaction); - // 4. Handle "no" vote — immediately reject - if (command.Vote == "no") + var participants = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase) + ? new List() + : (await connection.QueryAsync( + """ + SELECT p.id AS PlayerId, + p.display_name AS DisplayName, + p.telegram_username AS TelegramUsername + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId AND sp.is_gm = false + """, + new { proposal.SessionId }, + transaction)).ToList(); + + var approvedPlayerIds = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase) + ? new HashSet() + : (await connection.QueryAsync( + """ + SELECT player_id + FROM reschedule_votes + WHERE proposal_id = @ProposalId AND vote = 'yes' + """, + new { command.ProposalId }, + transaction)).ToHashSet(); + + var decision = RescheduleVoteRules.Evaluate(command.Vote, participants.Count, approvedPlayerIds.Count); + + if (decision.Outcome == RescheduleVoteOutcome.Rejected) { await connection.ExecuteAsync( "UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id", @@ -117,12 +134,10 @@ public sealed class HandleRescheduleVoteHandler( await transaction.CommitAsync(ct); - // Get voter's name var voterName = await connection.QuerySingleOrDefaultAsync( - "SELECT display_name FROM players WHERE telegram_id = @TgId", - new { TgId = command.TelegramUserId }); + "SELECT display_name FROM players WHERE telegram_id = @TelegramUserId", + new { command.TelegramUserId }); - // Update voting message — show rejection try { await bot.EditMessageText( @@ -137,38 +152,15 @@ public sealed class HandleRescheduleVoteHandler( logger.LogWarning(ex, "Failed to update vote message after rejection"); } - await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы проголосовали против переноса.", cancellationToken: ct); + await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct); logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId); return; } - // 5. Handle "yes" vote — check if all approved - var participants = (await connection.QueryAsync( - """ - SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId AND sp.is_gm = false - """, - new { proposal.SessionId }, - transaction)).ToList(); - - var approvedPlayerIds = (await connection.QueryAsync( - """ - SELECT player_id FROM reschedule_votes - WHERE proposal_id = @ProposalId AND vote = 'yes' - """, - new { command.ProposalId }, - transaction)).ToHashSet(); - - var allApproved = approvedPlayerIds.Count == participants.Count; - - if (allApproved) + if (decision.ShouldRescheduleSession) { - // 6. All approved — reschedule! - var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero); // ProposedAt is stored in UTC + var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero); - // Update session time and reset status to Planned for fresh notification cycle await connection.ExecuteAsync( """ UPDATE sessions @@ -187,19 +179,21 @@ public sealed class HandleRescheduleVoteHandler( new { Id = command.ProposalId }, transaction); - // Reset all participant RSVP to Pending for the new confirmation cycle - await connection.ExecuteAsync( - """ - UPDATE session_participants - SET rsvp_status = 'Pending', responded_at = NULL - WHERE session_id = @SessionId AND is_gm = false - """, - new { proposal.SessionId }, - transaction); + if (decision.ShouldResetParticipantRsvps) + { + await connection.ExecuteAsync( + """ + UPDATE session_participants + SET rsvp_status = 'Pending', + responded_at = NULL + WHERE session_id = @SessionId AND is_gm = false + """, + new { proposal.SessionId }, + transaction); + } await transaction.CommitAsync(ct); - // Update voting message — show approval try { await bot.EditMessageText( @@ -214,21 +208,24 @@ public sealed class HandleRescheduleVoteHandler( logger.LogWarning(ex, "Failed to update vote message after approval"); } - // Re-render batch message await TryUpdateBatchMessage(proposal, ct); - logger.LogInformation("Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})", - proposal.SessionId, newTime, command.ProposalId); + logger.LogInformation( + "Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})", + proposal.SessionId, + newTime, + command.ProposalId); } else { - // Not all voted yet — update the voting message to show progress await transaction.CommitAsync(ct); var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage( - proposal.Title, proposal.CurrentScheduledAt, + proposal.Title, + proposal.CurrentScheduledAt, new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero), - participants, approvedPlayerIds); + participants, + approvedPlayerIds); var keyboard = new InlineKeyboardMarkup([ [ @@ -253,15 +250,9 @@ public sealed class HandleRescheduleVoteHandler( } } - await bot.AnswerCallbackQuery(command.CallbackQueryId, - allApproved ? "Вы подтвердили перенос! Все согласны — время обновлено." : "Вы подтвердили перенос!", - cancellationToken: ct); + await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct); } - /// - /// Re-renders the batch schedule message to reflect the updated session time. - /// If batch_message_id is stored, edits the original message. Otherwise sends a notification. - /// private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct) { try @@ -274,7 +265,9 @@ public sealed class HandleRescheduleVoteHandler( var batchParticipants = (await connection.QueryAsync( """ - SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername + SELECT sp.session_id AS SessionId, + p.display_name AS DisplayName, + p.telegram_username AS TelegramUsername FROM session_participants sp JOIN players p ON sp.player_id = p.id JOIN sessions s ON sp.session_id = s.id @@ -285,7 +278,6 @@ public sealed class HandleRescheduleVoteHandler( if (proposal.BatchMessageId.HasValue) { - // Edit the original batch schedule message in-place var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants); await bot.EditMessageText( @@ -298,10 +290,9 @@ public sealed class HandleRescheduleVoteHandler( } else { - // Fallback for sessions created before V005 migration (no batch_message_id) await bot.SendMessage( chatId: proposal.TelegramChatId, - text: $"📢 Расписание обновлено! Сессия «{proposal.Title}» перенесена на {proposal.ProposedAt.FormatMoscow()} (МСК).", + text: $"📣 Расписание обновлено! Сессия «{proposal.Title}» перенесена на {proposal.ProposedAt.FormatMoscow()} (МСК).", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct); } diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs new file mode 100644 index 0000000..00365c9 --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs @@ -0,0 +1,39 @@ +namespace GmRelay.Bot.Features.Sessions.RescheduleSession; + +internal enum RescheduleVoteOutcome +{ + Pending, + Rejected, + Approved +} + +internal sealed record RescheduleVoteDecision( + RescheduleVoteOutcome Outcome, + string CallbackText, + bool ShouldRescheduleSession, + bool ShouldResetParticipantRsvps); + +internal static class RescheduleVoteRules +{ + public static RescheduleVoteDecision Evaluate(string vote, int totalParticipants, int approvedParticipants) + { + if (string.Equals(vote, "no", StringComparison.OrdinalIgnoreCase)) + { + return new RescheduleVoteDecision( + Outcome: RescheduleVoteOutcome.Rejected, + CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.", + ShouldRescheduleSession: false, + ShouldResetParticipantRsvps: false); + } + + var everyoneApproved = approvedParticipants == totalParticipants; + + return new RescheduleVoteDecision( + Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending, + CallbackText: everyoneApproved + ? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e." + : "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!", + ShouldRescheduleSession: everyoneApproved, + ShouldResetParticipantRsvps: everyoneApproved); + } +} diff --git a/src/GmRelay.Bot/Properties/AssemblyInfo.cs b/src/GmRelay.Bot/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..6620bd8 --- /dev/null +++ b/src/GmRelay.Bot/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")] diff --git a/tests/GmRelay.Bot.Tests/Features/Confirmation/HandleRsvp/RsvpFlowRulesTests.cs b/tests/GmRelay.Bot.Tests/Features/Confirmation/HandleRsvp/RsvpFlowRulesTests.cs new file mode 100644 index 0000000..ed64702 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Confirmation/HandleRsvp/RsvpFlowRulesTests.cs @@ -0,0 +1,50 @@ +using GmRelay.Bot.Features.Confirmation.HandleRsvp; +using GmRelay.Shared.Domain; + +namespace GmRelay.Bot.Tests.Features.Confirmation.HandleRsvp; + +public sealed class RsvpFlowRulesTests +{ + [Fact] + public void Evaluate_ShouldRevertAndAlert_WhenConfirmedSessionGetsDecline() + { + var decision = RsvpFlowRules.Evaluate( + RsvpStatus.Declined, + SessionStatus.Confirmed, + totalParticipants: 3, + confirmedParticipants: 2); + + Assert.True(decision.ShouldAlertGm); + Assert.True(decision.ShouldRevertSessionToConfirmationSent); + Assert.False(decision.ShouldMarkSessionConfirmed); + Assert.Equal("Вы отказались от участия.", decision.CallbackText); + } + + [Fact] + public void Evaluate_ShouldMarkConfirmed_WhenLastParticipantConfirms() + { + var decision = RsvpFlowRules.Evaluate( + RsvpStatus.Confirmed, + SessionStatus.ConfirmationSent, + totalParticipants: 3, + confirmedParticipants: 3); + + Assert.True(decision.ShouldMarkSessionConfirmed); + Assert.True(decision.ShouldNotifyGroup); + Assert.True(decision.ShouldNotifyGm); + } + + [Fact] + public void Evaluate_ShouldKeepWaiting_WhenNotEveryoneConfirmed() + { + var decision = RsvpFlowRules.Evaluate( + RsvpStatus.Confirmed, + SessionStatus.ConfirmationSent, + totalParticipants: 4, + confirmedParticipants: 2); + + Assert.False(decision.ShouldMarkSessionConfirmed); + Assert.False(decision.ShouldNotifyGroup); + Assert.False(decision.ShouldNotifyGm); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs new file mode 100644 index 0000000..41fb803 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/NewSessionCommandParserTests.cs @@ -0,0 +1,68 @@ +using GmRelay.Bot.Features.Sessions.CreateSession; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession; + +public sealed class NewSessionCommandParserTests +{ + [Fact] + public void Parse_ShouldExtractTitleLinkAndUpcomingTimes() + { + var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero); + var text = """ + /newsession + Название: Curse of Strahd + Время: 24.04.2026 19:30 + Время: 01.05.2026 20:00 + Ссылка: https://example.test/room + """; + + var result = NewSessionCommandParser.Parse(text, nowUtc); + + Assert.True(result.IsValid); + Assert.Equal("Curse of Strahd", result.Title); + Assert.Equal("https://example.test/room", result.Link); + Assert.Equal( + [ + new DateTimeOffset(2026, 4, 24, 16, 30, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 5, 1, 17, 0, 0, TimeSpan.Zero) + ], + result.ScheduledTimes); + Assert.Empty(result.PastTimeInputs); + Assert.Empty(result.InvalidTimeInputs); + } + + [Fact] + public void Parse_ShouldCollectPastAndInvalidTimes() + { + var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero); + var text = """ + Название: Delta Green + Время: 20.04.2026 19:30 + Время: 31.04.2026 19:30 + Время: 25.04.2026 18:00 + Ссылка: https://example.test/dg + """; + + var result = NewSessionCommandParser.Parse(text, nowUtc); + + Assert.True(result.IsValid); + Assert.Single(result.ScheduledTimes); + Assert.Equal(["20.04.2026 19:30"], result.PastTimeInputs); + Assert.Equal(["31.04.2026 19:30"], result.InvalidTimeInputs); + } + + [Fact] + public void Parse_ShouldBeInvalid_WhenRequiredFieldsMissing() + { + var text = """ + /newsession + Название: Blades in the Dark + Время: 25.04.2026 19:30 + """; + + var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow); + + Assert.False(result.IsValid); + Assert.Null(result.Link); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandlerTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandlerTests.cs new file mode 100644 index 0000000..c8985ac --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandlerTests.cs @@ -0,0 +1,32 @@ +using GmRelay.Bot.Features.Sessions.RescheduleSession; + +namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession; + +public sealed class HandleRescheduleTimeInputHandlerTests +{ + [Fact] + public void BuildVotingMessage_ShouldShowApprovedAndPendingParticipants() + { + var approvedId = Guid.NewGuid(); + var pendingId = Guid.NewGuid(); + var currentTime = new DateTime(2026, 4, 25, 16, 30, 0, DateTimeKind.Utc); + var newTime = new DateTimeOffset(2026, 4, 26, 17, 0, 0, TimeSpan.Zero); + var participants = new List + { + new(approvedId, "Alice", "alice"), + new(pendingId, "Bob", null) + }; + + var text = HandleRescheduleTimeInputHandler.BuildVotingMessage( + "Shadowrun", + currentTime, + newTime, + participants, + [approvedId]); + + Assert.Contains("Shadowrun", text); + Assert.Contains("✅ @alice", text); + Assert.Contains("⏳ Bob", text); + Assert.Contains("Голоса: 1/2 ✅", text); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/RescheduleVoteRulesTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/RescheduleVoteRulesTests.cs new file mode 100644 index 0000000..2ecbaf2 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/RescheduleVoteRulesTests.cs @@ -0,0 +1,36 @@ +using GmRelay.Bot.Features.Sessions.RescheduleSession; + +namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession; + +public sealed class RescheduleVoteRulesTests +{ + [Fact] + public void Evaluate_ShouldReject_WhenParticipantVotesNo() + { + var decision = RescheduleVoteRules.Evaluate("no", totalParticipants: 4, approvedParticipants: 3); + + Assert.Equal(RescheduleVoteOutcome.Rejected, decision.Outcome); + Assert.False(decision.ShouldRescheduleSession); + Assert.Equal("Вы проголосовали против переноса.", decision.CallbackText); + } + + [Fact] + public void Evaluate_ShouldApprove_WhenEveryoneVotedYes() + { + var decision = RescheduleVoteRules.Evaluate("yes", totalParticipants: 3, approvedParticipants: 3); + + Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome); + Assert.True(decision.ShouldRescheduleSession); + Assert.True(decision.ShouldResetParticipantRsvps); + } + + [Fact] + public void Evaluate_ShouldStayPending_WhileVotesOutstanding() + { + var decision = RescheduleVoteRules.Evaluate("yes", totalParticipants: 5, approvedParticipants: 2); + + Assert.Equal(RescheduleVoteOutcome.Pending, decision.Outcome); + Assert.False(decision.ShouldRescheduleSession); + Assert.False(decision.ShouldResetParticipantRsvps); + } +} diff --git a/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs new file mode 100644 index 0000000..70bd87a --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Rendering/SessionBatchRendererTests.cs @@ -0,0 +1,47 @@ +using GmRelay.Shared.Domain; +using GmRelay.Shared.Rendering; + +namespace GmRelay.Bot.Tests.Rendering; + +public sealed class SessionBatchRendererTests +{ + [Fact] + public void Render_ShouldOrderSessionsAndSkipButtonsForClosedStatuses() + { + var firstSessionId = Guid.NewGuid(); + var secondSessionId = Guid.NewGuid(); + var cancelledSessionId = Guid.NewGuid(); + + var sessions = new[] + { + new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), "Planned"), + new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), "Cancelled"), + new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), "RecruitmentClosed") + }; + var participants = new[] + { + new ParticipantBatchDto(secondSessionId, "Alice", "alice"), + new ParticipantBatchDto(cancelledSessionId, "Bob", null) + }; + + var result = SessionBatchRenderer.Render("Campaign", sessions, participants); + var text = result.Text; + var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList(); + + var firstIndex = text.IndexOf(sessions[2].ScheduledAt.FormatMoscow(), StringComparison.Ordinal); + var secondIndex = text.IndexOf(sessions[0].ScheduledAt.FormatMoscow(), StringComparison.Ordinal); + var thirdIndex = text.IndexOf(sessions[1].ScheduledAt.FormatMoscow(), StringComparison.Ordinal); + + Assert.Contains("Campaign", text); + Assert.True(firstIndex < secondIndex); + Assert.True(secondIndex < thirdIndex); + Assert.Contains("@alice", text); + Assert.Contains("Bob", text); + Assert.Single(result.Markup.InlineKeyboard); + Assert.Collection( + buttons.Select(button => button.CallbackData), + callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData), + callbackData => Assert.Equal($"cancel_session:{secondSessionId}", callbackData), + callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData)); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs index 91ea605..5fc9a79 100644 --- a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs @@ -66,6 +66,27 @@ public sealed class AuthorizedSessionServiceTests Assert.Equal(sessionId, session.Id); } + [Fact] + public async Task GetSessionForGmAsync_ReturnsNull_WhenSessionBelongsToAnotherGm() + { + var groupId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", 2002L) + ], + sessions: + [ + new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42) + ]); + var service = new AuthorizedSessionService(store); + + var session = await service.GetSessionForGmAsync(sessionId, 1001L); + + Assert.Null(session); + } + [Fact] public async Task UpdateSessionForGmAsync_Throws_WhenSessionBelongsToAnotherGm() { diff --git a/tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs new file mode 100644 index 0000000..f5fba30 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs @@ -0,0 +1,109 @@ +using System.Security.Cryptography; +using System.Text; +using GmRelay.Web.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; + +namespace GmRelay.Bot.Tests.Web; + +public sealed class TelegramAuthServiceTests +{ + [Fact] + public void Verify_ShouldAcceptValidTelegramPayload() + { + const string botToken = "test-bot-token"; + var authDate = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var query = CreateQueryCollection( + botToken, + new Dictionary + { + ["auth_date"] = authDate, + ["first_name"] = "Ada", + ["id"] = "424242", + ["last_name"] = "Lovelace", + ["username"] = "ada" + }); + var service = new TelegramAuthService(CreateConfiguration(botToken)); + + var verified = service.Verify(query, out var telegramId, out var name); + + Assert.True(verified); + Assert.Equal(424242L, telegramId); + Assert.Equal("Ada Lovelace", name); + } + + [Fact] + public void Verify_ShouldRejectTamperedHash() + { + const string botToken = "test-bot-token"; + var values = new Dictionary + { + ["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), + ["first_name"] = "Ada", + ["id"] = "424242" + }; + var query = CreateQueryCollection(botToken, values); + var invalidQuery = new QueryCollection(new Dictionary(query.ToDictionary( + pair => pair.Key, + pair => pair.Value)) + { + ["hash"] = "00" + }); + var service = new TelegramAuthService(CreateConfiguration(botToken)); + + var verified = service.Verify(invalidQuery, out _, out _); + + Assert.False(verified); + } + + [Fact] + public void Verify_ShouldRejectExpiredPayload() + { + const string botToken = "test-bot-token"; + var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds().ToString(); + var query = CreateQueryCollection( + botToken, + new Dictionary + { + ["auth_date"] = expiredAuthDate, + ["first_name"] = "Ada", + ["id"] = "424242" + }); + var service = new TelegramAuthService(CreateConfiguration(botToken)); + + var verified = service.Verify(query, out _, out _); + + Assert.False(verified); + } + + private static IConfiguration CreateConfiguration(string botToken) => + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Telegram:BotToken"] = botToken + }) + .Build(); + + private static QueryCollection CreateQueryCollection(string botToken, Dictionary values) + { + var hash = ComputeTelegramHash(botToken, values); + var queryValues = values.ToDictionary( + pair => pair.Key, + pair => new StringValues(pair.Value)); + queryValues["hash"] = new StringValues(hash); + return new QueryCollection(queryValues); + } + + private static string ComputeTelegramHash(string botToken, IReadOnlyDictionary values) + { + var dataCheckString = string.Join( + "\n", + values + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => $"{pair.Key}={pair.Value}")); + var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(botToken)); + var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString)); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } +}