From 383e2c1d8d13f88c2d9b44681d477c220b4ed300 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 27 May 2026 13:50:18 +0300 Subject: [PATCH 1/2] fix: create Telegram topics for template batches Create a Telegram forum topic when Web creates a batch from a campaign template, persist thread ownership on the generated sessions, and send the batch schedule into that topic. Bump version -> 3.1.1 --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- compose.yaml | 6 +-- .../Components/Layout/NavMenu.razor | 2 +- src/GmRelay.Web/Services/SessionService.cs | 41 ++++++++++++++++++- .../Discord/DiscordProjectStructureTests.cs | 14 +++---- .../TelegramTopicIntegrationSmokeTests.cs | 13 ++++++ 7 files changed, 65 insertions(+), 15 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 400f1ed..e9b190e 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 3.1.0 + VERSION: 3.1.1 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index b947ac1..e8f515c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 3.1.0 + 3.1.1 net10.0 preview enable diff --git a/compose.yaml b/compose.yaml index a69ee9e..8c87b78 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:3.1.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:3.1.1 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.1.0 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.1.1 restart: always depends_on: db: @@ -84,7 +84,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:3.1.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:3.1.1 restart: always depends_on: db: diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index d93b928..1e0eed4 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -73,7 +73,7 @@ - + diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 68eb9b8..73d3224 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -3,6 +3,7 @@ using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using Npgsql; using Telegram.Bot; +using Telegram.Bot.Exceptions; using GmRelay.Web.Services; namespace GmRelay.Web.Services; @@ -95,6 +96,7 @@ internal sealed record WebBatchSessionRow( string NotificationMode, bool TopicCreatedByBot = false); internal sealed record WebTemplateGroupDto(long TelegramChatId); +internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot); public sealed class SessionService( NpgsqlDataSource dataSource, @@ -1186,6 +1188,10 @@ public sealed class SessionService( throw new SessionAccessDeniedException(groupId, "0"); } + var topicDestination = await ResolveTemplateBatchTopicAsync(group.TelegramChatId, template.Title); + var messageThreadId = topicDestination.MessageThreadId; + var topicCreatedByBot = topicDestination.TopicCreatedByBot; + var schedule = BatchSchedulePlanner.BuildRecurringSchedule( firstScheduledAt, template.SessionCount, @@ -1197,8 +1203,8 @@ public sealed class SessionService( { var sessionId = await conn.ExecuteScalarAsync( """ - INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, notification_mode) - VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @MaxPlayers, @NotificationMode) + INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode) + VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode) RETURNING id """, new @@ -1209,6 +1215,8 @@ public sealed class SessionService( template.JoinLink, ScheduledAt = scheduledAt, Status = SessionStatus.Planned, + ThreadId = messageThreadId, + TopicCreatedByBot = topicCreatedByBot, template.MaxPlayers, template.NotificationMode }, @@ -1223,6 +1231,7 @@ public sealed class SessionService( var renderResult = TelegramSessionBatchRenderer.Render(view); var batchMessage = await bot.SendMessage( chatId: group.TelegramChatId, + messageThreadId: messageThreadId, text: renderResult.Text, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, replyMarkup: renderResult.Markup); @@ -1242,6 +1251,34 @@ public sealed class SessionService( template.NotificationMode); } + private async Task ResolveTemplateBatchTopicAsync(long telegramChatId, string title) + { + var chat = await bot.GetChat(chatId: telegramChatId); + if (!chat.IsForum) + { + return new WebTemplateTopicDestination(null, TopicCreatedByBot: false); + } + + try + { + var topic = await bot.CreateForumTopic( + chatId: telegramChatId, + name: $"🎲 Игры: {title}"); + return new WebTemplateTopicDestination(topic.MessageThreadId, TopicCreatedByBot: true); + } + catch (ApiRequestException ex) when (IsMissingForumTopicRightsError(ex.Message)) + { + throw new InvalidOperationException( + "Не удалось создать Telegram topic. Сделайте бота admin и включите право Manage Topics, затем повторите действие.", + ex); + } + } + + private static bool IsMissingForumTopicRightsError(string apiError) => + apiError.Contains("not enough rights", StringComparison.OrdinalIgnoreCase) || + apiError.Contains("CHAT_ADMIN_REQUIRED", StringComparison.OrdinalIgnoreCase) || + apiError.Contains("not an administrator", StringComparison.OrdinalIgnoreCase); + private async Task> LoadSessionDirectRecipientsAsync( Npgsql.NpgsqlConnection conn, Guid sessionId) diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs index 07aafb5..cf48fc2 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs @@ -62,7 +62,7 @@ public sealed class DiscordProjectStructureTests var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml")); var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")); - Assert.Contains("gmrelay-discord-bot:3.1.0", compose); + Assert.Contains("gmrelay-discord-bot:3.1.1", compose); Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose); Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy); Assert.Contains("DISCORD_BOT_TOKEN", deploy); @@ -76,13 +76,13 @@ public sealed class DiscordProjectStructureTests { var repoRoot = GetRepoRoot(); - Assert.Contains("3.1.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); - Assert.Contains("VERSION: 3.1.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); - Assert.Contains("gmrelay-bot:3.1.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-web:3.1.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-discord-bot:3.1.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("3.1.1", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); + Assert.Contains("VERSION: 3.1.1", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); + Assert.Contains("gmrelay-bot:3.1.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-web:3.1.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-discord-bot:3.1.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains( - "v3.1.0", + "v3.1.1", File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor"))); } diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs index cacd78d..e39296c 100644 --- a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs @@ -60,6 +60,19 @@ public sealed class TelegramTopicIntegrationSmokeTests Assert.Contains("ExternalThreadId", telegramMessenger, StringComparison.Ordinal); } + [Fact] + public async Task WebTemplateBatches_ShouldCreateAndPersistForumTopic() + { + var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs"); + + Assert.Contains("GetChat", sessionService, StringComparison.Ordinal); + Assert.Contains("CreateForumTopic", sessionService, StringComparison.Ordinal); + Assert.Contains("thread_id, topic_created_by_bot", sessionService, StringComparison.Ordinal); + Assert.Contains("ThreadId = messageThreadId", sessionService, StringComparison.Ordinal); + Assert.Contains("TopicCreatedByBot = topicCreatedByBot", sessionService, StringComparison.Ordinal); + Assert.Contains("messageThreadId: messageThreadId", sessionService, StringComparison.Ordinal); + } + private static async Task ReadRepositoryFileAsync(string relativePath) { var directory = new DirectoryInfo(AppContext.BaseDirectory); From 542f15f2d6d91f389a0d151d597b05e5a2168079 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 27 May 2026 14:52:09 +0300 Subject: [PATCH 2/2] refactor: extract remaining Telegram handlers to platform-neutral contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract CreateSessionHandler, ListSessionsHandler, DeleteSessionHandler, ExportCalendarHandler, HandleRescheduleTimeInputHandler, HandleRescheduleVoteHandler to GmRelay.Shared - Add IPlatformMessenger methods: SendScheduleAsync, UpdateScheduleAsync, SendGroupMessageAsync with actions, CreateThreadAsync, DeleteThreadAsync - Rewrite Telegram Bot wrappers as thin adapters delegating to shared handlers - Rewrite DiscordRescheduleVoteHandler to use shared HandleRescheduleVoteHandler - Update UpdateRouter with explicit type aliases for ambiguous handler names - Add contract and source-inspection tests for extracted handlers - Bump version 3.1.1 → 3.2.0 Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- compose.yaml | 6 +- .../CreateSession/CreateSessionHandler.cs | 354 ++++++------------ .../ExportCalendar/ExportCalendarHandler.cs | 117 +----- .../ListSessions/DeleteSessionHandler.cs | 164 +++----- .../ListSessions/ListSessionsHandler.cs | 121 +----- .../SessionListMessageRenderer.cs | 63 ++++ .../HandleRescheduleTimeInputHandler.cs | 293 ++++----------- .../HandleRescheduleVoteHandler.cs | 154 ++------ .../Telegram/TelegramPlatformMessenger.cs | 62 +++ .../Infrastructure/Telegram/UpdateRouter.cs | 9 +- src/GmRelay.Bot/Program.cs | 10 +- .../Sessions/DiscordRescheduleVoteHandler.cs | 124 ++---- .../Discord/DiscordPlatformMessenger.cs | 56 +++ src/GmRelay.DiscordBot/Program.cs | 1 + .../CreateSession/CreateSessionCommand.cs | 12 + .../CreateSession/CreateSessionHandler.cs | 157 ++++++++ .../CreateSession/CreateSessionResult.cs | 11 + .../ExportCalendar/ExportCalendarCommand.cs | 7 + .../ExportCalendar/ExportCalendarHandler.cs | 111 ++++++ .../ListSessions/DeleteSessionHandler.cs | 91 +++++ .../ListSessions/ListSessionsCommand.cs | 13 + .../ListSessions/ListSessionsHandler.cs | 57 +++ .../RescheduleSession/AwaitingProposalDto.cs | 12 + .../HandleRescheduleTimeInputCommand.cs | 15 + .../HandleRescheduleTimeInputHandler.cs | 181 +++++++++ .../HandleRescheduleTimeInputResult.cs | 17 + .../HandleRescheduleVoteHandler.cs | 156 ++++++++ .../HandleRescheduleVoteResult.cs | 15 + .../Platform/IPlatformMessenger.cs | 18 +- .../Components/Layout/NavMenu.razor | 2 +- .../Discord/DiscordProjectStructureTests.cs | 14 +- .../DiscordLandingPromisesSmokeTests.cs | 6 +- .../TelegramLandingPromisesSmokeTests.cs | 6 +- .../CreateSessionCommandContractTests.cs | 36 ++ .../CreateSessionHandlerTests.cs | 36 ++ .../ExportCalendarCommandContractTests.cs | 31 ++ .../ListSessionsCommandContractTests.cs | 41 ++ .../SessionListMessageRendererTests.cs | 24 +- .../HandleRescheduleTimeInputHandlerTests.cs | 6 +- .../RescheduleCommandContractTests.cs | 43 +++ .../PlatformIdentityMigrationTests.cs | 4 +- .../TelegramPlatformMessengerSourceTests.cs | 7 +- .../TelegramTopicIntegrationSmokeTests.cs | 11 +- 45 files changed, 1648 insertions(+), 1030 deletions(-) create mode 100644 src/GmRelay.Bot/Features/Sessions/ListSessions/SessionListMessageRenderer.cs create mode 100644 src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs create mode 100644 src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs create mode 100644 src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionResult.cs create mode 100644 src/GmRelay.Shared/Features/Sessions/ExportCalendar/ExportCalendarCommand.cs create mode 100644 src/GmRelay.Shared/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs create mode 100644 src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs create mode 100644 src/GmRelay.Shared/Features/Sessions/ListSessions/ListSessionsCommand.cs create mode 100644 src/GmRelay.Shared/Features/Sessions/ListSessions/ListSessionsHandler.cs create mode 100644 src/GmRelay.Shared/Features/Sessions/RescheduleSession/AwaitingProposalDto.cs create mode 100644 src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputCommand.cs create mode 100644 src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs create mode 100644 src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputResult.cs create mode 100644 src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs create mode 100644 src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleVoteResult.cs create mode 100644 tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionCommandContractTests.cs create mode 100644 tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerTests.cs create mode 100644 tests/GmRelay.Bot.Tests/Features/Sessions/ExportCalendar/ExportCalendarCommandContractTests.cs create mode 100644 tests/GmRelay.Bot.Tests/Features/Sessions/ListSessions/ListSessionsCommandContractTests.cs create mode 100644 tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/RescheduleCommandContractTests.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e9b190e..472553c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 3.1.1 + VERSION: 3.2.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index e8f515c..49062e2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 3.1.1 + 3.2.0 net10.0 preview enable diff --git a/compose.yaml b/compose.yaml index 8c87b78..104c368 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:3.1.1 + image: git.codeanddice.ru/toutsu/gmrelay-bot:3.2.0 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.1.1 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.2.0 restart: always depends_on: db: @@ -84,7 +84,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:3.1.1 + image: git.codeanddice.ru/toutsu/gmrelay-web:3.2.0 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index 95e3440..41a65c8 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -1,294 +1,178 @@ using Dapper; using GmRelay.Shared.Domain; -using GmRelay.Shared.Rendering; +using GmRelay.Shared.Platform; +using GmRelay.Bot.Infrastructure.Telegram; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types; -using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.CreateSession; -internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage); - public sealed class CreateSessionHandler( + GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler sharedHandler, NpgsqlDataSource dataSource, - ITelegramBotClient botClient, + IPlatformMessenger messenger, ILogger logger) { - public async Task HandleAsync(Message message, CancellationToken cancellationToken) + public async Task HandleAsync(Message message, CancellationToken ct) { var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow); foreach (var timeInput in parseResult.PastTimeInputs) { - await botClient.SendMessage( - message.Chat.Id, + await messenger.SendGroupMessageAsync( + TelegramPlatformIds.Group(message.Chat.Id, null), $"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.", - cancellationToken: cancellationToken); + ct); } foreach (var timeInput in parseResult.InvalidTimeInputs) { - await botClient.SendMessage( - message.Chat.Id, + await messenger.SendGroupMessageAsync( + TelegramPlatformIds.Group(message.Chat.Id, null), $"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.", - cancellationToken: cancellationToken); + ct); } foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs) { - await botClient.SendMessage( - message.Chat.Id, + await messenger.SendGroupMessageAsync( + TelegramPlatformIds.Group(message.Chat.Id, null), $"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.", - cancellationToken: cancellationToken); + ct); } foreach (var recurringInput in parseResult.InvalidRecurringInputs) { - await botClient.SendMessage( - message.Chat.Id, + await messenger.SendGroupMessageAsync( + TelegramPlatformIds.Group(message.Chat.Id, null), $"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.", - cancellationToken: cancellationToken); + ct); } if (!parseResult.IsValid) { - await botClient.SendMessage( - chatId: message.Chat.Id, - text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link\nКартинка: https://cover\n\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7", - cancellationToken: cancellationToken); + await messenger.SendGroupMessageAsync( + TelegramPlatformIds.Group(message.Chat.Id, null), + """ + ❌ Не удалось распознать формат. Пожалуйста, используйте шаблон: + + /newsession + Название: My Game + Время: 15.05.2026 19:30 + Время: 22.05.2026 19:30 + Мест: 4 + Ссылка: https://link + Картинка: https://cover + + Для повтора можно указать одну дату и строки: + Игр: 4 + Интервал: 7 + """, + ct); return; } - var title = parseResult.Title!; - var link = parseResult.Link!; var imageReference = GetBatchImageReference(message, parseResult.ImageUrl); var gmId = message.From!.Id; 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"; + var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination( + message.Chat.IsForum, + message.MessageThreadId); + var topicCreatedByBot = topicDestination.TopicCreatedByBot; + var messageThreadId = topicDestination.MessageThreadId; - await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); - await using var transaction = await connection.BeginTransactionAsync(cancellationToken); - - try + if (topicDestination.ShouldCreateForumTopic) { - await connection.ExecuteAsync( - """ - INSERT INTO players (display_name, platform, external_user_id, external_username) - VALUES (@Name, 'Telegram', @ExternalId, @Username) - ON CONFLICT (platform, external_user_id) - WHERE platform IS NOT NULL AND external_user_id IS NOT NULL - DO UPDATE - SET display_name = EXCLUDED.display_name, - external_username = EXCLUDED.external_username; - """, - new { ExternalId = gmId.ToString(), Name = gmName, Username = gmUsername }, - transaction); - - var existingGroup = await connection.QuerySingleOrDefaultAsync( - """ - SELECT g.id AS GroupId, - EXISTS ( - SELECT 1 - FROM group_managers gm - JOIN players p ON p.id = gm.player_id - WHERE gm.group_id = g.id - AND p.platform = 'Telegram' - AND p.external_user_id = @ExternalGmId - ) AS CanManage - FROM game_groups g - WHERE g.platform = 'Telegram' - AND g.external_group_id = @ExternalChatId - """, - new { ExternalChatId = chatId.ToString(), ExternalGmId = gmId.ToString() }, - transaction); - - Guid groupId; - if (existingGroup is null) - { - groupId = await connection.ExecuteScalarAsync( - """ - INSERT INTO game_groups (name, platform, external_group_id) - VALUES (@ChatName, 'Telegram', @ExternalChatId) - RETURNING id; - """, - new { ExternalChatId = chatId.ToString(), ChatName = chatTitle }, - transaction); - - await connection.ExecuteAsync( - """ - INSERT INTO group_managers (group_id, player_id, role) - SELECT @GroupId, p.id, @OwnerRole - FROM players p - WHERE p.platform = 'Telegram' - AND p.external_user_id = @ExternalGmId - ON CONFLICT (group_id, player_id) DO NOTHING - """, - new { GroupId = groupId, ExternalGmId = gmId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue }, - transaction); - } - else - { - if (!existingGroup.CanManage) - { - await transaction.RollbackAsync(cancellationToken); - await botClient.SendMessage( - chatId, - "⛔ Только owner или co-GM этой группы может создавать игровые сессии.", - cancellationToken: cancellationToken); - return; - } - - groupId = existingGroup.GroupId; - await connection.ExecuteAsync( - "UPDATE game_groups SET name = @ChatName WHERE id = @GroupId", - new { ChatName = chatTitle, GroupId = groupId }, - transaction); - } - - var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination( - message.Chat.IsForum, - message.MessageThreadId); - var messageThreadId = topicDestination.MessageThreadId; - var topicCreatedByBot = topicDestination.TopicCreatedByBot; - if (topicDestination.ShouldCreateForumTopic) - { - try - { - var topic = await botClient.CreateForumTopic( - chatId: chatId, - name: $"🎲 Игры: {title}", - cancellationToken: cancellationToken); - messageThreadId = topic.MessageThreadId; - } - catch (Telegram.Bot.Exceptions.ApiRequestException ex) - when (TelegramTopicRouting.IsMissingForumTopicRightsError(ex.Message)) - { - await transaction.RollbackAsync(cancellationToken); - await botClient.SendMessage( - chatId, - TelegramTopicRouting.MissingForumTopicRightsMessage, - cancellationToken: cancellationToken); - return; - } - } - - var batchId = Guid.NewGuid(); - var sessions = new List(); - - 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, topic_created_by_bot, max_players) - VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers) - RETURNING id; - """, - new - { - BatchId = batchId, - GroupId = groupId, - Title = title, - Link = link, - ScheduledAt = scheduledAt, - ThreadId = messageThreadId, - TopicCreatedByBot = topicCreatedByBot, - MaxPlayers = parseResult.MaxPlayers, - Status = SessionStatus.Planned - }, - transaction); - - sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers, link)); - } - - await transaction.CommitAsync(cancellationToken); - logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId); - - var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty()); - var renderResult = TelegramSessionBatchRenderer.Render(view); - - Message batchMessage; - - if (imageReference is not null && renderResult.Text.Length <= 1024) - { - // Картинка + расписание умещаются в одном Telegram-фото с подписью - try - { - batchMessage = await botClient.SendPhoto( - chatId: chatId, - messageThreadId: messageThreadId, - photo: InputFile.FromString(imageReference), - caption: renderResult.Text, - parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, - replyMarkup: renderResult.Markup, - cancellationToken: cancellationToken); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}, отправляем текстом", batchId); - batchMessage = await botClient.SendMessage( - chatId: chatId, - messageThreadId: messageThreadId, - text: renderResult.Text, - parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, - replyMarkup: renderResult.Markup, - cancellationToken: cancellationToken); - } - } - else - { - // Текст слишком длинный для caption — fallback на два сообщения - if (imageReference is not null) - { - try - { - await botClient.SendPhoto( - chatId: chatId, - messageThreadId: messageThreadId, - photo: InputFile.FromString(imageReference), - caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}", - parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, - cancellationToken: cancellationToken); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}", batchId); - } - } - - batchMessage = await botClient.SendMessage( - chatId: chatId, - messageThreadId: messageThreadId, - text: renderResult.Text, - parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, - replyMarkup: renderResult.Markup, - cancellationToken: cancellationToken); - } - - await connection.ExecuteAsync( - "UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId", - new { MsgId = batchMessage.MessageId, BatchId = batchId }); - try { - await botClient.DeleteMessage( - chatId: chatId, - messageId: message.MessageId, - cancellationToken: cancellationToken); + var topicRef = await messenger.CreateThreadAsync( + TelegramPlatformIds.Group(message.Chat.Id, null), + $"🎲 Игры: {parseResult.Title}", + ct); + messageThreadId = int.Parse(topicRef.ExternalThreadId!, System.Globalization.CultureInfo.InvariantCulture); } catch (Exception ex) + when (ex.Message.Contains("not enough rights") || + ex.Message.Contains("CHAT_ADMIN_REQUIRED") || + ex.Message.Contains("not an administrator")) { - logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, chatId); + await messenger.SendGroupMessageAsync( + TelegramPlatformIds.Group(message.Chat.Id, null), + TelegramTopicRouting.MissingForumTopicRightsMessage, + ct); + return; } } + + var platformGroup = TelegramPlatformIds.Group(message.Chat.Id, messageThreadId, message.Chat.Title ?? "Private Chat"); + var platformUser = new PlatformUser( + PlatformKind.Telegram, + gmId.ToString(System.Globalization.CultureInfo.InvariantCulture), + gmName, + gmUsername); + + var command = new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionCommand( + platformUser, + platformGroup, + parseResult.Title!, + parseResult.Link!, + parseResult.ScheduledTimes, + parseResult.MaxPlayers, + imageReference); + + GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionResult result; + try + { + result = await sharedHandler.HandleAsync(command, ct); + } + catch + { + await messenger.SendGroupMessageAsync( + TelegramPlatformIds.Group(message.Chat.Id, null), + "💥 Произошла ошибка базы данных при создании сессии.", + ct); + throw; + } + + if (!result.Success) + { + await messenger.SendGroupMessageAsync( + TelegramPlatformIds.Group(message.Chat.Id, null), + result.ErrorMessage!, + ct); + return; + } + + var scheduleMessage = new PlatformScheduleMessage( + platformGroup, + result.View!, + null, + imageReference); + + var sentMessageRef = await messenger.SendScheduleAsync(scheduleMessage, ct); + + // Store batch_message_id + if (int.TryParse(sentMessageRef.ExternalMessageId, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var batchMessageId)) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await connection.ExecuteAsync( + "UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId", + new { MsgId = batchMessageId, BatchId = result.BatchId }); + } + + // Delete original message + try + { + await messenger.DeleteMessageAsync( + TelegramPlatformIds.Message(message.Chat.Id, null, message.MessageId), + ct); + } catch (Exception ex) { - logger.LogError(ex, "Ошибка при создании сессии"); - await transaction.RollbackAsync(cancellationToken); - await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken); + logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, message.Chat.Id); } } diff --git a/src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs b/src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs index 20cbd2b..a0fa9da 100644 --- a/src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs @@ -1,114 +1,25 @@ -using System.Text; -using Dapper; -using GmRelay.Bot.Infrastructure.Telegram; -using GmRelay.Shared.Domain; using GmRelay.Shared.Platform; -using Microsoft.Extensions.Configuration; -using Npgsql; using Telegram.Bot.Types; namespace GmRelay.Bot.Features.Sessions.ExportCalendar; -internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt); - public sealed class ExportCalendarHandler( - NpgsqlDataSource dataSource, - IPlatformMessenger messenger, - IConfiguration configuration) + GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler sharedHandler) { - public async Task HandleAsync(Message message, CancellationToken cancellationToken) + public Task HandleAsync(Message message, CancellationToken cancellationToken) { - await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + var command = new GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarCommand( + new PlatformGroup( + PlatformKind.Telegram, + message.Chat.Id.ToString(), + message.Chat.Title ?? "Private Chat", + message.MessageThreadId?.ToString()), + new PlatformUser( + PlatformKind.Telegram, + message.From?.Id.ToString() ?? string.Empty, + message.From?.FirstName ?? string.Empty, + message.From?.Username)); - var sessions = await connection.QueryAsync( - @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt" - + " FROM sessions s" - + " JOIN game_groups g ON s.group_id = g.id" - + " WHERE g.platform = 'Telegram'" - + " AND g.external_group_id = @ExternalChatId" - + " AND s.status = @Planned" - + " AND s.scheduled_at > NOW()" - + " ORDER BY s.scheduled_at ASC", - new { ExternalChatId = message.Chat.Id.ToString(), Planned = SessionStatus.Planned }); - - var sessionsList = sessions.ToList(); - - if (sessionsList.Count == 0) - { - await messenger.SendGroupMessageAsync( - TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId), - "📭 У этой группы нет запланированных сессий для экспорта.", - cancellationToken); - return; - } - - var sb = new StringBuilder(); - sb.AppendLine("BEGIN:VCALENDAR"); - sb.AppendLine("VERSION:2.0"); - sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN"); - - foreach (var s in sessionsList) - { - var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ"); - var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ"); - - sb.AppendLine("BEGIN:VEVENT"); - sb.AppendLine($"UID:{s.Id}@gmrelay"); - sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}"); - sb.AppendLine($"DTSTART:{dtStart}"); - sb.AppendLine($"DTEND:{dtEnd}"); - sb.AppendLine($"SUMMARY:{s.Title}"); - sb.AppendLine("END:VEVENT"); - } - - sb.AppendLine("END:VCALENDAR"); - - var bytes = Encoding.UTF8.GetBytes(sb.ToString()); - - - // Create calendar subscription - string? subscriptionUrl = null; - var baseUrl = configuration["Web:BaseUrl"]; - var senderId = message.From?.Id; - if (!string.IsNullOrWhiteSpace(baseUrl) && senderId.HasValue) - { - try - { - var token = Guid.NewGuid().ToString("N"); - var groupId = await connection.QueryFirstOrDefaultAsync( - @"SELECT id FROM game_groups WHERE platform = 'Telegram' AND external_group_id = @ExternalChatId", - new { ExternalChatId = message.Chat.Id.ToString() }); - - await connection.ExecuteAsync( - @"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at) - VALUES (gen_random_uuid(), @token, 'Telegram', @userExternalId, @groupId, @filterType, now(), NULL)", - new { token, userExternalId = senderId.Value.ToString(), groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup }); - - subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics"; - } - catch - { - // Non-critical: if subscription creation fails, still send the file - } - } - - var actions = subscriptionUrl is not null - ? new[] - { - new PlatformMessageAction( - "calendar-subscription", - "🔗 Подписаться на календарь", - subscriptionUrl) - } - : Array.Empty(); - - await messenger.SendCalendarFileAsync( - new PlatformCalendarFile( - TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId), - "schedule.ics", - bytes, - "📅 Ваш календарь игр!\nОткройте файл на устройстве, чтобы добавить события в свой календарь.", - actions), - cancellationToken); + return sharedHandler.HandleAsync(command, cancellationToken); } } diff --git a/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs index 0b150cf..da4bc98 100644 --- a/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs @@ -1,8 +1,5 @@ -using Dapper; -using Npgsql; -using Telegram.Bot; using GmRelay.Bot.Infrastructure.Telegram; -using GmRelay.Shared.Domain; +using GmRelay.Shared.Platform; namespace GmRelay.Bot.Features.Sessions.ListSessions; @@ -13,143 +10,88 @@ public sealed record DeleteSessionCommand( long ChatId, int MessageId); -internal sealed record DeleteSessionInfoDto( - string Title, - Guid BatchId, - Guid GroupId, - bool CanManage, - int? ThreadId, - bool TopicCreatedByBot); - public sealed class DeleteSessionHandler( - NpgsqlDataSource dataSource, - ITelegramBotClient bot, + GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler sharedHandler, + GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler listSessionsHandler, + IPlatformMessenger messenger, ILogger logger) { public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct) { - await using var connection = await dataSource.OpenConnectionAsync(ct); - await using var transaction = await connection.BeginTransactionAsync(ct); + var platformUser = new PlatformUser( + PlatformKind.Telegram, + command.TelegramUserId.ToString(), + string.Empty, + null); - // 1. Fetch session and verify group manager. - var session = await connection.QuerySingleOrDefaultAsync( - """ - SELECT s.title AS Title, - s.batch_id AS BatchId, - s.group_id AS GroupId, - s.thread_id AS ThreadId, - s.topic_created_by_bot AS TopicCreatedByBot, - EXISTS ( - SELECT 1 - FROM group_managers gm - JOIN players p ON p.id = gm.player_id - WHERE gm.group_id = s.group_id - AND p.platform = 'Telegram' - AND p.external_user_id = @ExternalUserId - ) AS CanManage - FROM sessions s - WHERE s.id = @SessionId - """, - new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() }, transaction); + var platformGroup = new PlatformGroup( + PlatformKind.Telegram, + command.ChatId.ToString(), + string.Empty); - if (session == null) + var scheduleMessage = TelegramPlatformIds.Message(command.ChatId, null, command.MessageId); + + var sharedCommand = new GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionCommand( + command.SessionId, + platformUser, + platformGroup, + scheduleMessage); + + var result = await sharedHandler.HandleAsync(sharedCommand, ct); + + if (!result.Success) { - await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct); + await messenger.AnswerInteractionAsync( + new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("owner")), + ct); return; } - if (!session.CanManage) - { - await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может удалять сессию.", showAlert: true, cancellationToken: ct); - return; - } - - // 2. Delete session - await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction); - - var remainingInTopic = session.ThreadId.HasValue - ? await connection.ExecuteScalarAsync( - """ - SELECT COUNT(*) - FROM sessions - WHERE group_id = @GroupId - AND thread_id = @ThreadId - """, - new { session.GroupId, ThreadId = session.ThreadId.Value }, - transaction) - : 0; - - await transaction.CommitAsync(ct); - // 4. If no sessions are left in a bot-owned forum topic, delete the topic. - if (session.ThreadId.HasValue && - TelegramTopicRouting.ShouldDeleteForumTopic(session.TopicCreatedByBot, remainingInTopic)) + if (result.ThreadId.HasValue && + TelegramTopicRouting.ShouldDeleteForumTopic(result.TopicCreatedByBot, result.RemainingInTopic)) { try { - await bot.DeleteForumTopic(command.ChatId, session.ThreadId.Value, cancellationToken: ct); - logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", session.ThreadId.Value, session.BatchId); + await messenger.DeleteThreadAsync( + new PlatformGroup(PlatformKind.Telegram, command.ChatId.ToString(), string.Empty, null, result.ThreadId.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)), + ct); + logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", result.ThreadId.Value, result.GroupId); } catch (Exception ex) { - logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", session.ThreadId.Value); + logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", result.ThreadId.Value); } } - await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия удалена!", cancellationToken: ct); + await messenger.AnswerInteractionAsync( + new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!), + ct); - // 5. Update the /listsessions message (we delete the message or edit it to remove the button) - // A simple way is to re-render the list: - await using var readConnection = await dataSource.OpenConnectionAsync(ct); - var sessions = await readConnection.QueryAsync( - @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers, - COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount, - COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount, - EXISTS ( - SELECT 1 - FROM group_managers gm - JOIN players manager_player ON manager_player.id = gm.player_id - WHERE gm.group_id = s.group_id - AND manager_player.platform = 'Telegram' - AND manager_player.external_user_id = @ExternalUserId - ) AS CanManage - FROM sessions s - JOIN game_groups g ON s.group_id = g.id - LEFT JOIN session_participants sp ON s.id = sp.session_id - WHERE g.platform = 'Telegram' - AND g.external_group_id = @ExternalChatId - AND s.status != @Cancelled - AND s.scheduled_at > NOW() - GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id - ORDER BY s.scheduled_at ASC", - new - { - ExternalChatId = command.ChatId.ToString(), - ExternalUserId = command.TelegramUserId.ToString(), - Cancelled = SessionStatus.Cancelled, - Active = ParticipantRegistrationStatus.Active, - Waitlisted = ParticipantRegistrationStatus.Waitlisted - }); + // 5. Update the /listsessions message + var listCommand = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(platformGroup, platformUser); + var listResult = await listSessionsHandler.HandleAsync(listCommand, ct); - var sessionsList = sessions.ToList(); - - if (sessionsList.Count == 0) + if (listResult.Sessions.Count == 0) { - try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch { } + try + { + await messenger.UpdateGroupMessageAsync( + scheduleMessage, + "📭 В этой группе нет предстоящих игр.", + [], + ct); + } + catch { } return; } - var renderResult = SessionListMessageRenderer.Render(sessionsList); + var text = SessionListMessageRenderer.RenderText(listResult.Sessions); + var actions = listResult.CanManage ? SessionListMessageRenderer.RenderActions(listResult.Sessions) : []; try { - await bot.EditMessageText( - command.ChatId, - command.MessageId, - renderResult.Text, - parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, - replyMarkup: renderResult.Markup, - cancellationToken: ct); + await messenger.UpdateGroupMessageAsync(scheduleMessage, text, actions, ct); } catch (Exception ex) { diff --git a/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs b/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs index 662968d..03d9643 100644 --- a/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs @@ -1,118 +1,37 @@ -using Dapper; -using GmRelay.Shared.Domain; -using Npgsql; -using Telegram.Bot; +using GmRelay.Shared.Platform; using Telegram.Bot.Types; -using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Bot.Features.Sessions.ListSessions; -internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage); - -internal static class SessionListMessageRenderer -{ - public static (string Text, InlineKeyboardMarkup? Markup) Render(IReadOnlyList sessions) - { - var text = "📅 Ближайшие игры:\n\n"; - foreach (var session in sessions) - { - var seats = session.MaxPlayers.HasValue - ? $"{session.PlayerCount}/{session.MaxPlayers.Value}" - : session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture); - var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty; - text += $"🔹 {session.ScheduledAt.FormatMoscow()} — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n"; - } - - var canManage = sessions.Count > 0 && sessions.First().CanManage; - if (!canManage) - { - return (text, null); - } - - var buttons = new List(); - foreach (var session in sessions) - { - var dateTitle = session.ScheduledAt.FormatMoscowShort(); - buttons.Add( - [ - InlineKeyboardButton.WithCallbackData($"❌ {dateTitle}", $"cancel_session:{session.Id}"), - InlineKeyboardButton.WithCallbackData($"⏰ {dateTitle}", $"reschedule_session:{session.Id}") - ]); - - if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount)) - { - buttons.Add( - [ - InlineKeyboardButton.WithCallbackData($"⬆️ Из ожидания {dateTitle}", $"promote_waitlist:{session.Id}") - ]); - } - - buttons.Add( - [ - InlineKeyboardButton.WithCallbackData($"🗑 Удалить {dateTitle}", $"delete_session:{session.Id}") - ]); - } - - return (text, new InlineKeyboardMarkup(buttons)); - } -} - public sealed class ListSessionsHandler( - NpgsqlDataSource dataSource, - ITelegramBotClient botClient) + GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler sharedHandler, + IPlatformMessenger messenger) { public async Task HandleAsync(Message message, CancellationToken cancellationToken) { - await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + var command = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand( + new PlatformGroup( + PlatformKind.Telegram, + message.Chat.Id.ToString(), + message.Chat.Title ?? "Private Chat", + message.MessageThreadId?.ToString()), + new PlatformUser( + PlatformKind.Telegram, + message.From?.Id.ToString() ?? string.Empty, + message.From?.FirstName ?? string.Empty, + message.From?.Username)); - var sessions = await connection.QueryAsync( - @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers, - COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount, - COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount, - EXISTS ( - SELECT 1 - FROM group_managers gm - JOIN players manager_player ON manager_player.id = gm.player_id - WHERE gm.group_id = s.group_id - AND manager_player.platform = 'Telegram' - AND manager_player.external_user_id = @ExternalUserId - ) AS CanManage - FROM sessions s - JOIN game_groups g ON s.group_id = g.id - LEFT JOIN session_participants sp ON s.id = sp.session_id - WHERE g.platform = 'Telegram' - AND g.external_group_id = @ExternalChatId - AND s.status != @Cancelled - AND s.scheduled_at > NOW() - GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id - ORDER BY s.scheduled_at ASC", - new - { - ExternalChatId = message.Chat.Id.ToString(), - ExternalUserId = message.From?.Id.ToString(), - Cancelled = SessionStatus.Cancelled, - Active = ParticipantRegistrationStatus.Active, - Waitlisted = ParticipantRegistrationStatus.Waitlisted - }); + var result = await sharedHandler.HandleAsync(command, cancellationToken); - var sessionsList = sessions.ToList(); - - if (sessionsList.Count == 0) + if (result.Sessions.Count == 0) { - await botClient.SendMessage( - chatId: message.Chat.Id, - text: "📭 В этой группе нет предстоящих игр.", - cancellationToken: cancellationToken); + await messenger.SendGroupMessageAsync(command.Group, "📭 В этой группе нет предстоящих игр.", cancellationToken); return; } - var renderResult = SessionListMessageRenderer.Render(sessionsList); + var text = SessionListMessageRenderer.RenderText(result.Sessions); + var actions = result.CanManage ? SessionListMessageRenderer.RenderActions(result.Sessions) : []; - await botClient.SendMessage( - chatId: message.Chat.Id, - text: renderResult.Text, - parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, - replyMarkup: renderResult.Markup, - cancellationToken: cancellationToken); + await messenger.SendGroupMessageAsync(command.Group, text, actions, cancellationToken); } } diff --git a/src/GmRelay.Bot/Features/Sessions/ListSessions/SessionListMessageRenderer.cs b/src/GmRelay.Bot/Features/Sessions/ListSessions/SessionListMessageRenderer.cs new file mode 100644 index 0000000..7501604 --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/ListSessions/SessionListMessageRenderer.cs @@ -0,0 +1,63 @@ +using GmRelay.Shared.Domain; +using GmRelay.Shared.Platform; +using GmRelay.Shared.Features.Sessions.ListSessions; + +namespace GmRelay.Bot.Features.Sessions.ListSessions; + +internal static class SessionListMessageRenderer +{ + public static string RenderText(IReadOnlyList sessions) + { + var text = "📅 Ближайшие игры:\n\n"; + foreach (var session in sessions) + { + var seats = session.MaxPlayers.HasValue + ? $"{session.PlayerCount}/{session.MaxPlayers.Value}" + : session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture); + var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty; + text += $"🔹 {session.ScheduledAt.FormatMoscow()} — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n"; + } + + return text; + } + + public static IReadOnlyList RenderActions(IReadOnlyList sessions) + { + if (sessions.Count == 0 || !sessions.First().CanManage) + { + return []; + } + + var actions = new List(); + + foreach (var session in sessions) + { + var dateTitle = session.ScheduledAt.FormatMoscowShort(); + + actions.Add(new PlatformMessageAction( + $"cancel_session:{session.Id}", + $"❌ {dateTitle}", + $"cancel_session:{session.Id}")); + + actions.Add(new PlatformMessageAction( + $"reschedule_session:{session.Id}", + $"⏰ {dateTitle}", + $"reschedule_session:{session.Id}")); + + if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount)) + { + actions.Add(new PlatformMessageAction( + $"promote_waitlist:{session.Id}", + $"⬆️ Из ожидания {dateTitle}", + $"promote_waitlist:{session.Id}")); + } + + actions.Add(new PlatformMessageAction( + $"delete_session:{session.Id}", + $"🗑 Удалить {dateTitle}", + $"delete_session:{session.Id}")); + } + + return actions; + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs index f36acd5..cbd33a5 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs @@ -12,243 +12,156 @@ using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.RescheduleSession; -// ── DTOs ───────────────────────────────────────────────────────────── - -internal sealed record AwaitingProposalDto( - Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt, - Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode); - -// ── Handler ────────────────────────────────────────────────────────── - /// -/// Handles text input from the GM who has an AwaitingTime proposal. -/// Parses reschedule options with a voting deadline, creates a voting message, -/// and tags all participants. -/// If no participants are registered, reschedules immediately. +/// Telegram adapter for reschedule time input. +/// Delegates core logic to the shared handler, then performs Telegram-specific +/// message sending, DM notifications, vote_message_id storage, and cleanup. /// public sealed class HandleRescheduleTimeInputHandler( + GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler sharedHandler, NpgsqlDataSource dataSource, ITelegramBotClient bot, IPlatformMessenger messenger, DirectSessionNotificationSender directSender, ILogger logger) { - /// - /// Attempts to handle a text message as reschedule time input. - /// Returns true if it was handled (i.e. user had an AwaitingTime proposal). - /// public async Task TryHandleAsync(Message message, CancellationToken ct) { if (message.From is null || string.IsNullOrWhiteSpace(message.Text)) return false; - var gmTelegramId = message.From.Id; - var chatId = message.Chat.Id; - var text = message.Text.Trim(); + var command = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputCommand( + new PlatformUser( + PlatformKind.Telegram, + message.From.Id.ToString(), + message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"), + message.From.Username), + TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title), + message.Text.Trim()); - await using var connection = await dataSource.OpenConnectionAsync(ct); - - // 1. Check if this GM has an AwaitingTime proposal in this chat - var proposal = await connection.QuerySingleOrDefaultAsync( - """ - SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt, - s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, - g.external_group_id::BIGINT AS TelegramChatId, - s.thread_id AS ThreadId, - s.notification_mode AS NotificationMode - FROM reschedule_proposals rp - JOIN sessions s ON s.id = rp.session_id - JOIN game_groups g ON g.id = s.group_id - WHERE rp.proposed_by_external_user_id = @ExternalGmId - AND rp.status = 'AwaitingTime' - AND g.platform = 'Telegram' - AND g.external_group_id = @ExternalChatId - AND EXISTS ( - SELECT 1 - FROM group_managers gm - JOIN players manager_player ON manager_player.id = gm.player_id - WHERE gm.group_id = s.group_id - AND manager_player.platform = 'Telegram' - AND manager_player.external_user_id = @ExternalGmId - ) - ORDER BY rp.created_at DESC - LIMIT 1 - """, - new { ExternalGmId = gmTelegramId.ToString(), ExternalChatId = chatId.ToString() }); - - if (proposal is null) + var result = await sharedHandler.HandleAsync(command, ct); + if (!result.Handled) return false; - // 2. Parse voting input - if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError)) + if (!string.IsNullOrEmpty(result.ReplyText) && !result.IsRescheduledImmediately) { await messenger.SendGroupMessageAsync( - TelegramPlatformIds.Group(chatId, proposal.ThreadId), - $"⚠️ {parseError}\n\nИспользуйте формат:\n25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00", + command.Group, + $"""⚠️ {result.ReplyText}\n\nИспользуйте формат:\n25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00""", ct); return true; } - // 3. Load participants (non-GM) signed up for this session - var participants = (await connection.QueryAsync( - """ - SELECT p.id AS PlayerId, - p.display_name AS DisplayName, - p.external_username AS TelegramUsername, - p.external_user_id::BIGINT AS TelegramId - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId - AND sp.is_gm = false - AND sp.registration_status = @Active - """, - new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); - - // 4. If no participants — reschedule immediately - if (participants.Count == 0) + if (result.IsRescheduledImmediately) { - await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct); - await TryDeleteMessage(chatId, message.MessageId, ct); + if (result.UpdatedView is not null && result.BatchMessageId.HasValue) + { + await TryUpdateBatchMessage( + command.Group, + result.UpdatedView, + TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, result.BatchMessageId.Value), + ct); + } + + await messenger.SendGroupMessageAsync(command.Group, result.ReplyText!, ct); + await TryDeleteMessage(message.Chat.Id, message.MessageId, ct); return true; } - // 5. Create voting message - await using var transaction = await connection.BeginTransactionAsync(ct); - var options = votingInput.Options - .Select((proposedAt, index) => new RescheduleOptionDto( - Guid.NewGuid(), - index + 1, - proposedAt)) - .ToList(); - - await connection.ExecuteAsync( - """ - UPDATE reschedule_proposals - SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @ChatId - WHERE id = @Id - """, - new { votingInput.Deadline, ChatId = chatId, Id = proposal.Id }, - transaction); - - foreach (var option in options) - { - await connection.ExecuteAsync( - """ - INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order) - VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder) - """, - new - { - option.OptionId, - ProposalId = proposal.Id, - option.ProposedAt, - option.DisplayOrder - }, - transaction); - } - - await transaction.CommitAsync(ct); - + // Voting mode var voteText = BuildVotingMessage( - proposal.Title, - proposal.CurrentScheduledAt, - votingInput.Deadline, - options, - participants, + result.Title!, + result.CurrentScheduledAt, + result.VotingDeadlineAt!.Value, + result.Options, + result.Participants, []); - var keyboard = BuildVotingKeyboard(options); + + var keyboard = BuildVotingKeyboard(result.Options); var voteMsg = await bot.SendMessage( - chatId: chatId, - messageThreadId: proposal.ThreadId, + chatId: message.Chat.Id, + messageThreadId: message.MessageThreadId, text: voteText, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, replyMarkup: keyboard, cancellationToken: ct); - var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode); + var mode = await GetNotificationModeAsync(result.ProposalId!.Value, ct); if (mode.ShouldSendDirectMessages()) { var optionsText = string.Join( "\n", - options.Select(option => $"{option.DisplayOrder}. {option.ProposedAt.FormatMoscow()} (МСК)")); + result.Options.Select(option => $"{option.DisplayOrder}. {option.ProposedAt.FormatMoscow()} (МСК)")); var directText = $""" 🔄 Голосование за перенос сессии - 📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)} - 📅 Текущее время: {proposal.CurrentScheduledAt.FormatMoscow()} (МСК) + 📌 {System.Net.WebUtility.HtmlEncode(result.Title)} + 📅 Текущее время: {result.CurrentScheduledAt.FormatMoscow()} (МСК) 🗳 Варианты: {optionsText} - ⏳ Дедлайн: {votingInput.Deadline.FormatMoscow()} (МСК) + ⏳ Дедлайн: {result.VotingDeadlineAt.Value.FormatMoscow()} (МСК) Проголосуйте кнопкой в групповом сообщении. """; await directSender.SendAsync( - participants.Select(p => new DirectNotificationRecipient( + result.Participants.Select(p => new DirectNotificationRecipient( p.TelegramId, p.DisplayName)), directText, "reschedule-vote", - proposal.SessionId, + result.ProposalId.Value, ct); } - // Store vote message ID + await using var connection = await dataSource.OpenConnectionAsync(ct); await connection.ExecuteAsync( "UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id", - new { MsgId = voteMsg.MessageId, Id = proposal.Id }); + new { MsgId = voteMsg.MessageId, Id = result.ProposalId.Value }); logger.LogInformation( "Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}", - proposal.SessionId, - proposal.Id, - options.Count, - votingInput.Deadline); - - // Delete GM's time input message - await TryDeleteMessage(chatId, message.MessageId, ct); + result.ProposalId.Value, + result.ProposalId.Value, + result.Options.Count, + result.VotingDeadlineAt.Value); + await TryDeleteMessage(message.Chat.Id, message.MessageId, ct); return true; } - private async Task RescheduleImmediately( - NpgsqlConnection connection, AwaitingProposalDto proposal, - DateTimeOffset newTime, long chatId, CancellationToken ct) + private async Task GetNotificationModeAsync(Guid proposalId, CancellationToken ct) { - await using var transaction = await connection.BeginTransactionAsync(ct); - - await connection.ExecuteAsync( + await using var connection = await dataSource.OpenConnectionAsync(ct); + var raw = await connection.QuerySingleOrDefaultAsync( """ - UPDATE sessions - SET scheduled_at = @NewTime, - status = @Status, - confirmation_message_id = NULL, - confirmation_sent_at = NULL, - one_hour_reminder_processed_at = NULL, - updated_at = now() - WHERE id = @SessionId + SELECT s.notification_mode + FROM sessions s + JOIN reschedule_proposals rp ON rp.session_id = s.id + WHERE rp.id = @Id """, - new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned }, - transaction); + new { Id = proposalId }); + return SessionNotificationModeExtensions.FromDatabaseValue(raw ?? string.Empty); + } - await connection.ExecuteAsync( - "UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id", - new { NewTime = newTime, Id = proposal.Id }, - transaction); - - await transaction.CommitAsync(ct); - - await messenger.SendGroupMessageAsync( - TelegramPlatformIds.Group(chatId, proposal.ThreadId), - $"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: {newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))} (МСК)\n\nУчастников нет — голосование не требуется.", - ct); - - // Re-render batch message with updated time - await TryUpdateBatchMessage(proposal, ct); - - logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId); + private async Task TryUpdateBatchMessage( + PlatformGroup group, + SessionBatchViewModel view, + PlatformMessageRef scheduleMessage, + CancellationToken ct) + { + try + { + await messenger.UpdateScheduleAsync( + new PlatformScheduleMessage(group, view, scheduleMessage), + ct); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to update batch message after immediate reschedule"); + } } internal static string BuildVotingMessage( @@ -270,7 +183,7 @@ public sealed class HandleRescheduleTimeInputHandler( var lines = new List { - $"🔄 Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»", + $"""🔄 Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»""", "", $"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)", $"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)", @@ -351,52 +264,6 @@ public sealed class HandleRescheduleTimeInputHandler( "dd.MM HH:mm", System.Globalization.CultureInfo.InvariantCulture); - private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct) - { - try - { - await using var conn = await dataSource.OpenConnectionAsync(ct); - - var batchSessions = (await conn.QueryAsync( - "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", - new { proposal.BatchId })).ToList(); - - var batchParticipants = (await conn.QueryAsync( - """ - SELECT sp.session_id AS SessionId, - p.display_name AS DisplayName, - p.external_username AS TelegramUsername, - sp.registration_status AS RegistrationStatus - FROM session_participants sp - JOIN players p ON sp.player_id = p.id - JOIN sessions s ON sp.session_id = s.id - WHERE s.batch_id = @BatchId AND sp.is_gm = false - ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC - """, - new { proposal.BatchId })).ToList(); - - if (proposal.BatchMessageId.HasValue) - { - var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants); - - await messenger.UpdateScheduleAsync( - new PlatformScheduleMessage( - TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId), - view, - TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)), - ct); - } - else - { - logger.LogWarning("No batch_message_id stored for session {SessionId}, cannot edit batch message in-place", proposal.SessionId); - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to update batch message after immediate reschedule for session {SessionId}", proposal.SessionId); - } - } - private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct) { try diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs index f7c0595..62f7195 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs @@ -1,8 +1,7 @@ -using Dapper; +using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; -using Npgsql; using Telegram.Bot; namespace GmRelay.Bot.Features.Sessions.RescheduleSession; @@ -15,131 +14,49 @@ public sealed record HandleRescheduleVoteCommand( int MessageId); public sealed class HandleRescheduleVoteHandler( - NpgsqlDataSource dataSource, + GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler, ITelegramBotClient bot, IPlatformMessenger messenger, ILogger logger) { public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct) { - await using var connection = await dataSource.OpenConnectionAsync(ct); - await using var transaction = await connection.BeginTransactionAsync(ct); + var platformUser = new PlatformUser( + PlatformKind.Telegram, + command.TelegramUserId.ToString(), + string.Empty, + null); - var proposal = await connection.QuerySingleOrDefaultAsync( - """ - SELECT rp.id AS Id, - rp.session_id AS SessionId, - rp.voting_deadline_at AS VotingDeadlineAt, - s.title AS Title, - s.scheduled_at AS CurrentScheduledAt - FROM reschedule_options ro - JOIN reschedule_proposals rp ON rp.id = ro.proposal_id - JOIN sessions s ON s.id = rp.session_id - WHERE ro.id = @OptionId AND rp.status = 'Voting' - """, - new { command.OptionId }, - transaction); + var platformGroup = new PlatformGroup( + PlatformKind.Telegram, + command.ChatId.ToString(), + string.Empty); - if (proposal is null) + var sharedCommand = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteCommand( + command.OptionId, + platformUser, + platformGroup, + command.CallbackQueryId, + TelegramPlatformIds.Message(command.ChatId, null, command.MessageId)); + + var result = await sharedHandler.HandleAsync(sharedCommand, ct); + + if (!result.Success) { - await AnswerAsync(command.CallbackQueryId, "Голосование уже завершено или не найдено.", ct); + await messenger.AnswerInteractionAsync( + new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("дедлайн")), + ct); return; } - if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow) - { - await AnswerAsync(command.CallbackQueryId, "Дедлайн уже прошёл. Результаты скоро будут применены.", ct, showAlert: true); - return; - } - - var playerId = await connection.ExecuteScalarAsync( - """ - SELECT p.id - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId - AND p.platform = 'Telegram' - AND p.external_user_id = @ExternalUserId - AND sp.is_gm = false - AND sp.registration_status = @Active - """, - new { proposal.SessionId, ExternalUserId = command.TelegramUserId.ToString(), Active = ParticipantRegistrationStatus.Active }, - transaction); - - if (playerId is null) - { - await AnswerAsync(command.CallbackQueryId, "Вы не являетесь участником этой сессии.", ct); - return; - } - - await connection.ExecuteAsync( - """ - INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id) - VALUES (@ProposalId, @PlayerId, @OptionId) - ON CONFLICT (proposal_id, player_id) DO UPDATE - SET option_id = EXCLUDED.option_id, - voted_at = now() - """, - new - { - ProposalId = proposal.Id, - PlayerId = playerId.Value, - command.OptionId - }, - transaction); - - var participants = (await connection.QueryAsync( - """ - SELECT p.id AS PlayerId, - p.display_name AS DisplayName, - p.external_username AS TelegramUsername, - p.external_user_id::BIGINT AS TelegramId - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId - AND sp.is_gm = false - AND sp.registration_status = @Active - ORDER BY p.display_name - """, - new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, - transaction)).ToList(); - - var options = (await connection.QueryAsync( - """ - SELECT id AS OptionId, - display_order AS DisplayOrder, - proposed_at AS ProposedAt - FROM reschedule_options - WHERE proposal_id = @ProposalId - ORDER BY display_order - """, - new { ProposalId = proposal.Id }, - transaction)).ToList(); - - var votes = (await connection.QueryAsync( - """ - SELECT rov.option_id AS OptionId, - p.id AS PlayerId, - p.display_name AS DisplayName, - p.external_username AS TelegramUsername - FROM reschedule_option_votes rov - JOIN players p ON p.id = rov.player_id - WHERE rov.proposal_id = @ProposalId - ORDER BY rov.voted_at, p.display_name - """, - new { ProposalId = proposal.Id }, - transaction)).ToList(); - - await transaction.CommitAsync(ct); - var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage( - proposal.Title, - proposal.CurrentScheduledAt, - proposal.VotingDeadlineAt, - options, - participants, - votes); - var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options); + result.Title!, + result.CurrentScheduledAt, + result.VotingDeadlineAt, + result.Options, + result.Participants, + result.Votes); + var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(result.Options); try { @@ -153,12 +70,11 @@ public sealed class HandleRescheduleVoteHandler( } catch (Exception ex) { - logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id); + logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", result.ProposalId); } - await AnswerAsync(command.CallbackQueryId, "Ваш голос учтён. До дедлайна его можно изменить.", ct); + await messenger.AnswerInteractionAsync( + new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!), + ct); } - - private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) => - messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct); } diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs index 5f5cacd..38757c1 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs @@ -95,6 +95,68 @@ public sealed class TelegramPlatformMessenger( cancellationToken: ct); } + public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList actions, CancellationToken ct) + { + EnsureTelegram(group.Platform); + await bot.SendMessage( + chatId: ParseLong(group.ExternalGroupId), + messageThreadId: ParseNullableInt(group.ExternalThreadId), + text: htmlText, + parseMode: ParseMode.Html, + replyMarkup: BuildActionsMarkup(actions), + cancellationToken: ct); + } + + public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList actions, CancellationToken ct) + { + EnsureTelegram(messageRef.Platform); + await bot.EditMessageText( + chatId: ParseLong(messageRef.ExternalGroupId), + messageId: ParseInt(messageRef.ExternalMessageId), + text: htmlText, + parseMode: ParseMode.Html, + replyMarkup: BuildActionsMarkup(actions), + cancellationToken: ct); + } + + public async Task CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct) + { + EnsureTelegram(group.Platform); + var topic = await bot.CreateForumTopic( + chatId: ParseLong(group.ExternalGroupId), + name: title, + cancellationToken: ct); + + return new PlatformMessageRef( + PlatformKind.Telegram, + group.ExternalGroupId, + topic.MessageThreadId.ToString(CultureInfo.InvariantCulture), + string.Empty); + } + + public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) + { + EnsureTelegram(group.Platform); + if (string.IsNullOrWhiteSpace(group.ExternalThreadId)) + { + return Task.CompletedTask; + } + + return bot.DeleteForumTopic( + ParseLong(group.ExternalGroupId), + ParseInt(group.ExternalThreadId), + cancellationToken: ct); + } + + public Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct) + { + EnsureTelegram(messageRef.Platform); + return bot.DeleteMessage( + ParseLong(messageRef.ExternalGroupId), + ParseInt(messageRef.ExternalMessageId), + cancellationToken: ct); + } + public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) { EnsureTelegram(message.Recipient.Platform); diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs index 36520c6..d395fc0 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs @@ -5,6 +5,9 @@ using GmRelay.Shared.Features.Sessions.CreateSession; using GmRelay.Shared.Rendering; using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.ListSessions; +using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler; +using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler; +using BotRescheduleVoteHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler; using GmRelay.Bot.Features.Sessions.ExportCalendar; using GmRelay.Bot.Features.Sessions.RescheduleSession; using Telegram.Bot; @@ -20,7 +23,7 @@ namespace GmRelay.Bot.Infrastructure.Telegram; /// public sealed class UpdateRouter( HandleRsvpHandler rsvpHandler, - CreateSessionHandler createSessionHandler, + BotCreateSessionHandler createSessionHandler, JoinSessionHandler joinSessionHandler, LeaveSessionHandler leaveSessionHandler, PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler, @@ -29,8 +32,8 @@ public sealed class UpdateRouter( ListSessionsHandler listSessionsHandler, ExportCalendarHandler exportCalendarHandler, InitiateRescheduleHandler initiateRescheduleHandler, - HandleRescheduleTimeInputHandler rescheduleTimeInputHandler, - HandleRescheduleVoteHandler rescheduleVoteHandler, + BotRescheduleTimeInputHandler rescheduleTimeInputHandler, + BotRescheduleVoteHandler rescheduleVoteHandler, ITelegramBotClient bot, IConfiguration configuration, ILogger logger) : ITelegramUpdateHandler diff --git a/src/GmRelay.Bot/Program.cs b/src/GmRelay.Bot/Program.cs index b080907..b3b3d7a 100644 --- a/src/GmRelay.Bot/Program.cs +++ b/src/GmRelay.Bot/Program.cs @@ -66,7 +66,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -74,10 +75,13 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs index 5bf8885..7716ffa 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs @@ -1,114 +1,46 @@ -namespace GmRelay.DiscordBot.Features.Sessions; - -using Dapper; using GmRelay.DiscordBot.Rendering; -using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; -using Npgsql; using NetCord.Rest; +namespace GmRelay.DiscordBot.Features.Sessions; + public sealed record DiscordRescheduleVoteInput( - Guid OptionId, ulong UserId, string InteractionId, - string GuildId, string ChannelId, string MessageId); + Guid OptionId, + ulong UserId, + string InteractionId, + string GuildId, + string ChannelId, + string MessageId); public sealed class DiscordRescheduleVoteHandler( - NpgsqlDataSource dataSource, + GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler, RestClient restClient, ILogger logger) { public async Task HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct) { - await using var connection = await dataSource.OpenConnectionAsync(ct); - await using var transaction = await connection.BeginTransactionAsync(ct); + var command = new HandleRescheduleVoteCommand( + input.OptionId, + new PlatformUser(PlatformKind.Discord, input.UserId.ToString(), string.Empty, null), + new PlatformGroup(PlatformKind.Discord, input.GuildId, string.Empty, input.ChannelId), + input.InteractionId, + new PlatformMessageRef(PlatformKind.Discord, input.ChannelId, null, input.MessageId)); - // 1. Load proposal + option - var proposal = await connection.QuerySingleOrDefaultAsync( - """ - SELECT rp.id AS Id, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt, - s.title AS Title, s.scheduled_at AS CurrentScheduledAt - FROM reschedule_options ro - JOIN reschedule_proposals rp ON rp.id = ro.proposal_id - JOIN sessions s ON s.id = rp.session_id - WHERE ro.id = @OptionId AND rp.status = 'Voting' - """, - new { input.OptionId }, - transaction); + var result = await sharedHandler.HandleAsync(command, ct); - if (proposal is null) - return "Голосование уже завершено или не найдено."; + if (!result.Success) + { + return result.ReplyText!; + } - if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow) - return "Дедлайн уже прошёл. Результаты скоро будут применены."; - - // 2. Verify participant (Discord platform) - var playerId = await connection.ExecuteScalarAsync( - """ - SELECT p.id - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId - AND p.platform = 'Discord' - AND p.external_user_id = @UserId - AND sp.is_gm = false - AND sp.registration_status = @Active - """, - new { proposal.SessionId, UserId = input.UserId.ToString(), Active = ParticipantRegistrationStatus.Active }, - transaction); - - if (playerId is null) - return "Вы не являетесь участником этой сессии."; - - // 3. Upsert vote - await connection.ExecuteAsync( - """ - INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id) - VALUES (@ProposalId, @PlayerId, @OptionId) - ON CONFLICT (proposal_id, player_id) DO UPDATE - SET option_id = EXCLUDED.option_id, voted_at = now() - """, - new { ProposalId = proposal.Id, PlayerId = playerId.Value, input.OptionId }, - transaction); - - // 4. Reload participants, options, votes for re-rendering - var participants = (await connection.QueryAsync( - """ - SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId - FROM session_participants sp - JOIN players p ON p.id = sp.player_id - WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active - ORDER BY p.display_name - """, - new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, - transaction)).ToList(); - - var options = (await connection.QueryAsync( - """ - SELECT id AS OptionId, display_order AS DisplayOrder, proposed_at AS ProposedAt - FROM reschedule_options - WHERE proposal_id = @ProposalId - ORDER BY display_order - """, - new { ProposalId = proposal.Id }, - transaction)).ToList(); - - var votes = (await connection.QueryAsync( - """ - SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername - FROM reschedule_option_votes rov - JOIN players p ON p.id = rov.player_id - WHERE rov.proposal_id = @ProposalId - ORDER BY rov.voted_at, p.display_name - """, - new { ProposalId = proposal.Id }, - transaction)).ToList(); - - await transaction.CommitAsync(ct); - - // 5. Re-render and update Discord vote message var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( - proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt, - options, participants, votes); + result.Title!, + result.CurrentScheduledAt, + result.VotingDeadlineAt, + result.Options, + result.Participants, + result.Votes); var channelIdUlong = ulong.Parse(input.ChannelId); var messageIdUlong = ulong.Parse(input.MessageId); @@ -123,9 +55,9 @@ public sealed class DiscordRescheduleVoteHandler( } catch (Exception ex) { - logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id); + logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", result.ProposalId); } - return "Ваш голос учтён. До дедлайна его можно изменить."; + return result.ReplyText!; } } diff --git a/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs b/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs index e43b9bd..4734790 100644 --- a/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs +++ b/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs @@ -77,6 +77,38 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger await restClient.SendMessageAsync(GetChannelId(group), htmlText); } + public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList actions, CancellationToken ct) + { + var rows = BuildActionRows(actions); + await restClient.SendMessageAsync(GetChannelId(group), new MessageProperties().WithContent(htmlText).WithComponents(rows)); + } + + public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList actions, CancellationToken ct) + { + var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId)); + var messageId = ParseSnowflake(messageRef.ExternalMessageId); + var rows = BuildActionRows(actions); + await restClient.ModifyMessageAsync(channelId, messageId, options => + { + options.Content = htmlText; + options.Components = rows; + }); + } + + public Task CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct) + { + // Discord thread creation is not implemented in this adapter + return Task.FromResult(new PlatformMessageRef(PlatformKind.Discord, group.ExternalGroupId, group.ExternalThreadId, string.Empty)); + } + + public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) => Task.CompletedTask; + + public async Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct) + { + var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId)); + await restClient.DeleteMessageAsync(channelId, ParseSnowflake(messageRef.ExternalMessageId)); + } + public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) { await SendDirectContentAsync(message.Recipient, message.HtmlText, ct); @@ -403,6 +435,30 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger return ParseSnowflake(channelId); } + private static IReadOnlyList BuildActionRows(IReadOnlyList actions) + { + if (actions.Count == 0) + { + return []; + } + + var rows = new List(); + foreach (var chunk in actions.Chunk(5)) + { + var row = new ActionRowProperties(); + foreach (var action in chunk) + { + row.Add(new ButtonProperties(action.Key, action.Label, ButtonStyle.Secondary) + { + CustomId = action.Payload + }); + } + rows.Add(row); + } + + return rows; + } + private static ulong ParseSnowflake(string value) => ulong.Parse(value, CultureInfo.InvariantCulture); } diff --git a/src/GmRelay.DiscordBot/Program.cs b/src/GmRelay.DiscordBot/Program.cs index a9fcf68..28a00f4 100644 --- a/src/GmRelay.DiscordBot/Program.cs +++ b/src/GmRelay.DiscordBot/Program.cs @@ -59,6 +59,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs new file mode 100644 index 0000000..3f63c5e --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs @@ -0,0 +1,12 @@ +using GmRelay.Shared.Platform; + +namespace GmRelay.Shared.Features.Sessions.CreateSession; + +public sealed record CreateSessionCommand( + PlatformUser User, + PlatformGroup Group, + string Title, + string Link, + IReadOnlyList ScheduledTimes, + int? MaxPlayers, + string? ImageReference); diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs new file mode 100644 index 0000000..4c96744 --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -0,0 +1,157 @@ +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Platform; +using GmRelay.Shared.Rendering; +using Npgsql; + +namespace GmRelay.Shared.Features.Sessions.CreateSession; + +internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage); + +public sealed class CreateSessionHandler( + NpgsqlDataSource dataSource) +{ + public async Task HandleAsync(CreateSessionCommand command, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + + try + { + var platform = command.User.Platform.ToString(); + var externalUserId = command.User.ExternalUserId; + var displayName = command.User.DisplayName; + var externalUsername = command.User.ExternalUsername; + + await connection.ExecuteAsync( + """ + INSERT INTO players (display_name, platform, external_user_id, external_username) + VALUES (@Name, @Platform, @ExternalId, @Username) + ON CONFLICT (platform, external_user_id) + WHERE platform IS NOT NULL AND external_user_id IS NOT NULL + DO UPDATE + SET display_name = EXCLUDED.display_name, + external_username = EXCLUDED.external_username; + """, + new { ExternalId = externalUserId, Name = displayName, Username = externalUsername }, + transaction); + + var existingGroup = await connection.QuerySingleOrDefaultAsync( + """ + SELECT g.id AS GroupId, + EXISTS ( + SELECT 1 + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + WHERE gm.group_id = g.id + AND p.platform = @Platform + AND p.external_user_id = @ExternalGmId + ) AS CanManage + FROM game_groups g + WHERE g.platform = @Platform + AND g.external_group_id = @ExternalGroupId + """, + new { Platform = platform, ExternalGroupId = command.Group.ExternalGroupId, ExternalGmId = externalUserId }, + transaction); + + Guid groupId; + if (existingGroup is null) + { + groupId = await connection.ExecuteScalarAsync( + """ + INSERT INTO game_groups (name, platform, external_group_id, external_channel_id) + VALUES (@ChatName, @Platform, @ExternalGroupId, @ExternalChannelId) + RETURNING id; + """, + new + { + Platform = platform, + ExternalGroupId = command.Group.ExternalGroupId, + ExternalChannelId = command.Group.ExternalChannelId, + ChatName = command.Group.DisplayName + }, + transaction); + + await connection.ExecuteAsync( + """ + INSERT INTO group_managers (group_id, player_id, role) + SELECT @GroupId, p.id, @OwnerRole + FROM players p + WHERE p.platform = @Platform + AND p.external_user_id = @ExternalGmId + ON CONFLICT (group_id, player_id) DO NOTHING + """, + new { GroupId = groupId, ExternalGmId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }, + transaction); + } + else + { + if (!existingGroup.CanManage) + { + await transaction.RollbackAsync(ct); + return new CreateSessionResult( + false, + "⛔ Только owner или co-GM этой группы может создавать игровые сессии.", + null, + null, + null, + Array.Empty()); + } + + groupId = existingGroup.GroupId; + await connection.ExecuteAsync( + """ + UPDATE game_groups + SET name = @ChatName + WHERE id = @GroupId + """, + new { ChatName = command.Group.DisplayName, GroupId = groupId }, + transaction); + } + + var batchId = Guid.NewGuid(); + var sessions = new List(); + var orderedTimes = command.ScheduledTimes.OrderBy(v => v).ToList(); + + foreach (var scheduledAt in orderedTimes) + { + var sessionId = await connection.ExecuteScalarAsync( + """ + INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players) + VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers) + RETURNING id; + """, + new + { + BatchId = batchId, + GroupId = groupId, + command.Title, + Link = command.Link, + ScheduledAt = scheduledAt, + Status = SessionStatus.Planned, + MaxPlayers = command.MaxPlayers + }, + transaction); + + sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, command.MaxPlayers, command.Link)); + } + + await transaction.CommitAsync(ct); + + var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty()); + + return new CreateSessionResult( + true, + null, + view, + batchId, + groupId, + Array.Empty()); + } + catch + { + await transaction.RollbackAsync(ct); + throw; + } + } +} diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionResult.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionResult.cs new file mode 100644 index 0000000..c3eb89a --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionResult.cs @@ -0,0 +1,11 @@ +using GmRelay.Shared.Rendering; + +namespace GmRelay.Shared.Features.Sessions.CreateSession; + +public sealed record CreateSessionResult( + bool Success, + string? ErrorMessage, + SessionBatchViewModel? View, + Guid? BatchId, + Guid? GroupId, + IReadOnlyList Warnings); diff --git a/src/GmRelay.Shared/Features/Sessions/ExportCalendar/ExportCalendarCommand.cs b/src/GmRelay.Shared/Features/Sessions/ExportCalendar/ExportCalendarCommand.cs new file mode 100644 index 0000000..2d7c7d7 --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/ExportCalendar/ExportCalendarCommand.cs @@ -0,0 +1,7 @@ +using GmRelay.Shared.Platform; + +namespace GmRelay.Shared.Features.Sessions.ExportCalendar; + +public sealed record ExportCalendarCommand( + PlatformGroup Group, + PlatformUser User); diff --git a/src/GmRelay.Shared/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs b/src/GmRelay.Shared/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs new file mode 100644 index 0000000..b228cac --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs @@ -0,0 +1,111 @@ +using System.Text; +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Platform; +using Microsoft.Extensions.Configuration; +using Npgsql; + +namespace GmRelay.Shared.Features.Sessions.ExportCalendar; + +internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt); + +public sealed class ExportCalendarHandler( + NpgsqlDataSource dataSource, + IPlatformMessenger messenger, + IConfiguration configuration) +{ + public async Task HandleAsync(ExportCalendarCommand command, CancellationToken cancellationToken) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sessions = await connection.QueryAsync( + @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt" + + " FROM sessions s" + + " JOIN game_groups g ON s.group_id = g.id" + + " WHERE g.platform = @Platform" + + " AND g.external_group_id = @ExternalGroupId" + + " AND s.status = @Planned" + + " AND s.scheduled_at > NOW()" + + " ORDER BY s.scheduled_at ASC", + new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId, Planned = SessionStatus.Planned }); + + var sessionsList = sessions.ToList(); + + if (sessionsList.Count == 0) + { + await messenger.SendGroupMessageAsync( + command.Group, + "📭 У этой группы нет запланированных сессий для экспорта.", + cancellationToken); + return; + } + + var sb = new StringBuilder(); + sb.AppendLine("BEGIN:VCALENDAR"); + sb.AppendLine("VERSION:2.0"); + sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN"); + + foreach (var s in sessionsList) + { + var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ"); + var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ"); + + sb.AppendLine("BEGIN:VEVENT"); + sb.AppendLine($"UID:{s.Id}@gmrelay"); + sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}"); + sb.AppendLine($"DTSTART:{dtStart}"); + sb.AppendLine($"DTEND:{dtEnd}"); + sb.AppendLine($"SUMMARY:{s.Title}"); + sb.AppendLine("END:VEVENT"); + } + + sb.AppendLine("END:VCALENDAR"); + + var bytes = Encoding.UTF8.GetBytes(sb.ToString()); + + // Create calendar subscription + string? subscriptionUrl = null; + var baseUrl = configuration["Web:BaseUrl"]; + var senderId = command.User.ExternalUserId; + if (!string.IsNullOrWhiteSpace(baseUrl) && !string.IsNullOrWhiteSpace(senderId)) + { + try + { + var token = Guid.NewGuid().ToString("N"); + var groupId = await connection.QueryFirstOrDefaultAsync( + @"SELECT id FROM game_groups WHERE platform = @Platform AND external_group_id = @ExternalGroupId", + new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId }); + + await connection.ExecuteAsync( + @"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at) + VALUES (gen_random_uuid(), @token, @userPlatform, @userExternalId, @groupId, @filterType, now(), NULL)", + new { token, userPlatform = command.Group.Platform.ToString(), userExternalId = senderId, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup }); + + subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics"; + } + catch + { + // Non-critical: if subscription creation fails, still send the file + } + } + + var actions = subscriptionUrl is not null + ? new[] + { + new PlatformMessageAction( + "calendar-subscription", + "🔗 Подписаться на календарь", + subscriptionUrl) + } + : Array.Empty(); + + await messenger.SendCalendarFileAsync( + new PlatformCalendarFile( + command.Group, + "schedule.ics", + bytes, + "📅 Ваш календарь игр!\nОткройте файл на устройстве, чтобы добавить события в свой календарь.", + actions), + cancellationToken); + } +} diff --git a/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs new file mode 100644 index 0000000..94e0bdb --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs @@ -0,0 +1,91 @@ +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Platform; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace GmRelay.Shared.Features.Sessions.ListSessions; + +internal sealed record DeleteSessionInfoDto( + string Title, + Guid BatchId, + Guid GroupId, + bool CanManage, + int? ThreadId, + bool TopicCreatedByBot); + +public sealed record DeleteSessionResult( + bool Success, + string? ReplyText, + string? Title, + Guid? GroupId, + int? ThreadId, + bool TopicCreatedByBot, + int RemainingInTopic); + +public sealed class DeleteSessionHandler( + NpgsqlDataSource dataSource) +{ + public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + + // 1. Fetch session and verify group manager. + var session = await connection.QuerySingleOrDefaultAsync( + """ + SELECT s.title AS Title, + s.batch_id AS BatchId, + s.group_id AS GroupId, + s.thread_id AS ThreadId, + s.topic_created_by_bot AS TopicCreatedByBot, + EXISTS ( + SELECT 1 + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + WHERE gm.group_id = s.group_id + AND p.platform = @Platform + AND p.external_user_id = @ExternalUserId + ) AS CanManage + FROM sessions s + WHERE s.id = @SessionId + """, + new { command.SessionId, Platform = command.User.Platform.ToString(), ExternalUserId = command.User.ExternalUserId }, transaction); + + if (session == null) + { + return new DeleteSessionResult(false, "Сессия не найдена.", null, null, null, false, 0); + } + + if (!session.CanManage) + { + return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0); + } + + // 2. Delete session + await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction); + + var remainingInTopic = session.ThreadId.HasValue + ? await connection.ExecuteScalarAsync( + """ + SELECT COUNT(*) + FROM sessions + WHERE group_id = @GroupId + AND thread_id = @ThreadId + """, + new { session.GroupId, ThreadId = session.ThreadId.Value }, + transaction) + : 0; + + await transaction.CommitAsync(ct); + + return new DeleteSessionResult( + true, + "Сессия удалена!", + session.Title, + session.GroupId, + session.ThreadId, + session.TopicCreatedByBot, + remainingInTopic); + } +} diff --git a/src/GmRelay.Shared/Features/Sessions/ListSessions/ListSessionsCommand.cs b/src/GmRelay.Shared/Features/Sessions/ListSessions/ListSessionsCommand.cs new file mode 100644 index 0000000..c57cf9c --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/ListSessions/ListSessionsCommand.cs @@ -0,0 +1,13 @@ +using GmRelay.Shared.Platform; + +namespace GmRelay.Shared.Features.Sessions.ListSessions; + +public sealed record ListSessionsCommand( + PlatformGroup Group, + PlatformUser User); + +public sealed record DeleteSessionCommand( + Guid SessionId, + PlatformUser User, + PlatformGroup Group, + PlatformMessageRef ScheduleMessage); diff --git a/src/GmRelay.Shared/Features/Sessions/ListSessions/ListSessionsHandler.cs b/src/GmRelay.Shared/Features/Sessions/ListSessions/ListSessionsHandler.cs new file mode 100644 index 0000000..25d047a --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/ListSessions/ListSessionsHandler.cs @@ -0,0 +1,57 @@ +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Platform; +using Npgsql; + +namespace GmRelay.Shared.Features.Sessions.ListSessions; + +public sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage); + +public sealed record SessionListResult( + IReadOnlyList Sessions, + bool CanManage); + +public sealed class ListSessionsHandler( + NpgsqlDataSource dataSource) +{ + public async Task HandleAsync(ListSessionsCommand command, CancellationToken cancellationToken) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sessions = await connection.QueryAsync( + @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers, + COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount, + COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount, + EXISTS ( + SELECT 1 + FROM group_managers gm + JOIN players manager_player ON manager_player.id = gm.player_id + WHERE gm.group_id = s.group_id + AND manager_player.platform = @Platform + AND manager_player.external_user_id = @ExternalUserId + ) AS CanManage + FROM sessions s + JOIN game_groups g ON s.group_id = g.id + LEFT JOIN session_participants sp ON s.id = sp.session_id + WHERE g.platform = @Platform + AND g.external_group_id = @ExternalGroupId + AND s.status != @Cancelled + AND s.scheduled_at > NOW() + GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id + ORDER BY s.scheduled_at ASC", + new + { + Platform = command.Group.Platform.ToString(), + ExternalGroupId = command.Group.ExternalGroupId, + ExternalUserId = command.User.ExternalUserId, + Cancelled = SessionStatus.Cancelled, + Active = ParticipantRegistrationStatus.Active, + Waitlisted = ParticipantRegistrationStatus.Waitlisted + }); + + var sessionsList = sessions.ToList(); + var canManage = sessionsList.Count > 0 && sessionsList.First().CanManage; + + return new SessionListResult(sessionsList, canManage); + } +} diff --git a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/AwaitingProposalDto.cs b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/AwaitingProposalDto.cs new file mode 100644 index 0000000..e41459d --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/AwaitingProposalDto.cs @@ -0,0 +1,12 @@ +namespace GmRelay.Shared.Features.Sessions.RescheduleSession; + +internal sealed record AwaitingProposalDto( + Guid Id, + Guid SessionId, + string Title, + DateTime CurrentScheduledAt, + Guid BatchId, + int? BatchMessageId, + string ExternalGroupId, + int? ThreadId, + string NotificationMode); diff --git a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputCommand.cs b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputCommand.cs new file mode 100644 index 0000000..786528b --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputCommand.cs @@ -0,0 +1,15 @@ +using GmRelay.Shared.Platform; + +namespace GmRelay.Shared.Features.Sessions.RescheduleSession; + +public sealed record HandleRescheduleTimeInputCommand( + PlatformUser User, + PlatformGroup Group, + string Text); + +public sealed record HandleRescheduleVoteCommand( + Guid OptionId, + PlatformUser User, + PlatformGroup Group, + string InteractionId, + PlatformMessageRef ScheduleMessage); diff --git a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs new file mode 100644 index 0000000..b8effad --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs @@ -0,0 +1,181 @@ +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Platform; +using GmRelay.Shared.Rendering; +using Npgsql; + +namespace GmRelay.Shared.Features.Sessions.RescheduleSession; + +public sealed class HandleRescheduleTimeInputHandler( + NpgsqlDataSource dataSource) +{ + public async Task HandleAsync( + HandleRescheduleTimeInputCommand command, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var platform = command.User.Platform.ToString(); + var externalGmId = command.User.ExternalUserId; + var externalGroupId = command.Group.ExternalGroupId; + + var proposal = await connection.QuerySingleOrDefaultAsync( + """ + SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt, + s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, + g.external_group_id AS ExternalGroupId, + s.thread_id AS ThreadId, + s.notification_mode AS NotificationMode + FROM reschedule_proposals rp + JOIN sessions s ON s.id = rp.session_id + JOIN game_groups g ON g.id = s.group_id + WHERE rp.proposed_by_external_user_id = @ExternalGmId + AND rp.status = 'AwaitingTime' + AND g.platform = @Platform + AND g.external_group_id = @ExternalGroupId + AND EXISTS ( + SELECT 1 + FROM group_managers gm + JOIN players manager_player ON manager_player.id = gm.player_id + WHERE gm.group_id = s.group_id + AND manager_player.platform = @Platform + AND manager_player.external_user_id = @ExternalGmId + ) + ORDER BY rp.created_at DESC + LIMIT 1 + """, + new { ExternalGmId = externalGmId, Platform = platform, ExternalGroupId = externalGroupId }); + + if (proposal is null) + return new HandleRescheduleTimeInputResult(false, false, null, null, null, null, [], [], [], null, default, null); + + if (!RescheduleVotingInput.TryParse(command.Text, DateTimeOffset.UtcNow, out var votingInput, out var parseError)) + { + return new HandleRescheduleTimeInputResult( + true, false, parseError, null, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, null); + } + + var participants = (await connection.QueryAsync( + """ + SELECT p.id AS PlayerId, + p.display_name AS DisplayName, + p.external_username AS TelegramUsername, + p.external_user_id::BIGINT AS TelegramId + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + AND sp.is_gm = false + AND sp.registration_status = @Active + """, + new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); + + if (participants.Count == 0) + { + var newTime = votingInput.Options[0]; + var view = await RescheduleImmediatelyAsync(connection, proposal, newTime, ct); + var replyText = + $"""✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: {newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))} (МСК)\n\nУчастников нет — голосование не требуется."""; + return new HandleRescheduleTimeInputResult( + true, true, replyText, view, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, proposal.BatchMessageId); + } + + await using var transaction = await connection.BeginTransactionAsync(ct); + var options = votingInput.Options + .Select((proposedAt, index) => new RescheduleOptionDto( + Guid.NewGuid(), + index + 1, + proposedAt)) + .ToList(); + + await connection.ExecuteAsync( + """ + UPDATE reschedule_proposals + SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @VoteChatId + WHERE id = @Id + """, + new { votingInput.Deadline, VoteChatId = externalGroupId, Id = proposal.Id }, + transaction); + + foreach (var option in options) + { + await connection.ExecuteAsync( + """ + INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order) + VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder) + """, + new + { + option.OptionId, + ProposalId = proposal.Id, + option.ProposedAt, + option.DisplayOrder + }, + transaction); + } + + await transaction.CommitAsync(ct); + + return new HandleRescheduleTimeInputResult( + true, + false, + null, + null, + proposal.Id, + votingInput.Deadline, + options, + participants, + [], + proposal.Title, + proposal.CurrentScheduledAt, + null); + } + + private static async Task RescheduleImmediatelyAsync( + NpgsqlConnection connection, + AwaitingProposalDto proposal, + DateTimeOffset newTime, + CancellationToken ct) + { + await using var transaction = await connection.BeginTransactionAsync(ct); + + await connection.ExecuteAsync( + """ + UPDATE sessions + SET scheduled_at = @NewTime, + status = @Status, + confirmation_message_id = NULL, + confirmation_sent_at = NULL, + one_hour_reminder_processed_at = NULL, + updated_at = now() + WHERE id = @SessionId + """, + new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned }, + transaction); + + await connection.ExecuteAsync( + "UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id", + new { NewTime = newTime, Id = proposal.Id }, + transaction); + + await transaction.CommitAsync(ct); + + var batchSessions = (await connection.QueryAsync( + "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", + new { proposal.BatchId })).ToList(); + + var batchParticipants = (await connection.QueryAsync( + """ + SELECT sp.session_id AS SessionId, + p.display_name AS DisplayName, + p.external_username AS TelegramUsername, + sp.registration_status AS RegistrationStatus + FROM session_participants sp + JOIN players p ON sp.player_id = p.id + JOIN sessions s ON sp.session_id = s.id + WHERE s.batch_id = @BatchId AND sp.is_gm = false + ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC + """, + new { proposal.BatchId })).ToList(); + + return SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants); + } +} diff --git a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputResult.cs b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputResult.cs new file mode 100644 index 0000000..025b220 --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputResult.cs @@ -0,0 +1,17 @@ +using GmRelay.Shared.Rendering; + +namespace GmRelay.Shared.Features.Sessions.RescheduleSession; + +public sealed record HandleRescheduleTimeInputResult( + bool Handled, + bool IsRescheduledImmediately, + string? ReplyText, + SessionBatchViewModel? UpdatedView, + Guid? ProposalId, + DateTimeOffset? VotingDeadlineAt, + IReadOnlyList Options, + IReadOnlyList Participants, + IReadOnlyList Votes, + string? Title, + DateTime CurrentScheduledAt, + int? BatchMessageId); diff --git a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs new file mode 100644 index 0000000..ab318bf --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs @@ -0,0 +1,156 @@ +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Platform; +using Npgsql; + +namespace GmRelay.Shared.Features.Sessions.RescheduleSession; + +public sealed class HandleRescheduleVoteHandler( + NpgsqlDataSource dataSource) +{ + public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + + var proposal = await connection.QuerySingleOrDefaultAsync( + """ + SELECT rp.id AS Id, + rp.session_id AS SessionId, + rp.voting_deadline_at AS VotingDeadlineAt, + s.title AS Title, + s.scheduled_at AS CurrentScheduledAt + FROM reschedule_options ro + JOIN reschedule_proposals rp ON rp.id = ro.proposal_id + JOIN sessions s ON s.id = rp.session_id + WHERE ro.id = @OptionId AND rp.status = 'Voting' + """, + new { command.OptionId }, + transaction); + + if (proposal is null) + { + return new HandleRescheduleVoteResult( + false, + "Голосование уже завершено или не найдено.", + null, null, null, default, default, + Array.Empty(), + Array.Empty(), + Array.Empty()); + } + + if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow) + { + return new HandleRescheduleVoteResult( + false, + "Дедлайн уже прошёл. Результаты скоро будут применены.", + null, null, null, default, default, + Array.Empty(), + Array.Empty(), + Array.Empty()); + } + + var playerId = await connection.ExecuteScalarAsync( + """ + SELECT p.id + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + AND p.platform = @Platform + AND p.external_user_id = @ExternalUserId + AND sp.is_gm = false + AND sp.registration_status = @Active + """, + new + { + proposal.SessionId, + Platform = command.User.Platform.ToString(), + ExternalUserId = command.User.ExternalUserId, + Active = ParticipantRegistrationStatus.Active + }, + transaction); + + if (playerId is null) + { + return new HandleRescheduleVoteResult( + false, + "Вы не являетесь участником этой сессии.", + null, null, null, default, default, + Array.Empty(), + Array.Empty(), + Array.Empty()); + } + + await connection.ExecuteAsync( + """ + INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id) + VALUES (@ProposalId, @PlayerId, @OptionId) + ON CONFLICT (proposal_id, player_id) DO UPDATE + SET option_id = EXCLUDED.option_id, + voted_at = now() + """, + new + { + ProposalId = proposal.Id, + PlayerId = playerId.Value, + command.OptionId + }, + transaction); + + var participants = (await connection.QueryAsync( + """ + SELECT p.id AS PlayerId, + p.display_name AS DisplayName, + p.external_username AS TelegramUsername, + p.external_user_id AS TelegramId + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId + AND sp.is_gm = false + AND sp.registration_status = @Active + ORDER BY p.display_name + """, + new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, + transaction)).ToList(); + + var options = (await connection.QueryAsync( + """ + SELECT id AS OptionId, + display_order AS DisplayOrder, + proposed_at AS ProposedAt + FROM reschedule_options + WHERE proposal_id = @ProposalId + ORDER BY display_order + """, + new { ProposalId = proposal.Id }, + transaction)).ToList(); + + var votes = (await connection.QueryAsync( + """ + SELECT rov.option_id AS OptionId, + p.id AS PlayerId, + p.display_name AS DisplayName, + p.external_username AS TelegramUsername + FROM reschedule_option_votes rov + JOIN players p ON p.id = rov.player_id + WHERE rov.proposal_id = @ProposalId + ORDER BY rov.voted_at, p.display_name + """, + new { ProposalId = proposal.Id }, + transaction)).ToList(); + + await transaction.CommitAsync(ct); + + return new HandleRescheduleVoteResult( + true, + "Ваш голос учтён. До дедлайна его можно изменить.", + proposal.Id, + proposal.SessionId, + proposal.Title, + proposal.CurrentScheduledAt, + proposal.VotingDeadlineAt, + participants, + options, + votes); + } +} diff --git a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleVoteResult.cs b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleVoteResult.cs new file mode 100644 index 0000000..4040142 --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleVoteResult.cs @@ -0,0 +1,15 @@ +using GmRelay.Shared.Features.Sessions.RescheduleSession; + +namespace GmRelay.Shared.Features.Sessions.RescheduleSession; + +public sealed record HandleRescheduleVoteResult( + bool Success, + string? ReplyText, + Guid? ProposalId, + Guid? SessionId, + string? Title, + DateTime CurrentScheduledAt, + DateTimeOffset VotingDeadlineAt, + IReadOnlyList Participants, + IReadOnlyList Options, + IReadOnlyList Votes); diff --git a/src/GmRelay.Shared/Platform/IPlatformMessenger.cs b/src/GmRelay.Shared/Platform/IPlatformMessenger.cs index 1023b21..72b2a93 100644 --- a/src/GmRelay.Shared/Platform/IPlatformMessenger.cs +++ b/src/GmRelay.Shared/Platform/IPlatformMessenger.cs @@ -2,7 +2,23 @@ namespace GmRelay.Shared.Platform; public interface IPlatformMessenger { - Task SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct); + Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList actions, CancellationToken ct) => + throw new NotSupportedException("This platform messenger does not support messages with actions."); + + Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList actions, CancellationToken ct) => + throw new NotSupportedException("This platform messenger does not support message updates with actions."); + + Task CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct) => + throw new NotSupportedException("This platform messenger does not support thread creation."); + + Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) => + throw new NotSupportedException("This platform messenger does not support thread deletion."); + + Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct) => + throw new NotSupportedException("This platform messenger does not support message deletion."); + + Task SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) => + throw new NotSupportedException("This platform messenger does not support schedule messages."); Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct); diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 1e0eed4..106c43a 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -73,7 +73,7 @@ - + diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs index cf48fc2..ff46a86 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs @@ -62,7 +62,7 @@ public sealed class DiscordProjectStructureTests var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml")); var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")); - Assert.Contains("gmrelay-discord-bot:3.1.1", compose); + Assert.Contains("gmrelay-discord-bot:3.2.0", compose); Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose); Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy); Assert.Contains("DISCORD_BOT_TOKEN", deploy); @@ -76,13 +76,13 @@ public sealed class DiscordProjectStructureTests { var repoRoot = GetRepoRoot(); - Assert.Contains("3.1.1", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); - Assert.Contains("VERSION: 3.1.1", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); - Assert.Contains("gmrelay-bot:3.1.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-web:3.1.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-discord-bot:3.1.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("3.2.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); + Assert.Contains("VERSION: 3.2.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); + Assert.Contains("gmrelay-bot:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-web:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-discord-bot:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains( - "v3.1.1", + "v3.2.0", File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor"))); } diff --git a/tests/GmRelay.Bot.Tests/Features/Landing/DiscordLandingPromisesSmokeTests.cs b/tests/GmRelay.Bot.Tests/Features/Landing/DiscordLandingPromisesSmokeTests.cs index 8238766..b245c58 100644 --- a/tests/GmRelay.Bot.Tests/Features/Landing/DiscordLandingPromisesSmokeTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Landing/DiscordLandingPromisesSmokeTests.cs @@ -1,5 +1,5 @@ using GmRelay.Bot.Features.Sessions.CreateSession; -using GmRelay.Bot.Features.Sessions.RescheduleSession; +using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler; using GmRelay.DiscordBot.Rendering; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; @@ -69,14 +69,14 @@ public sealed class DiscordLandingPromisesSmokeTests }; var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero); var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId); - var voteMessage = HandleRescheduleTimeInputHandler.BuildVotingMessage( + var voteMessage = BotRescheduleHandler.BuildVotingMessage( scenario.Title, scenario.Sessions[0].ScheduledAt, deadline, options, voteParticipants, []); - var voteKeyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options); + var voteKeyboard = BotRescheduleHandler.BuildVotingKeyboard(options); Assert.Contains("Landing Promise Smoke", voteMessage); Assert.Contains("0/2", voteMessage); diff --git a/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs b/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs index bfe3801..76b4df8 100644 --- a/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs @@ -1,5 +1,5 @@ using GmRelay.Bot.Features.Sessions.CreateSession; -using GmRelay.Bot.Features.Sessions.RescheduleSession; +using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Rendering; @@ -65,14 +65,14 @@ public sealed class TelegramLandingPromisesSmokeTests }; var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero); var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId); - var voteMessage = HandleRescheduleTimeInputHandler.BuildVotingMessage( + var voteMessage = BotRescheduleHandler.BuildVotingMessage( scenario.Title, scenario.Sessions[0].ScheduledAt, deadline, options, voteParticipants, []); - var voteKeyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options); + var voteKeyboard = BotRescheduleHandler.BuildVotingKeyboard(options); Assert.Contains("Landing Promise Smoke", voteMessage); Assert.Contains("0/2", voteMessage); diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionCommandContractTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionCommandContractTests.cs new file mode 100644 index 0000000..0e5d53b --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionCommandContractTests.cs @@ -0,0 +1,36 @@ +using GmRelay.Shared.Features.Sessions.CreateSession; +using GmRelay.Shared.Platform; + +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession; + +public sealed class CreateSessionCommandContractTests +{ + [Fact] + public void CreateSessionCommand_ShouldExposePlatformNeutralContext() + { + AssertProperty("User", typeof(PlatformUser)); + AssertProperty("Group", typeof(PlatformGroup)); + AssertProperty("Title", typeof(string)); + AssertProperty("Link", typeof(string)); + AssertProperty("ScheduledTimes", typeof(IReadOnlyList)); + AssertProperty("MaxPlayers", typeof(int?)); + AssertProperty("ImageReference", typeof(string)); + AssertNoTelegramSpecificProperties(); + } + + private static void AssertProperty(string name, Type expectedType) + { + var property = Assert.Single(typeof(T).GetProperties(), p => p.Name == name); + Assert.Equal(expectedType, property.PropertyType); + } + + private static void AssertNoTelegramSpecificProperties() + { + var names = typeof(T).GetProperties().Select(p => p.Name).ToArray(); + Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal)); + Assert.DoesNotContain("ChatId", names); + Assert.DoesNotContain("MessageId", names); + Assert.DoesNotContain("TelegramUserId", names); + Assert.DoesNotContain("TelegramUsername", names); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerTests.cs new file mode 100644 index 0000000..89bdc97 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerTests.cs @@ -0,0 +1,36 @@ +namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession; + +public sealed class CreateSessionHandlerTests +{ + [Fact] + public async Task SharedHandler_ShouldExist_AndBePlatformNeutral() + { + var handler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs"); + + Assert.Contains("CreateSessionCommand", handler, StringComparison.Ordinal); + Assert.Contains("CreateSessionResult", handler, StringComparison.Ordinal); + Assert.Contains("command.User", handler, StringComparison.Ordinal); + Assert.Contains("command.Group", handler, StringComparison.Ordinal); + Assert.DoesNotContain("ITelegramBotClient", handler, StringComparison.Ordinal); + Assert.DoesNotContain("Telegram.Bot", handler, StringComparison.Ordinal); + Assert.DoesNotContain("InlineKeyboardMarkup", handler, StringComparison.Ordinal); + Assert.DoesNotContain("MessageThreadId", handler, StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/ExportCalendar/ExportCalendarCommandContractTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/ExportCalendar/ExportCalendarCommandContractTests.cs new file mode 100644 index 0000000..44f2487 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/ExportCalendar/ExportCalendarCommandContractTests.cs @@ -0,0 +1,31 @@ +using GmRelay.Shared.Features.Sessions.ExportCalendar; +using GmRelay.Shared.Platform; + +namespace GmRelay.Bot.Tests.Features.Sessions.ExportCalendar; + +public sealed class ExportCalendarCommandContractTests +{ + [Fact] + public void ExportCalendarCommand_ShouldExposePlatformNeutralContext() + { + AssertProperty("Group", typeof(PlatformGroup)); + AssertProperty("User", typeof(PlatformUser)); + AssertNoTelegramSpecificProperties(); + } + + private static void AssertProperty(string name, Type expectedType) + { + var property = Assert.Single(typeof(T).GetProperties(), p => p.Name == name); + Assert.Equal(expectedType, property.PropertyType); + } + + private static void AssertNoTelegramSpecificProperties() + { + var names = typeof(T).GetProperties().Select(p => p.Name).ToArray(); + Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal)); + Assert.DoesNotContain("ChatId", names); + Assert.DoesNotContain("MessageId", names); + Assert.DoesNotContain("TelegramUserId", names); + Assert.DoesNotContain("TelegramUsername", names); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/ListSessions/ListSessionsCommandContractTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/ListSessions/ListSessionsCommandContractTests.cs new file mode 100644 index 0000000..4ad40f0 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/ListSessions/ListSessionsCommandContractTests.cs @@ -0,0 +1,41 @@ +using GmRelay.Shared.Features.Sessions.ListSessions; +using GmRelay.Shared.Platform; + +namespace GmRelay.Bot.Tests.Features.Sessions.ListSessions; + +public sealed class ListSessionsCommandContractTests +{ + [Fact] + public void ListSessionsCommand_ShouldExposePlatformNeutralContext() + { + AssertProperty("Group", typeof(PlatformGroup)); + AssertProperty("User", typeof(PlatformUser)); + AssertNoTelegramSpecificProperties(); + } + + [Fact] + public void DeleteSessionCommand_ShouldExposePlatformNeutralContext() + { + AssertProperty("SessionId", typeof(Guid)); + AssertProperty("User", typeof(PlatformUser)); + AssertProperty("Group", typeof(PlatformGroup)); + AssertProperty("ScheduleMessage", typeof(PlatformMessageRef)); + AssertNoTelegramSpecificProperties(); + } + + private static void AssertProperty(string name, Type expectedType) + { + var property = Assert.Single(typeof(T).GetProperties(), p => p.Name == name); + Assert.Equal(expectedType, property.PropertyType); + } + + private static void AssertNoTelegramSpecificProperties() + { + var names = typeof(T).GetProperties().Select(p => p.Name).ToArray(); + Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal)); + Assert.DoesNotContain("ChatId", names); + Assert.DoesNotContain("MessageId", names); + Assert.DoesNotContain("TelegramUserId", names); + Assert.DoesNotContain("TelegramUsername", names); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/ListSessions/SessionListMessageRendererTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/ListSessions/SessionListMessageRendererTests.cs index 79ee6e6..d0a3d8d 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/ListSessions/SessionListMessageRendererTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/ListSessions/SessionListMessageRendererTests.cs @@ -1,5 +1,6 @@ using GmRelay.Bot.Features.Sessions.ListSessions; using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.ListSessions; namespace GmRelay.Bot.Tests.Features.Sessions.ListSessions; @@ -22,17 +23,15 @@ public sealed class SessionListMessageRendererTests true) }; - var result = SessionListMessageRenderer.Render(sessions); - Assert.NotNull(result.Markup); - var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList(); + var text = SessionListMessageRenderer.RenderText(sessions); + var actions = SessionListMessageRenderer.RenderActions(sessions); - Assert.Contains("Ravenloft", result.Text); - Assert.Collection( - buttons.Select(button => button.CallbackData), - callbackData => Assert.Equal($"cancel_session:{sessionId}", callbackData), - callbackData => Assert.Equal($"reschedule_session:{sessionId}", callbackData), - callbackData => Assert.Equal($"promote_waitlist:{sessionId}", callbackData), - callbackData => Assert.Equal($"delete_session:{sessionId}", callbackData)); + Assert.Contains("Ravenloft", text); + Assert.Equal(4, actions.Count); + Assert.Contains(actions, a => a.Payload == $"cancel_session:{sessionId}"); + Assert.Contains(actions, a => a.Payload == $"reschedule_session:{sessionId}"); + Assert.Contains(actions, a => a.Payload == $"promote_waitlist:{sessionId}"); + Assert.Contains(actions, a => a.Payload == $"delete_session:{sessionId}"); } [Fact] @@ -51,8 +50,7 @@ public sealed class SessionListMessageRendererTests false) }; - var result = SessionListMessageRenderer.Render(sessions); - - Assert.Null(result.Markup); + var actions = SessionListMessageRenderer.RenderActions(sessions); + Assert.Empty(actions); } } diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandlerTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandlerTests.cs index 28fb875..4551cba 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandlerTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandlerTests.cs @@ -1,4 +1,4 @@ -using GmRelay.Bot.Features.Sessions.RescheduleSession; +using BotHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler; using GmRelay.Shared.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession; @@ -72,7 +72,7 @@ public sealed class HandleRescheduleTimeInputHandlerTests new(secondOptionId, bobId, "Bob", null) }; - var text = HandleRescheduleTimeInputHandler.BuildVotingMessage( + var text = BotHandler.BuildVotingMessage( "Shadowrun", currentTime, deadline, @@ -101,7 +101,7 @@ public sealed class HandleRescheduleTimeInputHandlerTests new(secondOptionId, 2, new DateTimeOffset(2026, 4, 27, 17, 0, 0, TimeSpan.Zero)) }; - var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options); + var keyboard = BotHandler.BuildVotingKeyboard(options); var buttons = keyboard.InlineKeyboard.SelectMany(row => row).ToList(); Assert.Collection( diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/RescheduleCommandContractTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/RescheduleCommandContractTests.cs new file mode 100644 index 0000000..47faa92 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/RescheduleCommandContractTests.cs @@ -0,0 +1,43 @@ +using GmRelay.Shared.Features.Sessions.RescheduleSession; +using GmRelay.Shared.Platform; + +namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession; + +public sealed class RescheduleCommandContractTests +{ + [Fact] + public void HandleRescheduleTimeInputCommand_ShouldExposePlatformNeutralContext() + { + AssertProperty("User", typeof(PlatformUser)); + AssertProperty("Group", typeof(PlatformGroup)); + AssertProperty("Text", typeof(string)); + AssertNoTelegramSpecificProperties(); + } + + [Fact] + public void HandleRescheduleVoteCommand_ShouldExposePlatformNeutralContext() + { + AssertProperty("OptionId", typeof(Guid)); + AssertProperty("User", typeof(PlatformUser)); + AssertProperty("Group", typeof(PlatformGroup)); + AssertProperty("InteractionId", typeof(string)); + AssertProperty("ScheduleMessage", typeof(PlatformMessageRef)); + AssertNoTelegramSpecificProperties(); + } + + private static void AssertProperty(string name, Type expectedType) + { + var property = Assert.Single(typeof(T).GetProperties(), p => p.Name == name); + Assert.Equal(expectedType, property.PropertyType); + } + + private static void AssertNoTelegramSpecificProperties() + { + var names = typeof(T).GetProperties().Select(p => p.Name).ToArray(); + Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal)); + Assert.DoesNotContain("ChatId", names); + Assert.DoesNotContain("MessageId", names); + Assert.DoesNotContain("TelegramUserId", names); + Assert.DoesNotContain("TelegramUsername", names); + } +} diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs index 0c2d1e3..4aad1b7 100644 --- a/tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs @@ -58,7 +58,7 @@ public sealed class PlatformIdentityMigrationTests [Fact] public async Task Code_ShouldQueryPlayersUsingExternalUserIdFallback() { - var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs"); + var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs"); Assert.Contains("external_user_id", createHandler, StringComparison.Ordinal); } @@ -66,7 +66,7 @@ public sealed class PlatformIdentityMigrationTests [Fact] public async Task Code_ShouldQueryGroupsUsingExternalGroupIdFallback() { - var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs"); + var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs"); Assert.Contains("external_group_id", createHandler, StringComparison.Ordinal); } diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs index f165f4b..c74c3bf 100644 --- a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs @@ -20,12 +20,13 @@ public sealed class TelegramPlatformMessengerSourceTests [InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs")] - [InlineData("src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs")] - public async Task SessionFlows_ShouldUsePlatformMessengerForOutboundTelegramWork(string relativePath) + [InlineData("src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs", "src/GmRelay.Shared/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs")] + public async Task SessionFlows_ShouldUsePlatformMessengerForOutboundTelegramWork(string relativePath, string? sharedPath = null) { var source = await ReadRepositoryFileAsync(relativePath); + var sharedSource = sharedPath is not null ? await ReadRepositoryFileAsync(sharedPath) : string.Empty; - Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal); + Assert.Contains("IPlatformMessenger", source + sharedSource, StringComparison.Ordinal); Assert.DoesNotContain("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal); Assert.DoesNotContain(".AnswerCallbackQuery(", source, StringComparison.Ordinal); } diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs index e39296c..40238d4 100644 --- a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs @@ -12,11 +12,11 @@ public sealed class TelegramTopicIntegrationSmokeTests Assert.Contains("topic_created_by_bot", migration, StringComparison.Ordinal); Assert.Contains("ResolveNewScheduleDestination", createHandler, StringComparison.Ordinal); Assert.Contains("message.MessageThreadId", createHandler, StringComparison.Ordinal); - Assert.Contains("topic_created_by_bot", createHandler, StringComparison.Ordinal); + Assert.Contains("topicCreatedByBot", createHandler, StringComparison.Ordinal); Assert.Contains("MissingForumTopicRightsMessage", createHandler, StringComparison.Ordinal); Assert.Contains("TopicCreatedByBot", deleteHandler, StringComparison.Ordinal); Assert.Contains("ShouldDeleteForumTopic", deleteHandler, StringComparison.Ordinal); - Assert.Contains("remainingInTopic", deleteHandler, StringComparison.Ordinal); + Assert.Contains("RemainingInTopic", deleteHandler, StringComparison.Ordinal); } [Fact] @@ -28,6 +28,7 @@ public sealed class TelegramTopicIntegrationSmokeTests var cancelHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs"); var initiateRescheduleHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs"); var rescheduleInputHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs"); + var sharedRescheduleInputHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs"); var rescheduleDeadlineService = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs"); var telegramMessenger = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs"); @@ -48,9 +49,9 @@ public sealed class TelegramTopicIntegrationSmokeTests Assert.Contains("int? MessageThreadId", initiateRescheduleHandler, StringComparison.Ordinal); Assert.Contains("TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId)", initiateRescheduleHandler, StringComparison.Ordinal); - Assert.Contains("int? ThreadId", rescheduleInputHandler, StringComparison.Ordinal); - Assert.Contains("s.thread_id AS ThreadId", rescheduleInputHandler, StringComparison.Ordinal); - Assert.Contains("TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId)", rescheduleInputHandler, StringComparison.Ordinal); + Assert.Contains("message.MessageThreadId", rescheduleInputHandler, StringComparison.Ordinal); + Assert.Contains("s.thread_id AS ThreadId", sharedRescheduleInputHandler, StringComparison.Ordinal); + Assert.Contains("TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId", rescheduleInputHandler, StringComparison.Ordinal); Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);