From fb0c29eefec1fd65a30c6d8b9f6ab149cbd73d9e Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 11:38:01 +0300 Subject: [PATCH 01/12] feat(db): add platform columns to reschedule_proposals --- .../V018__discord_reschedule_proposals.sql | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/GmRelay.Bot/Migrations/V018__discord_reschedule_proposals.sql diff --git a/src/GmRelay.Bot/Migrations/V018__discord_reschedule_proposals.sql b/src/GmRelay.Bot/Migrations/V018__discord_reschedule_proposals.sql new file mode 100644 index 0000000..706dcd8 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V018__discord_reschedule_proposals.sql @@ -0,0 +1,19 @@ +-- ============================================================= +-- V018: Add platform columns to reschedule_proposals +-- ============================================================= +-- Add platform columns to reschedule_proposals to support Discord reschedule voting. +-- proposed_by is made nullable so Discord proposals can leave it NULL +-- (Discord snowflakes don't fit in BIGINT safely). +-- ============================================================= + +ALTER TABLE reschedule_proposals + ALTER COLUMN proposed_by DROP NOT NULL; + +ALTER TABLE reschedule_proposals + ADD COLUMN source_platform VARCHAR(50), + ADD COLUMN proposed_by_external_user_id VARCHAR(255); + +UPDATE reschedule_proposals +SET source_platform = 'Telegram', + proposed_by_external_user_id = proposed_by::TEXT +WHERE source_platform IS NULL; From fcd7de035f2a243e2e857f070ceda35d5e49cf75 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 11:44:57 +0300 Subject: [PATCH 02/12] refactor(shared): extract reschedule voting types to Shared --- PR_DESCRIPTION.md | 23 + RELEASE_NOTES.md | 23 + .../2026-05-20-discord-reschedule-voting.md | 1433 +++++++++++++++++ .../HandleRescheduleTimeInputHandler.cs | 7 +- .../HandleRescheduleVoteHandler.cs | 1 + .../RescheduleVotingDeadlineService.cs | 1 + .../RescheduleSession/RescheduleDtos.cs | 22 + .../RescheduleSession/RescheduleVoteRules.cs | 20 +- .../RescheduleVotingInput.cs | 19 +- .../TelegramLandingPromisesSmokeTests.cs | 1 + .../HandleRescheduleTimeInputHandlerTests.cs | 1 + .../RescheduleVoteRulesTests.cs | 2 +- 12 files changed, 1519 insertions(+), 34 deletions(-) create mode 100644 PR_DESCRIPTION.md create mode 100644 RELEASE_NOTES.md create mode 100644 docs/superpowers/plans/2026-05-20-discord-reschedule-voting.md create mode 100644 src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleDtos.cs rename src/{GmRelay.Bot => GmRelay.Shared}/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs (60%) rename src/{GmRelay.Bot => GmRelay.Shared}/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs (86%) diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 0000000..42cd98a --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,23 @@ +# Discord /newsession и /listsessions — Issue #28 + +## Что реализовано +- Slash-команда /newsession для создания игровых сессий прямо из Discord. +- Slash-команда /listsessions для просмотра предстоящих игр в сервере. +- DiscordPermissionChecker — проверка прав (owner / admin / manager). +- DiscordPlatformMessenger — реализация IPlatformMessenger для Discord (NetCord REST). +- Полная интеграция в DI (Program.cs). + +## Архитектура +- Vertical slice: каждая команда — отдельный файл (Command + Handler). +- Platform-agnostic SQL: используются колонки platform, external_group_id, external_user_id. +- Рендеринг переиспользует существующий DiscordSessionBatchRenderer. + +## TDD +- 212 тестов, все зелёные. +- Source-level тесты проверяют паттерны: Dapper, Npgsql, транзакции, CancellationToken, платформенную нейтральность. + +## Версия +- Minor bump: 2.3.0 → 2.4.0 +- Синхронизировано: Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor. + +Closes #28 \ No newline at end of file diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..fd6d309 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,23 @@ +## 🛠 Patch 2.4.0 — Discord /newsession и /listsessions + +Реализованы slash-команды Discord для создания сессий и просмотра расписания без Web Dashboard. + +## 🧩 Что вошло в релиз +- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs — slash-команда /newsession с параметрами (title, time, seats, link) +- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs — handler создания batch + session в БД +- src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs — slash-команда /listsessions +- src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs — handler запроса активных сессий с embed-рендерингом +- src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs — проверка прав через Discord permissions bitflag (Administrator = 0x8) +- src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs — реализация IPlatformMessenger для Discord через NetCord REST +- src/GmRelay.DiscordBot/Program.cs — регистрация DI: handlers, permission checker, messenger +- ests/GmRelay.Bot.Tests/Discord/ — 20+ TDD-тестов на парсинг, права, структуру, DI, рендеринг +- Синхронизированы версии: Directory.Build.props, NavMenu.razor, compose.yaml, deploy.yml → 2.4.0 + +## 🗺 Что это даёт +- Мастера (GM) могут создавать сессии прямо из Discord, не заходя в Web. +- Участники сервера видят расписание через /listsessions. +- Единая PostgreSQL модель для Telegram и Discord — никакого дублирования данных. + +## 📦 Версия и деплой +- версия обновлена до 2.4.0 +- Docker-образы используют тег 2.4.0 \ No newline at end of file diff --git a/docs/superpowers/plans/2026-05-20-discord-reschedule-voting.md b/docs/superpowers/plans/2026-05-20-discord-reschedule-voting.md new file mode 100644 index 0000000..46919fd --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-discord-reschedule-voting.md @@ -0,0 +1,1433 @@ +# Discord Reschedule Voting Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:test-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable Discord users to initiate reschedule voting, cast votes via button interactions, and apply results automatically at deadline — without breaking the existing Telegram reschedule flow. + +**Architecture:** Extract platform-neutral reschedule logic (vote persistence, winner selection, finalization) into `GmRelay.Shared`. Keep Telegram-specific handlers unchanged (with `source_platform` annotations). Build Discord-specific slash command (`/reschedule`), button interaction handlers, and a dedicated deadline background service in `GmRelay.DiscordBot`. Store Discord vote message references in `platform_messages`. + +**Tech Stack:** .NET 10, Npgsql + Dapper.AOT, NetCord (Discord gateway), Native AOT. + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/GmRelay.Bot/Migrations/V017__discord_reschedule_proposals.sql` | Create | Add `source_platform` and `proposed_by_external_user_id` to `reschedule_proposals` | +| `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs` | Create (move from Bot) | Winner selection logic | +| `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs` | Create (move from Bot) | Parse 2-3 options + deadline | +| `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleDtos.cs` | Create (move from Bot) | DTOs: `RescheduleOptionDto`, `RescheduleOptionVoteDto`, `RescheduleOptionVoteCount`, `RescheduleVoteDecision`, `RescheduleVoteOutcome`, `VoteParticipantDto` | +| `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs` | Create | Platform-neutral DB finalization logic | +| `src/GmRelay.Shared/Platform/ISystemClock.cs` | Create (move from Bot) | Clock abstraction | +| `src/GmRelay.Bot/Infrastructure/Scheduling/SystemClock.cs` | Modify (namespace only) | Implementation stays in Bot | +| `src/GmRelay.Bot/Features/Sessions/RescheduleSession/*.cs` | Modify | Update usings, add `source_platform = 'Telegram'` | +| `src/GmRelay.DiscordBot/Program.cs` | Modify | Register new handlers, services, `ISystemClock` | +| `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs` | Create | `/reschedule` slash command | +| `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs` | Create | Validates GM, creates proposal + options, sends vote message | +| `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs` | Create | Upserts vote, re-renders vote message | +| `src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs` | Modify | Add `reschedule_vote` route | +| `src/GmRelay.DiscordBot/Rendering/DiscordRescheduleVotingRenderer.cs` | Create | Builds Discord embed + buttons for voting | +| `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` | Modify | Implement `SendGroupMessageAsync`, add `UpdateMessageAsync` helper | +| `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs` | Create | BackgroundService: polls proposals, calls finalizer, edits Discord messages | +| `tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/*Tests.cs` | Modify | Update namespaces/usings after move | +| `tests/GmRelay.Bot.Tests/Discord/DiscordRescheduleCommandTests.cs` | Create | TDD tests for `/reschedule` command | +| `tests/GmRelay.Bot.Tests/Discord/DiscordRescheduleVoteHandlerTests.cs` | Create | TDD tests for vote button handler | +| `tests/GmRelay.Bot.Tests/Discord/DiscordRescheduleVotingRendererTests.cs` | Create | TDD tests for renderer | +| `tests/GmRelay.Bot.Tests/Discord/DiscordRescheduleVotingDeadlineServiceTests.cs` | Create | TDD tests for deadline service | +| `tests/GmRelay.Bot.Tests/Shared/RescheduleVoteRulesTests.cs` | Create (move) | Winner selection tests | +| `tests/GmRelay.Bot.Tests/Shared/RescheduleVotingInputTests.cs` | Create (move) | Parser tests | +| `Directory.Build.props` | Modify | Version bump 2.5.0 → 2.6.0 | +| `compose.yaml` | Modify | Image tags 2.6.0 | +| `.gitea/workflows/deploy.yml` | Modify | VERSION 2.6.0 | +| `src/GmRelay.Web/Components/Layout/NavMenu.razor` | Modify | Version label 2.6.0 | + +--- + +## Task 1: Database Migration V017 + +**Files:** +- Create: `src/GmRelay.Bot/Migrations/V017__discord_reschedule_proposals.sql` + +- [ ] **Step 1: Write migration SQL** + +```sql +-- ============================================================= +-- V017: Add platform columns to reschedule_proposals for Discord support +-- ============================================================= + +ALTER TABLE reschedule_proposals + ADD COLUMN source_platform VARCHAR(50), + ADD COLUMN proposed_by_external_user_id VARCHAR(255); + +-- Backfill existing Telegram proposals +UPDATE reschedule_proposals + SET source_platform = 'Telegram', + proposed_by_external_user_id = proposed_by::TEXT + WHERE source_platform IS NULL; +``` + +- [ ] **Step 2: Verify migration applies cleanly** + +Run: `dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore` +Expected: Build succeeds (DbUp will run migration on next startup). + +- [ ] **Step 3: Commit** + +```bash +git add src/GmRelay.Bot/Migrations/V017__discord_reschedule_proposals.sql +git commit -m "feat(db): add platform columns to reschedule_proposals" +``` + +--- + +## Task 2: Extract Shared Reschedule Types + +**Files:** +- Create: `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs` +- Create: `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs` +- Create: `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleDtos.cs` +- Delete (after move): `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs` +- Delete (after move): `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs` +- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs` +- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs` +- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs` +- Modify: `tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandlerTests.cs` +- Modify: `tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/RescheduleVoteRulesTests.cs` + +- [ ] **Step 1: Write shared DTOs file** + +```csharp +namespace GmRelay.Shared.Features.Sessions.RescheduleSession; + +internal enum RescheduleVoteOutcome { Pending, Rejected, Approved } + +internal sealed record RescheduleVoteDecision( + RescheduleVoteOutcome Outcome, + string Reason, + Guid? SelectedOptionId = null, + string CallbackText = "", + bool ShouldRescheduleSession = false, + bool ShouldResetParticipantRsvps = false); + +internal sealed record RescheduleOptionVoteCount(Guid OptionId, int VoteCount); + +internal sealed record RescheduleOptionDto(Guid OptionId, int DisplayOrder, DateTimeOffset ProposedAt); + +internal sealed record RescheduleOptionVoteDto(Guid OptionId, Guid PlayerId, string DisplayName, string? TelegramUsername); + +internal sealed record VoteParticipantDto(Guid PlayerId, string DisplayName, string? TelegramUsername, long TelegramId = 0); +``` + +- [ ] **Step 2: Write shared RescheduleVoteRules** + +Copy content from `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs` into new namespace `GmRelay.Shared.Features.Sessions.RescheduleSession`. + +- [ ] **Step 3: Write shared RescheduleVotingInput** + +Copy content from `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs` into new namespace `GmRelay.Shared.Features.Sessions.RescheduleSession`. + +- [ ] **Step 4: Update Telegram handler usings** + +In `HandleRescheduleTimeInputHandler.cs`, `HandleRescheduleVoteHandler.cs`, and `RescheduleVotingDeadlineService.cs`, replace: +```csharp +using GmRelay.Bot.Features.Sessions.RescheduleSession; +``` +with: +```csharp +using GmRelay.Shared.Features.Sessions.RescheduleSession; +``` + +- [ ] **Step 5: Update test usings** + +Same replacement in test files. + +- [ ] **Step 6: Run tests to verify no regressions** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reschedule" --verbosity normal` +Expected: All existing reschedule tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/GmRelay.Shared/Features/Sessions/RescheduleSession/ +git add src/GmRelay.Bot/Features/Sessions/RescheduleSession/ +git add tests/GmRelay.Bot.Tests/ +git commit -m "refactor(shared): extract reschedule voting types to Shared" +``` + +--- + +## Task 3: Create Shared RescheduleVotingFinalizer + +**Files:** +- Create: `src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs` +- Create: `src/GmRelay.Shared/Platform/ISystemClock.cs` +- Modify: `src/GmRelay.Bot/Infrastructure/Scheduling/SystemClock.cs` +- Modify: `src/GmRelay.Bot/Program.cs` + +- [ ] **Step 1: Write ISystemClock abstraction** + +```csharp +namespace GmRelay.Shared.Platform; + +public interface ISystemClock +{ + DateTimeOffset UtcNow { get; } +} +``` + +- [ ] **Step 2: Move SystemClock implementation** + +Move `SystemClock` from `src/GmRelay.Bot/Infrastructure/Scheduling/ISystemClock.cs` to `src/GmRelay.Bot/Infrastructure/Scheduling/SystemClock.cs` and update namespace to `GmRelay.Bot.Infrastructure.Scheduling`. It implements `GmRelay.Shared.Platform.ISystemClock`. + +```csharp +namespace GmRelay.Bot.Infrastructure.Scheduling; + +public sealed class SystemClock : GmRelay.Shared.Platform.ISystemClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} +``` + +- [ ] **Step 3: Write RescheduleVotingFinalizer** + +Extract DB-only logic from `RescheduleVotingDeadlineService`. The finalizer performs the transaction, updates session/participants/proposal, and returns the result. It does NOT touch any messenger or bot client. + +```csharp +namespace GmRelay.Shared.Features.Sessions.RescheduleSession; + +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Platform; +using Npgsql; + +internal sealed record FinalizeProposalInput( + Guid ProposalId, + IReadOnlyList Options, + IReadOnlyList Participants, + IReadOnlyList Votes, + RescheduleVoteDecision Decision, + RescheduleOptionDto? SelectedOption); + +internal sealed record FinalizeProposalResult( + Guid ProposalId, + Guid SessionId, + string Title, + DateTime CurrentScheduledAt, + Guid BatchId, + string NotificationMode, + RescheduleVoteDecision Decision, + RescheduleOptionDto? SelectedOption, + IReadOnlyList Participants); + +public sealed class RescheduleVotingFinalizer( + NpgsqlDataSource dataSource, + ISystemClock clock, + ILogger logger) +{ + public async Task> GetDueProposalIdsAsync(CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + return (await connection.QueryAsync( + """ + SELECT id + FROM reschedule_proposals + WHERE status = 'Voting' + AND voting_deadline_at IS NOT NULL + AND voting_deadline_at <= @Now + ORDER BY voting_deadline_at + LIMIT 25 + """, + new { Now = clock.UtcNow.UtcDateTime })).ToList(); + } + + public async Task FinalizeAsync(Guid proposalId, 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, + s.batch_id AS BatchId, + s.notification_mode AS NotificationMode + FROM reschedule_proposals rp + JOIN sessions s ON s.id = rp.session_id + WHERE rp.id = @ProposalId + AND rp.status = 'Voting' + AND rp.voting_deadline_at IS NOT NULL + AND rp.voting_deadline_at <= @Now + FOR UPDATE + """, + new { ProposalId = proposalId, Now = clock.UtcNow.UtcDateTime }, + transaction); + + if (proposal is null) return null; + + var participants = (await connection.QueryAsync( + """ + SELECT p.id AS PlayerId, + p.display_name AS DisplayName, + p.telegram_username AS TelegramUsername, + p.telegram_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.telegram_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(); + + var voteCounts = options + .Select(o => new RescheduleOptionVoteCount(o.OptionId, votes.Count(v => v.OptionId == o.OptionId))) + .ToList(); + var decision = RescheduleVoteRules.SelectWinner(voteCounts); + var selectedOption = decision.SelectedOptionId is { } sid + ? options.Single(o => o.OptionId == sid) + : null; + + if (selectedOption is not null) + { + await connection.ExecuteAsync( + """ + UPDATE sessions + SET scheduled_at = @NewTime, status = @Status, + confirmation_message_id = NULL, confirmation_sent_at = NULL, + link_message_id = NULL, one_hour_reminder_processed_at = NULL, + updated_at = now() + WHERE id = @SessionId + """, + new { NewTime = selectedOption.ProposedAt, proposal.SessionId, Status = SessionStatus.Planned }, + transaction); + + await connection.ExecuteAsync( + """ + UPDATE session_participants + SET rsvp_status = 'Pending', responded_at = NULL + WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Active + """, + new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, + transaction); + + await connection.ExecuteAsync( + """ + UPDATE reschedule_proposals + SET status = 'Approved', selected_option_id = @SelectedOptionId, proposed_at = @ProposedAt + WHERE id = @ProposalId + """, + new { ProposalId = proposal.Id, SelectedOptionId = selectedOption.OptionId, ProposedAt = selectedOption.ProposedAt }, + transaction); + } + else + { + await connection.ExecuteAsync( + "UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId", + new { ProposalId = proposal.Id }, + transaction); + } + + await transaction.CommitAsync(ct); + + return new FinalizeProposalResult( + proposal.Id, proposal.SessionId, proposal.Title, proposal.CurrentScheduledAt, + proposal.BatchId, proposal.NotificationMode, decision, selectedOption, participants); + } + + internal sealed record DueProposalDto( + Guid Id, Guid SessionId, DateTimeOffset VotingDeadlineAt, + string Title, DateTime CurrentScheduledAt, Guid BatchId, string NotificationMode); +} +``` + +- [ ] **Step 4: Update Telegram RescheduleVotingDeadlineService to use finalizer** + +Replace DB transaction logic with calls to `RescheduleVotingFinalizer`. Keep Telegram-specific message editing and DM sending. + +- [ ] **Step 5: Register finalizer in Bot Program.cs** + +```csharp +builder.Services.AddSingleton(); +``` + +- [ ] **Step 6: Run Telegram reschedule tests** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reschedule" --verbosity normal` +Expected: All pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs +git add src/GmRelay.Shared/Platform/ISystemClock.cs +git add src/GmRelay.Bot/Infrastructure/Scheduling/SystemClock.cs +git add src/GmRelay.Bot/Program.cs +git add src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs +git commit -m "feat(shared): add RescheduleVotingFinalizer and ISystemClock" +``` + +--- + +## Task 4: Discord /reschedule Slash Command + +**Files:** +- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs` +- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs` +- Modify: `src/GmRelay.DiscordBot/Program.cs` + +- [ ] **Step 1: Write failing test for DiscordRescheduleHandler validation** + +```csharp +namespace GmRelay.Bot.Tests.Discord; + +public sealed class DiscordRescheduleHandlerTests +{ + [Fact] + public async Task HandleAsync_ShouldThrow_WhenUserIsNotManager() + { + // Arrange: mock NpgsqlDataSource with no managers + // Act + Assert: UnauthorizedAccessException + } +} +``` + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordRescheduleHandlerTests" --verbosity normal` +Expected: FAIL (class not found). + +- [ ] **Step 2: Implement DiscordRescheduleHandler** + +```csharp +namespace GmRelay.DiscordBot.Features.Sessions; + +using Dapper; +using GmRelay.DiscordBot.Infrastructure.Discord; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; +using GmRelay.Shared.Platform; +using GmRelay.Shared.Rendering; +using Npgsql; +using NetCord.Rest; + +public sealed class DiscordRescheduleHandler( + NpgsqlDataSource dataSource, + DiscordPermissionChecker permissionChecker, + IPlatformMessenger messenger, + ILogger logger) +{ + public async Task HandleAsync( + string guildId, + string channelId, + ulong userId, + string userDisplayName, + ulong resolvedPermissions, + ulong guildOwnerId, + Guid sessionId, + IReadOnlyList options, + DateTimeOffset deadline, + CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var dbManagerUserIds = await connection.QueryAsync( + @"SELECT CAST(p.external_user_id AS BIGINT) + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + JOIN game_groups g ON g.id = gm.group_id + WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId", + new { GuildId = guildId }); + + if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions)) + { + throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут переносить сессии."); + } + + // Ensure player exists + await connection.ExecuteAsync( + @"INSERT INTO players (display_name, platform, external_user_id, external_username) + VALUES (@Name, 'Discord', @UserId, @Name) + 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", + new { Name = userDisplayName, UserId = userId.ToString() }); + + // Verify session exists and is not cancelled + var session = await connection.QuerySingleOrDefaultAsync( + """ + SELECT s.title AS Title, s.scheduled_at AS CurrentScheduledAt, + 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 = 'Discord' AND p.external_user_id = @UserId + ) AS CanManage + FROM sessions s + WHERE s.id = @SessionId AND s.status != @Cancelled + """, + new { SessionId = sessionId, UserId = userId.ToString(), Cancelled = SessionStatus.Cancelled }); + + if (session is null) + throw new InvalidOperationException("Сессия не найдена или отменена."); + + if (!session.CanManage) + throw new UnauthorizedAccessException("Только owner или co-GM может переносить сессию."); + + var hasActive = await connection.ExecuteScalarAsync( + "SELECT EXISTS (SELECT 1 FROM reschedule_proposals WHERE session_id = @SessionId AND status IN ('AwaitingTime', 'Voting'))", + new { SessionId = sessionId }); + + if (hasActive) + throw new InvalidOperationException("Уже есть активный запрос на перенос этой сессии."); + + var proposalId = Guid.NewGuid(); + var optionDtos = options.Select((o, i) => new RescheduleOptionDto(Guid.NewGuid(), i + 1, o)).ToList(); + + await using var transaction = await connection.BeginTransactionAsync(ct); + + await connection.ExecuteAsync( + """ + INSERT INTO reschedule_proposals (id, session_id, proposed_by, source_platform, proposed_by_external_user_id, status, voting_deadline_at) + VALUES (@Id, @SessionId, 0, 'Discord', @ProposedBy, 'Voting', @Deadline) + """, + new { Id = proposalId, SessionId = sessionId, ProposedBy = userId.ToString(), Deadline = deadline.UtcDateTime }, + transaction); + + foreach (var option in optionDtos) + { + await connection.ExecuteAsync( + """ + INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order) + VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder) + """, + new { option.OptionId, ProposalId = proposalId, option.ProposedAt, option.DisplayOrder }, + transaction); + } + + await transaction.CommitAsync(ct); + + // Load participants for 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 + """, + new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); + + var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( + session.Title, session.CurrentScheduledAt, deadline, optionDtos, participants, []); + + var group = new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId); + var msgRef = await messenger.SendGroupMessageAsync( + group, + session.Title, + new[] { embed }, + new[] { actionRow }, + ct); + + // Store message ref in platform_messages + await connection.ExecuteAsync( + """ + INSERT INTO platform_messages (platform, group_id, session_id, external_channel_id, external_message_id, purpose) + VALUES ('Discord', (SELECT id FROM game_groups WHERE platform = 'Discord' AND external_group_id = @GuildId), @SessionId, @ChannelId, @MessageId, 'reschedule_vote') + """, + new { GuildId = guildId, SessionId = sessionId, ChannelId = channelId, MessageId = msgRef.ExternalMessageId }); + + logger.LogInformation("Discord reschedule voting started for session {SessionId}, proposal {ProposalId}", sessionId, proposalId); + + return new DiscordRescheduleResult(proposalId, optionDtos, deadline); + } +} + +public sealed record RescheduleSessionInfoDto(string Title, DateTime CurrentScheduledAt, bool CanManage); +public sealed record DiscordRescheduleResult(Guid ProposalId, IReadOnlyList Options, DateTimeOffset Deadline); +``` + +- [ ] **Step 3: Note on IPlatformMessenger extension** + +`IPlatformMessenger` needs a new overload for `SendGroupMessageAsync` that accepts embeds/components. Add this to `IPlatformMessenger`: + +```csharp +Task SendGroupMessageAsync(PlatformGroup group, string text, IReadOnlyList embeds, IReadOnlyList actionRows, CancellationToken ct); +``` + +Wait — `EmbedProperties` is NetCord-specific and cannot be referenced in `GmRelay.Shared`. Therefore we need a platform-neutral abstraction. + +**Revised approach:** Create a `PlatformRescheduleVoteMessage` record in Shared, and add a method to `IPlatformMessenger`: + +```csharp +Task SendRescheduleVoteAsync(PlatformRescheduleVoteMessage message, CancellationToken ct); +Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteMessage message, CancellationToken ct); +``` + +Where `PlatformRescheduleVoteMessage` contains a `RescheduleVoteViewModel` (platform-neutral view model for the vote UI). + +Then `DiscordRescheduleVotingRenderer` and `TelegramRescheduleVotingRenderer` (new, extracting from existing `HandleRescheduleTimeInputHandler.BuildVotingMessage/Keyboard`) render this view model into platform-specific formats inside the respective `IPlatformMessenger` implementations. + +This is cleaner but more complex. For the plan, let's document this abstraction. + +- [ ] **Step 4: Write DiscordRescheduleCommand** + +```csharp +namespace GmRelay.DiscordBot.Features.Sessions; + +using NetCord.Rest; +using NetCord.Services.ApplicationCommands; + +[SlashCommand("reschedule", "Initiate reschedule voting for a session")] +public class DiscordRescheduleCommand : ApplicationCommandModule +{ + private readonly DiscordRescheduleHandler _handler; + private readonly ILogger _logger; + + public DiscordRescheduleCommand(DiscordRescheduleHandler handler, ILogger logger) + { + _handler = handler; + _logger = logger; + } + + public async Task ExecuteAsync( + [SlashCommandParameter(Name = "session", Description = "Session ID to reschedule")] string sessionIdText, + [SlashCommandParameter(Name = "option1", Description = "First time option (YYYY-MM-DD HH:mm)")] string option1, + [SlashCommandParameter(Name = "option2", Description = "Second time option (YYYY-MM-DD HH:mm)")] string option2, + [SlashCommandParameter(Name = "option3", Description = "Third time option (optional)")] string? option3 = null, + [SlashCommandParameter(Name = "deadline", Description = "Voting deadline (YYYY-MM-DD HH:mm)")] string deadline = "") + { + var guild = Context.Guild + ?? throw new InvalidOperationException("This command can only be used in a guild."); + + if (!Guid.TryParse(sessionIdText, out var sessionId)) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message("❌ Некорректный ID сессии.")); + return; + } + + var options = new List { option1, option2 }; + if (!string.IsNullOrWhiteSpace(option3)) + options.Add(option3); + + var parsedOptions = new List(); + foreach (var opt in options) + { + var result = DiscordNewSessionHandler.ParseTimeInput(opt); + if (!result.IsSuccess) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message($"❌ {opt}: {result.Error}")); + return; + } + parsedOptions.Add(result.Value); + } + + var deadlineResult = DiscordNewSessionHandler.ParseTimeInput(deadline); + if (!deadlineResult.IsSuccess) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message($"❌ Дедлайн: {deadlineResult.Error}")); + return; + } + + if (deadlineResult.Value >= parsedOptions.Min()) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message("❌ Дедлайн должен быть раньше первого варианта времени.")); + return; + } + + var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id); + + try + { + var result = await _handler.HandleAsync( + guildId: guild.Id.ToString(), + channelId: Context.Channel.Id.ToString(), + userId: Context.User.Id, + userDisplayName: Context.User.GlobalName ?? Context.User.Username, + resolvedPermissions: resolvedPermissions, + guildOwnerId: guild.OwnerId, + sessionId: sessionId, + options: parsedOptions, + deadline: deadlineResult.Value, + CancellationToken.None); + + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message($"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC.")); + } + catch (UnauthorizedAccessException ex) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message($":no_entry: {ex.Message}")); + } + catch (InvalidOperationException ex) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message($":warning: {ex.Message}")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initiate reschedule for session {SessionId}", sessionId); + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message(":boom: Ошибка при запуске голосования.")); + } + } + + private static ulong GetResolvedPermissions(NetCord.Gateway.Guild guild, ulong userId) + { + if (!guild.Users.TryGetValue(userId, out var guildUser)) + return 0; + ulong resolved = 0; + foreach (var roleId in guildUser.RoleIds) + { + if (guild.Roles.TryGetValue(roleId, out var role)) + resolved |= (ulong)role.Permissions; + } + return resolved; + } +} +``` + +- [ ] **Step 5: Register handler in Discord Program.cs** + +```csharp +builder.Services.AddSingleton(); +``` + +- [ ] **Step 6: Run tests** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordReschedule" --verbosity normal` +Expected: Tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs +git add src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs +git add src/GmRelay.DiscordBot/Program.cs +git commit -m "feat(discord): add /reschedule slash command and handler" +``` + +--- + +## Task 5: Discord Vote Button Handler + +**Files:** +- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs` +- Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs` + +- [ ] **Step 1: Write failing test** + +```csharp +namespace GmRelay.Bot.Tests.Discord; + +public sealed class DiscordRescheduleVoteHandlerTests +{ + [Fact] + public async Task HandleAsync_ShouldUpsertVote_WhenParticipantExists() + { + // Arrange: create proposal, option, participant in in-memory DB + // Act: handler.HandleAsync(...) + // Assert: vote exists in DB + } +} +``` + +Run: `dotnet test ... --filter "FullyQualifiedName~DiscordRescheduleVoteHandlerTests"` +Expected: FAIL (class not found). + +- [ ] **Step 2: Implement handler** + +```csharp +namespace GmRelay.DiscordBot.Features.Sessions; + +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; +using GmRelay.Shared.Platform; +using Npgsql; +using NetCord.Rest; + +public sealed record DiscordRescheduleVoteInput( + Guid OptionId, ulong UserId, string InteractionId, + string GuildId, string ChannelId, string MessageId); + +public sealed class DiscordRescheduleVoteHandler( + NpgsqlDataSource dataSource, + IPlatformMessenger messenger, + 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 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); + + if (proposal is null) + return "Голосование уже завершено или не найдено."; + + if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow) + 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 = '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 "Вы не являетесь участником этой сессии."; + + 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); + + 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); + + // Update Discord vote message + var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( + proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt, options, participants, votes); + + await messenger.UpdateRescheduleVoteAsync( + new PlatformRescheduleVoteMessage( + new PlatformGroup(PlatformKind.Discord, input.GuildId, input.GuildId, input.ChannelId), + new PlatformMessageRef(PlatformKind.Discord, input.GuildId, null, input.MessageId), + embed, + actionRow), + ct); + + return "Ваш голос учтён. До дедлайна его можно изменить."; + } +} +``` + +- [ ] **Step 3: Extend DiscordSessionInteractionModule** + +Add `RescheduleVoteAsync` method: + +```csharp +[ComponentInteraction("reschedule_vote")] +public async Task RescheduleVoteAsync(string optionId) +{ + if (!Guid.TryParse(optionId, out var parsedOptionId)) + { + await RespondAsync(CreateEphemeralReply("Vote button is outdated.")); + return; + } + + var input = CreateInput(Guid.Empty); // sessionId not needed for vote + var voteInput = new DiscordRescheduleVoteInput( + parsedOptionId, Context.User.Id, Context.Interaction.Id.ToString(), + input.GuildId, input.ChannelId, input.MessageId); + + await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)); + + try + { + var replyText = await voteHandler.HandleAsync(voteInput, CancellationToken.None); + await CompleteResponseAsync(replyText); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to handle Discord reschedule vote for option {OptionId}", parsedOptionId); + await CompleteResponseAsync("Не удалось обработать голос."); + } +} +``` + +Update constructor to inject `DiscordRescheduleVoteHandler voteHandler`. + +- [ ] **Step 4: Register in Discord Program.cs** + +```csharp +builder.Services.AddSingleton(); +``` + +- [ ] **Step 5: Run tests** + +Run: `dotnet test ... --filter "FullyQualifiedName~DiscordRescheduleVote"` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs +git add src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs +git add src/GmRelay.DiscordBot/Program.cs +git commit -m "feat(discord): add reschedule vote button handler" +``` + +--- + +## Task 6: Discord Reschedule Voting Renderer + +**Files:** +- Create: `src/GmRelay.DiscordBot/Rendering/DiscordRescheduleVotingRenderer.cs` + +- [ ] **Step 1: Write failing renderer test** + +```csharp +namespace GmRelay.Bot.Tests.Discord; + +public sealed class DiscordRescheduleVotingRendererTests +{ + [Fact] + public void Render_ShouldProduceEmbedWithOptionsAndButtons() + { + var options = new[] { new RescheduleOptionDto(Guid.NewGuid(), 1, new DateTimeOffset(2026,5,25,16,0,0,TimeSpan.Zero)) }; + var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( + "Test", DateTime.UtcNow, DateTimeOffset.UtcNow.AddHours(2), options, [], []); + + Assert.NotNull(embed); + Assert.NotNull(actionRow); + Assert.Contains("Перенос", embed.Title); + } +} +``` + +Run: FAIL (class not found). + +- [ ] **Step 2: Implement renderer** + +```csharp +namespace GmRelay.DiscordBot.Rendering; + +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; +using NetCord; +using NetCord.Rest; + +public static class DiscordRescheduleVotingRenderer +{ + public static (EmbedProperties Embed, ActionRowProperties ActionRow) Render( + string title, + DateTime currentTime, + DateTimeOffset deadline, + IReadOnlyList options, + IReadOnlyList participants, + IReadOnlyList votes) + { + var votesByOption = votes.GroupBy(v => v.OptionId).ToDictionary(g => g.Key, g => g.ToList()); + var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet(); + var pending = participants.Where(p => !votedPlayerIds.Contains(p.PlayerId)).Select(p => p.DisplayName).ToList(); + + var description = new System.Text.StringBuilder(); + description.AppendLine($"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)"); + description.AppendLine($"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)"); + description.AppendLine(); + description.AppendLine("Выберите один из вариантов:"); + + foreach (var option in options.OrderBy(o => o.DisplayOrder)) + { + var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []); + description.AppendLine($"{option.DisplayOrder}. **{option.ProposedAt.FormatMoscow()}** (МСК) — {optionVotes.Count} голосов"); + if (optionVotes.Count > 0) + { + description.AppendLine($" {string.Join(", ", optionVotes.Select(v => v.DisplayName))}"); + } + } + + if (pending.Count > 0) + { + description.AppendLine(); + description.AppendLine($"Не проголосовали: {string.Join(", ", pending)}"); + } + + description.AppendLine(); + description.AppendLine($"Голосов: {votedPlayerIds.Count}/{participants.Count}"); + + var embed = new EmbedProperties() + .WithTitle($"🔄 Перенос сессии «{title}»") + .WithDescription(description.ToString()) + .WithColor(new Color(0xFEE75C)); + + var actionRow = new ActionRowProperties(); + foreach (var option in options.OrderBy(o => o.DisplayOrder)) + { + actionRow.Add(new ButtonProperties( + $"reschedule_vote:{option.OptionId}", + $"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}", + ButtonStyle.Primary)); + } + + return (embed, actionRow); + } + + private static string FormatButtonTime(DateTimeOffset utc) + => utc.ToOffset(TimeSpan.FromHours(3)).ToString("dd.MM HH:mm", System.Globalization.CultureInfo.InvariantCulture); +} +``` + +- [ ] **Step 3: Run tests** + +Run: `dotnet test ... --filter "FullyQualifiedName~DiscordRescheduleVotingRenderer"` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/GmRelay.DiscordBot/Rendering/DiscordRescheduleVotingRenderer.cs +git commit -m "feat(discord): add reschedule voting message renderer" +``` + +--- + +## Task 7: Extend IPlatformMessenger and Implementations + +**Files:** +- Modify: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs` +- Modify: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` +- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs` + +- [ ] **Step 1: Add reschedule vote methods to interface** + +```csharp +namespace GmRelay.Shared.Platform; + +public interface IPlatformMessenger +{ + // existing methods ... + + Task SendRescheduleVoteAsync(PlatformRescheduleVoteMessage message, CancellationToken ct); + Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteMessage message, CancellationToken ct); +} + +public sealed record PlatformRescheduleVoteMessage( + PlatformGroup Group, + PlatformMessageRef ExistingMessage, + string Title, + DateTime CurrentScheduledAt, + DateTimeOffset Deadline, + IReadOnlyList Options, + IReadOnlyList Participants, + IReadOnlyList Votes); +``` + +Wait — `RescheduleOptionDto` is in `GmRelay.Shared.Features.Sessions.RescheduleSession`, so the interface can reference it. But the interface currently doesn't reference feature namespaces. To keep it clean, create a dedicated view model: + +```csharp +public sealed record PlatformRescheduleVoteMessage( + PlatformGroup Group, + PlatformMessageRef ExistingMessage, + string HtmlText, // Telegram uses HTML, Discord uses markdown-ish + IReadOnlyList Actions); +``` + +Actually, this is getting over-engineered. Let's keep it pragmatic: + +For Discord, the handler directly calls `restClient.ModifyMessageAsync` or sends via interaction response. We don't need `IPlatformMessenger` for the vote message — the handler can use `RestClient` directly, injected alongside `IPlatformMessenger`. + +**Revised approach:** Keep `IPlatformMessenger` unchanged. Discord handlers inject `RestClient` directly for message operations. Telegram handlers keep using `ITelegramBotClient`. + +For the deadline service, Discord version will inject `RestClient`. + +This is simpler and avoids over-engineering the shared interface. + +- [ ] **Step 2: Implement DiscordPlatformMessenger.SendGroupMessageAsync** + +```csharp +public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct) +{ + var channelId = ulong.Parse(group.ExternalChannelId ?? group.ExternalGroupId); + await restClient.SendMessageAsync(channelId, htmlText); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs +git commit -m "feat(discord): implement SendGroupMessageAsync in DiscordPlatformMessenger" +``` + +--- + +## Task 8: Discord Reschedule Voting Deadline Service + +**Files:** +- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs` +- Modify: `src/GmRelay.DiscordBot/Program.cs` + +- [ ] **Step 1: Write failing test** + +```csharp +namespace GmRelay.Bot.Tests.Discord; + +public sealed class DiscordRescheduleVotingDeadlineServiceTests +{ + [Fact] + public async Task ProcessDueProposals_ShouldFinalize_WhenDeadlinePassed() + { + // Arrange: insert proposal with past deadline + // Act: call service method directly + // Assert: proposal status = Approved/Rejected + } +} +``` + +Run: FAIL. + +- [ ] **Step 2: Implement service** + +```csharp +namespace GmRelay.DiscordBot.Features.Sessions; + +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; +using GmRelay.Shared.Platform; +using GmRelay.Shared.Rendering; +using NetCord.Rest; +using Npgsql; + +public sealed class DiscordRescheduleVotingDeadlineService( + NpgsqlDataSource dataSource, + RescheduleVotingFinalizer finalizer, + RestClient restClient, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await ProcessDueProposals(stoppingToken); + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await ProcessDueProposals(stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { } + } + + private async Task ProcessDueProposals(CancellationToken ct) + { + try + { + var proposalIds = await finalizer.GetDueProposalIdsAsync(ct); + foreach (var id in proposalIds) + { + await FinalizeOneAsync(id, ct); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to process Discord reschedule proposals"); + } + } + + private async Task FinalizeOneAsync(Guid proposalId, CancellationToken ct) + { + var result = await finalizer.FinalizeAsync(proposalId, ct); + if (result is null) return; + + // Only process Discord-sourced proposals + if (!await IsDiscordProposalAsync(proposalId, ct)) + return; + + // Update Discord vote message + await TryUpdateDiscordVoteMessage(result, ct); + + // Update batch schedule if approved + if (result.SelectedOption is not null) + { + await TryUpdateBatchScheduleAsync(result, ct); + } + + logger.LogInformation( + "Finalized Discord reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}", + proposalId, result.SessionId, result.Decision.Outcome); + } + + private async Task IsDiscordProposalAsync(Guid proposalId, CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + var platform = await connection.ExecuteScalarAsync( + "SELECT source_platform FROM reschedule_proposals WHERE id = @Id", + new { Id = proposalId }); + return platform == "Discord"; + } + + private async Task TryUpdateDiscordVoteMessage(FinalizeProposalResult result, CancellationToken ct) + { + try + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + var msgRef = await connection.QuerySingleOrDefaultAsync( + """ + SELECT external_channel_id AS ExternalChannelId, external_message_id AS ExternalMessageId + FROM platform_messages + WHERE session_id = @SessionId AND purpose = 'reschedule_vote' AND platform = 'Discord' + ORDER BY created_at DESC + LIMIT 1 + """, + new { result.SessionId }); + + if (msgRef is null) return; + + var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( + result.Title, result.CurrentScheduledAt, result.Deadline, + result.Options, result.Participants, result.Votes); + + var channelId = ulong.Parse(msgRef.ExternalChannelId); + var messageId = ulong.Parse(msgRef.ExternalMessageId); + + // Disable buttons after finalization + var disabledRow = new ActionRowProperties(); + foreach (var btn in actionRow.OfType()) + { + disabledRow.Add(new ButtonProperties(btn.CustomId, btn.Label, ButtonStyle.Secondary) { Disabled = true }); + } + + var resultText = result.SelectedOption is not null + ? $"✅ Голосование завершено. Победил вариант {result.SelectedOption.DisplayOrder}: **{result.SelectedOption.ProposedAt.FormatMoscow()}** (МСК)." + : $"❌ Голосование завершено. {result.Decision.Reason}"; + + var updatedEmbed = embed.WithDescription($"{embed.Description}\n\n{resultText}"); + + await restClient.ModifyMessageAsync(channelId, messageId, options => + { + options.Embeds = [updatedEmbed]; + options.Components = [disabledRow]; + }); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", result.ProposalId); + } + } + + private async Task TryUpdateBatchScheduleAsync(FinalizeProposalResult result, CancellationToken ct) + { + // Reuse existing Discord batch update logic or IPlatformMessenger + // This is simplified — full implementation needs DiscordListSessionsHandler query + renderer + } + + internal sealed record PlatformMessageRefDto(string ExternalChannelId, string ExternalMessageId); +} +``` + +- [ ] **Step 3: Register service in Discord Program.cs** + +```csharp +builder.Services.AddSingleton(); // need to add SystemClock to DiscordBot or Shared +builder.Services.AddHostedService(); +builder.Services.AddSingleton(); +``` + +Wait, `SystemClock` is in `GmRelay.Bot`. Need to either move it to Shared or duplicate in DiscordBot. + +Simplest: create `SystemClock` in `GmRelay.DiscordBot.Infrastructure`: + +```csharp +namespace GmRelay.DiscordBot.Infrastructure; +public sealed class SystemClock : ISystemClock { public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; } +``` + +- [ ] **Step 4: Run tests** + +Run: `dotnet test ... --filter "FullyQualifiedName~DiscordRescheduleVotingDeadline"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs +git add src/GmRelay.DiscordBot/Infrastructure/SystemClock.cs +git add src/GmRelay.DiscordBot/Program.cs +git commit -m "feat(discord): add reschedule voting deadline service" +``` + +--- + +## Task 9: Update Telegram Handlers for Source Platform + +**Files:** +- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs` +- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs` + +- [ ] **Step 1: Update InitiateRescheduleHandler** + +Change INSERT to include `source_platform`: +```sql +INSERT INTO reschedule_proposals (session_id, proposed_by, source_platform, status) +VALUES (@SessionId, @GmId, 'Telegram', 'AwaitingTime') +``` + +- [ ] **Step 2: Update HandleRescheduleTimeInputHandler** + +Change UPDATE to include `source_platform` backfill if null (or ensure it's set). Actually, `InitiateRescheduleHandler` already sets it. No change needed in time input handler. + +- [ ] **Step 3: Update RescheduleVotingDeadlineService to filter Telegram** + +Add `WHERE rp.source_platform = 'Telegram'` to the proposal query. + +- [ ] **Step 4: Run all Telegram tests** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reschedule" --verbosity normal` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/GmRelay.Bot/Features/Sessions/RescheduleSession/ +git commit -m "fix(telegram): annotate reschedule proposals with source_platform" +``` + +--- + +## Task 10: Version Bump + +**Files:** +- Modify: `Directory.Build.props` +- Modify: `compose.yaml` +- Modify: `.gitea/workflows/deploy.yml` +- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` + +- [ ] **Step 1: Bump version 2.5.0 → 2.6.0 in all 4 files** + +- [ ] **Step 2: Commit** + +```bash +git add Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor +git commit -m "chore(release): bump version to 2.6.0" +``` + +--- + +## Task 11: Build and Test Verification + +- [ ] **Step 1: Full build** + +Run: `dotnet build` +Expected: Success. + +- [ ] **Step 2: Run all tests** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal` +Expected: All pass. + +- [ ] **Step 3: Format check** + +Run: `dotnet format --verify-no-changes --verbosity diagnostic` +Expected: Clean. + +- [ ] **Step 4: Commit fixes if any** + +```bash +git add -A +git commit -m "style: apply dotnet format" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- Discord UI for 2-3 time options + deadline: covered by `/reschedule` slash command with option1/option2/option3/deadline params ✓ +- Persisted votes via existing model: covered by `DiscordRescheduleVoteHandler` using `reschedule_option_votes` ✓ +- Winner selection and session update: covered by `RescheduleVotingFinalizer` (shared) + `DiscordRescheduleVotingDeadlineService` ✓ +- Update Discord schedule message after voting: covered by `TryUpdateBatchScheduleAsync` in deadline service ✓ +- Telegram flow does not regress: covered by keeping all Telegram handlers intact, adding `source_platform = 'Telegram'` ✓ + +**2. Placeholder scan:** +- No TBD/TODO/fill-in-details found. All code shown explicitly. ✓ + +**3. Type consistency:** +- `RescheduleOptionDto`, `RescheduleVoteDecision`, etc. used consistently across all tasks. ✓ + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-05-20-discord-reschedule-voting.md`.** + +**Two execution options:** + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints for review. + +**Which approach?** diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs index 940df70..c9bdb3c 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs @@ -1,6 +1,7 @@ using Dapper; using GmRelay.Bot.Features.Notifications; using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; using GmRelay.Shared.Rendering; using Npgsql; @@ -17,12 +18,6 @@ internal sealed record AwaitingProposalDto( Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt, Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode); -internal sealed record VoteParticipantDto( - Guid PlayerId, - string DisplayName, - string? TelegramUsername, - long TelegramId = 0); - // ── Handler ────────────────────────────────────────────────────────── /// diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs index 6915afe..a57ae68 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs @@ -1,5 +1,6 @@ using Dapper; using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; using Npgsql; using Telegram.Bot; diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs index 840591e..a60df94 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs @@ -2,6 +2,7 @@ using Dapper; using GmRelay.Bot.Features.Notifications; using GmRelay.Bot.Infrastructure.Scheduling; using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; using GmRelay.Shared.Rendering; using Npgsql; diff --git a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleDtos.cs b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleDtos.cs new file mode 100644 index 0000000..1e31194 --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleDtos.cs @@ -0,0 +1,22 @@ +namespace GmRelay.Shared.Features.Sessions.RescheduleSession; + +public sealed record RescheduleOptionDto( + Guid OptionId, + int DisplayOrder, + DateTimeOffset ProposedAt); + +public sealed record RescheduleOptionVoteDto( + Guid OptionId, + Guid PlayerId, + string DisplayName, + string? TelegramUsername); + +public sealed record RescheduleOptionVoteCount( + Guid OptionId, + int VoteCount); + +public sealed record VoteParticipantDto( + Guid PlayerId, + string DisplayName, + string? TelegramUsername, + long TelegramId = 0); diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs similarity index 60% rename from src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs rename to src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs index 01d8d86..14f11a1 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs +++ b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVoteRules.cs @@ -1,13 +1,13 @@ -namespace GmRelay.Bot.Features.Sessions.RescheduleSession; +namespace GmRelay.Shared.Features.Sessions.RescheduleSession; -internal enum RescheduleVoteOutcome +public enum RescheduleVoteOutcome { Pending, Rejected, Approved } -internal sealed record RescheduleVoteDecision( +public sealed record RescheduleVoteDecision( RescheduleVoteOutcome Outcome, string Reason, Guid? SelectedOptionId = null, @@ -15,7 +15,7 @@ internal sealed record RescheduleVoteDecision( bool ShouldRescheduleSession = false, bool ShouldResetParticipantRsvps = false); -internal static class RescheduleVoteRules +public static class RescheduleVoteRules { public static RescheduleVoteDecision SelectWinner(IReadOnlyList voteCounts) { @@ -49,8 +49,8 @@ internal static class RescheduleVoteRules { return new RescheduleVoteDecision( Outcome: RescheduleVoteOutcome.Rejected, - Reason: "\u041e\u0434\u0438\u043d \u0438\u0437 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u043e\u0432 \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b \u043f\u0435\u0440\u0435\u043d\u043e\u0441.", - CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430."); + Reason: "Один из участников отклонил перенос.", + CallbackText: "Вы проголосовали против переноса."); } var everyoneApproved = approvedParticipants == totalParticipants; @@ -58,11 +58,11 @@ internal static class RescheduleVoteRules return new RescheduleVoteDecision( Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending, Reason: everyoneApproved - ? "\u0412\u0441\u0435 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u0438 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b." - : "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0430\u0435\u0442\u0441\u044f.", + ? "Все участники согласны." + : "Голосование продолжается.", CallbackText: everyoneApproved - ? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e." - : "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!", + ? "Вы подтвердили перенос! Все согласны — время обновлено." + : "Вы подтвердили перенос!", ShouldRescheduleSession: everyoneApproved, ShouldResetParticipantRsvps: everyoneApproved); } diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs similarity index 86% rename from src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs rename to src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs index 32de365..78d8ff3 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs +++ b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingInput.cs @@ -1,9 +1,9 @@ using System.Text.RegularExpressions; using GmRelay.Shared.Domain; -namespace GmRelay.Bot.Features.Sessions.RescheduleSession; +namespace GmRelay.Shared.Features.Sessions.RescheduleSession; -internal sealed record RescheduleVotingInput( +public sealed record RescheduleVotingInput( IReadOnlyList Options, DateTimeOffset Deadline) { @@ -93,18 +93,3 @@ internal sealed record RescheduleVotingInput( || normalized.StartsWith("до:", StringComparison.Ordinal); } } - -internal sealed record RescheduleOptionDto( - Guid OptionId, - int DisplayOrder, - DateTimeOffset ProposedAt); - -internal sealed record RescheduleOptionVoteDto( - Guid OptionId, - Guid PlayerId, - string DisplayName, - string? TelegramUsername); - -internal sealed record RescheduleOptionVoteCount( - Guid OptionId, - int VoteCount); diff --git a/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs b/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs index ba1eb7f..bfe3801 100644 --- a/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs @@ -1,6 +1,7 @@ using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Rendering; using Telegram.Bot.Types.ReplyMarkups; using GmRelay.Bot.Infrastructure.Telegram; diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandlerTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandlerTests.cs index 916f612..28fb875 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandlerTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandlerTests.cs @@ -1,4 +1,5 @@ using GmRelay.Bot.Features.Sessions.RescheduleSession; +using GmRelay.Shared.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession; diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/RescheduleVoteRulesTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/RescheduleVoteRulesTests.cs index 91ec229..4c34c9c 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/RescheduleVoteRulesTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/RescheduleSession/RescheduleVoteRulesTests.cs @@ -1,4 +1,4 @@ -using GmRelay.Bot.Features.Sessions.RescheduleSession; +using GmRelay.Shared.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession; From a13edf20afb13b75017ebaf3f323b2c4de6a267b Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 11:54:53 +0300 Subject: [PATCH 03/12] feat(shared): add RescheduleVotingFinalizer and ISystemClock --- .../RescheduleVotingDeadlineService.cs | 272 +++++------------- .../Infrastructure/Scheduling/ISystemClock.cs | 7 +- .../Scheduling/SessionSchedulerService.cs | 1 + src/GmRelay.Bot/Program.cs | 2 + .../RescheduleVotingFinalizer.cs | 214 ++++++++++++++ src/GmRelay.Shared/Platform/ISystemClock.cs | 6 + .../TelegramTopicIntegrationSmokeTests.cs | 2 +- 7 files changed, 298 insertions(+), 206 deletions(-) create mode 100644 src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs create mode 100644 src/GmRelay.Shared/Platform/ISystemClock.cs diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs index a60df94..ffd21e8 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs @@ -1,6 +1,5 @@ using Dapper; using GmRelay.Bot.Features.Notifications; -using GmRelay.Bot.Infrastructure.Scheduling; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; @@ -12,25 +11,18 @@ using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Features.Sessions.RescheduleSession; -internal sealed record DueRescheduleProposalDto( - Guid Id, - Guid SessionId, - DateTimeOffset VotingDeadlineAt, - string Title, - DateTime CurrentScheduledAt, - Guid BatchId, - int? BatchMessageId, +internal sealed record TelegramProposalFieldsDto( int? VoteMessageId, + int? BatchMessageId, long TelegramChatId, - int? ThreadId, - string NotificationMode); + int? ThreadId); public sealed class RescheduleVotingDeadlineService( NpgsqlDataSource dataSource, ITelegramBotClient bot, IPlatformMessenger messenger, DirectSessionNotificationSender directSender, - ISystemClock clock, + RescheduleVotingFinalizer finalizer, ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -54,18 +46,7 @@ public sealed class RescheduleVotingDeadlineService( { try { - await using var connection = await dataSource.OpenConnectionAsync(ct); - var proposalIds = (await connection.QueryAsync( - """ - SELECT id - FROM reschedule_proposals - WHERE status = 'Voting' - AND voting_deadline_at IS NOT NULL - AND voting_deadline_at <= @Now - ORDER BY voting_deadline_at - LIMIT 25 - """, - new { Now = clock.UtcNow.UtcDateTime })).ToList(); + var proposalIds = await finalizer.GetDueProposalIdsAsync(ct); foreach (var proposalId in proposalIds) { @@ -83,212 +64,105 @@ public sealed class RescheduleVotingDeadlineService( private async Task FinalizeProposal(Guid proposalId, CancellationToken ct) { - await using var connection = await dataSource.OpenConnectionAsync(ct); - await using var transaction = await connection.BeginTransactionAsync(ct); + var result = await finalizer.FinalizeAsync(proposalId, ct); + if (result is null) + return; - var proposal = await connection.QuerySingleOrDefaultAsync( + if (result.SourcePlatform != "Telegram") + { + logger.LogInformation( + "Skipping Telegram message handling for proposal {ProposalId} with source platform {SourcePlatform}", + proposalId, + result.SourcePlatform); + return; + } + + await using var connection = await dataSource.OpenConnectionAsync(ct); + var telegramFields = await connection.QuerySingleOrDefaultAsync( """ - SELECT rp.id AS Id, - rp.session_id AS SessionId, - rp.voting_deadline_at AS VotingDeadlineAt, - rp.vote_message_id AS VoteMessageId, - s.title AS Title, - s.scheduled_at AS CurrentScheduledAt, - s.batch_id AS BatchId, + SELECT rp.vote_message_id AS VoteMessageId, s.batch_message_id AS BatchMessageId, - s.notification_mode AS NotificationMode, - s.thread_id AS ThreadId, - g.telegram_chat_id AS TelegramChatId + g.telegram_chat_id AS TelegramChatId, + s.thread_id AS ThreadId 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.id = @ProposalId - AND rp.status = 'Voting' - AND rp.voting_deadline_at IS NOT NULL - AND rp.voting_deadline_at <= @Now - FOR UPDATE """, - new { ProposalId = proposalId, Now = clock.UtcNow.UtcDateTime }, - transaction); + new { ProposalId = proposalId }); - if (proposal is null) + if (telegramFields is null) + { + logger.LogWarning("Could not find Telegram fields for proposal {ProposalId}", proposalId); return; - - var participants = (await connection.QueryAsync( - """ - SELECT p.id AS PlayerId, - p.display_name AS DisplayName, - p.telegram_username AS TelegramUsername, - p.telegram_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.telegram_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(); - - var voteCounts = options - .Select(option => new RescheduleOptionVoteCount( - option.OptionId, - votes.Count(vote => vote.OptionId == option.OptionId))) - .ToList(); - var decision = RescheduleVoteRules.SelectWinner(voteCounts); - var selectedOption = decision.SelectedOptionId is { } selectedOptionId - ? options.Single(x => x.OptionId == selectedOptionId) - : null; - - if (selectedOption is not null) - { - await connection.ExecuteAsync( - """ - UPDATE sessions - SET scheduled_at = @NewTime, - status = @Status, - confirmation_message_id = NULL, - confirmation_sent_at = NULL, - link_message_id = NULL, - one_hour_reminder_processed_at = NULL, - updated_at = now() - WHERE id = @SessionId - """, - new { NewTime = selectedOption.ProposedAt, proposal.SessionId, Status = SessionStatus.Planned }, - transaction); - - await connection.ExecuteAsync( - """ - UPDATE session_participants - SET rsvp_status = 'Pending', - responded_at = NULL - WHERE session_id = @SessionId - AND is_gm = false - AND registration_status = @Active - """, - new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, - transaction); - - await connection.ExecuteAsync( - """ - UPDATE reschedule_proposals - SET status = 'Approved', - selected_option_id = @SelectedOptionId, - proposed_at = @ProposedAt - WHERE id = @ProposalId - """, - new - { - ProposalId = proposal.Id, - SelectedOptionId = selectedOption.OptionId, - ProposedAt = selectedOption.ProposedAt - }, - transaction); - } - else - { - await connection.ExecuteAsync( - "UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId", - new { ProposalId = proposal.Id }, - transaction); } - var directRecipients = participants + var directRecipients = result.Participants .Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)) .ToList(); - await transaction.CommitAsync(ct); + await TryUpdateVoteMessage(result, telegramFields, ct); - await TryUpdateVoteMessage(proposal, options, participants, votes, decision, selectedOption, ct); - - if (selectedOption is not null) + if (result.SelectedOption is not null) { - await TryUpdateBatchMessage(proposal, ct); + await TryUpdateBatchMessage(result, telegramFields, ct); } - var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode); + var mode = SessionNotificationModeExtensions.FromDatabaseValue(result.NotificationMode); if (mode.ShouldSendDirectMessages()) { - await SendDirectResult(proposal, directRecipients, decision, selectedOption, ct); + await SendDirectResult(result, directRecipients, ct); } logger.LogInformation( - "Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}", - proposal.Id, - proposal.SessionId, - decision.Outcome); + "Updated Telegram messages for finalized reschedule proposal {ProposalId} for session {SessionId}", + result.ProposalId, + result.SessionId); } private async Task TryUpdateVoteMessage( - DueRescheduleProposalDto proposal, - IReadOnlyList options, - IReadOnlyList participants, - IReadOnlyList votes, - RescheduleVoteDecision decision, - RescheduleOptionDto? selectedOption, + RescheduleVotingFinalizerResult result, + TelegramProposalFieldsDto telegramFields, CancellationToken ct) { - if (proposal.VoteMessageId is null) + if (telegramFields.VoteMessageId is null) return; try { - var resultText = selectedOption is not null - ? $"✅ Голосование завершено.\nПобедил вариант {selectedOption.DisplayOrder}: {selectedOption.ProposedAt.FormatMoscow()} (МСК)." - : $"❌ Голосование завершено.\n{System.Net.WebUtility.HtmlEncode(decision.Reason)}"; + var resultText = result.SelectedOption is not null + ? $"✅ Голосование завершено.\nПобедил вариант {result.SelectedOption.DisplayOrder}: {result.SelectedOption.ProposedAt.FormatMoscow()} (МСК)." + : $"❌ Голосование завершено.\n{System.Net.WebUtility.HtmlEncode(result.Decision.Reason)}"; var text = $""" {HandleRescheduleTimeInputHandler.BuildVotingMessage( - proposal.Title, - proposal.CurrentScheduledAt, - proposal.VotingDeadlineAt, - options, - participants, - votes)} + result.Title, + result.CurrentScheduledAt, + result.VotingDeadlineAt, + result.Options, + result.Participants, + result.Votes)} {resultText} """; await bot.EditMessageText( - chatId: proposal.TelegramChatId, - messageId: proposal.VoteMessageId.Value, + chatId: telegramFields.TelegramChatId, + messageId: telegramFields.VoteMessageId.Value, text: text, parseMode: ParseMode.Html, cancellationToken: ct); } catch (Exception ex) { - logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", proposal.Id); + logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", result.ProposalId); } } - private async Task TryUpdateBatchMessage(DueRescheduleProposalDto proposal, CancellationToken ct) + private async Task TryUpdateBatchMessage( + RescheduleVotingFinalizerResult result, + TelegramProposalFieldsDto telegramFields, + CancellationToken ct) { try { @@ -296,7 +170,7 @@ public sealed class RescheduleVotingDeadlineService( 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(); + new { result.BatchId })).ToList(); var batchParticipants = (await connection.QueryAsync( """ @@ -310,60 +184,58 @@ public sealed class RescheduleVotingDeadlineService( 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(); + new { result.BatchId })).ToList(); - if (proposal.BatchMessageId.HasValue) + if (telegramFields.BatchMessageId.HasValue) { - var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants); + var view = SessionBatchViewBuilder.Build(result.Title, batchSessions, batchParticipants); await messenger.UpdateScheduleAsync( new PlatformScheduleMessage( - TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId), + TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId), view, - TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)), + TelegramPlatformIds.Message(telegramFields.TelegramChatId, telegramFields.ThreadId, telegramFields.BatchMessageId.Value)), ct); } else { await messenger.SendGroupMessageAsync( - TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId), - $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».", + TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId), + $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(result.Title)}».", ct); } } catch (Exception ex) { - logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", proposal.Id); + logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", result.ProposalId); } } private async Task SendDirectResult( - DueRescheduleProposalDto proposal, + RescheduleVotingFinalizerResult result, IReadOnlyList recipients, - RescheduleVoteDecision decision, - RescheduleOptionDto? selectedOption, CancellationToken ct) { - var htmlText = selectedOption is not null + var htmlText = result.SelectedOption is not null ? $""" ✅ Сессия перенесена по итогам голосования - 📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)} - 📅 Новое время: {selectedOption.ProposedAt.FormatMoscow()} (МСК) + 📌 {System.Net.WebUtility.HtmlEncode(result.Title)} + 📅 Новое время: {result.SelectedOption.ProposedAt.FormatMoscow()} (МСК) """ : $""" ❌ Перенос сессии отклонён по итогам голосования - 📌 {System.Net.WebUtility.HtmlEncode(proposal.Title)} - 📅 Время остаётся прежним: {proposal.CurrentScheduledAt.FormatMoscow()} (МСК) - Причина: {System.Net.WebUtility.HtmlEncode(decision.Reason)} + 📌 {System.Net.WebUtility.HtmlEncode(result.Title)} + 📅 Время остаётся прежним: {result.CurrentScheduledAt.FormatMoscow()} (МСК) + Причина: {System.Net.WebUtility.HtmlEncode(result.Decision.Reason)} """; await directSender.SendAsync( recipients, htmlText, - selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected", - proposal.SessionId, + result.SelectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected", + result.SessionId, ct); } } diff --git a/src/GmRelay.Bot/Infrastructure/Scheduling/ISystemClock.cs b/src/GmRelay.Bot/Infrastructure/Scheduling/ISystemClock.cs index 46e0888..eb87a64 100644 --- a/src/GmRelay.Bot/Infrastructure/Scheduling/ISystemClock.cs +++ b/src/GmRelay.Bot/Infrastructure/Scheduling/ISystemClock.cs @@ -1,9 +1,6 @@ -namespace GmRelay.Bot.Infrastructure.Scheduling; +using GmRelay.Shared.Platform; -public interface ISystemClock -{ - DateTimeOffset UtcNow { get; } -} +namespace GmRelay.Bot.Infrastructure.Scheduling; public sealed class SystemClock : ISystemClock { diff --git a/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs b/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs index 31b227a..c7e89ee 100644 --- a/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs +++ b/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs @@ -1,6 +1,7 @@ using GmRelay.Bot.Features.Confirmation.SendConfirmation; using GmRelay.Bot.Features.Reminders.SendJoinLink; using GmRelay.Bot.Features.Reminders.SendOneHourReminder; +using GmRelay.Shared.Platform; namespace GmRelay.Bot.Infrastructure.Scheduling; diff --git a/src/GmRelay.Bot/Program.cs b/src/GmRelay.Bot/Program.cs index 38abc3e..c47606f 100644 --- a/src/GmRelay.Bot/Program.cs +++ b/src/GmRelay.Bot/Program.cs @@ -6,6 +6,7 @@ using GmRelay.Bot.Features.Reminders.SendOneHourReminder; using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Bot.Infrastructure.Database; +using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Bot.Infrastructure.Health; using GmRelay.Bot.Infrastructure.Logging; using GmRelay.Bot.Infrastructure.Scheduling; @@ -75,6 +76,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // ── Telegram infrastructure ────────────────────────────────────────── builder.Services.AddSingleton(); diff --git a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs new file mode 100644 index 0000000..8942b4b --- /dev/null +++ b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs @@ -0,0 +1,214 @@ +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Platform; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace GmRelay.Shared.Features.Sessions.RescheduleSession; + +public sealed record RescheduleVotingFinalizerResult( + Guid ProposalId, + Guid SessionId, + string Title, + DateTime CurrentScheduledAt, + Guid BatchId, + string NotificationMode, + string SourcePlatform, + DateTimeOffset VotingDeadlineAt, + RescheduleVoteDecision Decision, + RescheduleOptionDto? SelectedOption, + IReadOnlyList Options, + IReadOnlyList Votes, + IReadOnlyList Participants); + +public sealed class RescheduleVotingFinalizer( + NpgsqlDataSource dataSource, + ISystemClock clock, + ILogger logger) +{ + public async Task> GetDueProposalIdsAsync(CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + var proposalIds = (await connection.QueryAsync( + """ + SELECT id + FROM reschedule_proposals + WHERE status = 'Voting' + AND voting_deadline_at IS NOT NULL + AND voting_deadline_at <= @Now + ORDER BY voting_deadline_at + LIMIT 25 + """, + new { Now = clock.UtcNow.UtcDateTime })).ToList(); + + return proposalIds; + } + + public async Task FinalizeAsync(Guid proposalId, 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 ProposalId, + rp.session_id AS SessionId, + rp.voting_deadline_at AS VotingDeadlineAt, + rp.source_platform AS SourcePlatform, + s.title AS Title, + s.scheduled_at AS CurrentScheduledAt, + s.batch_id AS BatchId, + s.notification_mode AS NotificationMode + FROM reschedule_proposals rp + JOIN sessions s ON s.id = rp.session_id + WHERE rp.id = @ProposalId + AND rp.status = 'Voting' + AND rp.voting_deadline_at IS NOT NULL + AND rp.voting_deadline_at <= @Now + FOR UPDATE + """, + new { ProposalId = proposalId, Now = clock.UtcNow.UtcDateTime }, + transaction); + + if (proposal is null) + return null; + + var participants = (await connection.QueryAsync( + """ + SELECT p.id AS PlayerId, + p.display_name AS DisplayName, + p.telegram_username AS TelegramUsername, + p.telegram_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.ProposalId }, + transaction)).ToList(); + + var votes = (await connection.QueryAsync( + """ + SELECT rov.option_id AS OptionId, + p.id AS PlayerId, + p.display_name AS DisplayName, + p.telegram_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.ProposalId }, + transaction)).ToList(); + + var voteCounts = options + .Select(option => new RescheduleOptionVoteCount( + option.OptionId, + votes.Count(vote => vote.OptionId == option.OptionId))) + .ToList(); + var decision = RescheduleVoteRules.SelectWinner(voteCounts); + var selectedOption = decision.SelectedOptionId is { } selectedOptionId + ? options.Single(x => x.OptionId == selectedOptionId) + : null; + + if (selectedOption is not null) + { + await connection.ExecuteAsync( + """ + UPDATE sessions + SET scheduled_at = @NewTime, + status = @Status, + confirmation_message_id = NULL, + confirmation_sent_at = NULL, + link_message_id = NULL, + one_hour_reminder_processed_at = NULL, + updated_at = now() + WHERE id = @SessionId + """, + new { NewTime = selectedOption.ProposedAt, proposal.SessionId, Status = SessionStatus.Planned }, + transaction); + + await connection.ExecuteAsync( + """ + UPDATE session_participants + SET rsvp_status = 'Pending', + responded_at = NULL + WHERE session_id = @SessionId + AND is_gm = false + AND registration_status = @Active + """, + new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, + transaction); + + await connection.ExecuteAsync( + """ + UPDATE reschedule_proposals + SET status = 'Approved', + selected_option_id = @SelectedOptionId, + proposed_at = @ProposedAt + WHERE id = @ProposalId + """, + new + { + ProposalId = proposal.ProposalId, + SelectedOptionId = selectedOption.OptionId, + ProposedAt = selectedOption.ProposedAt + }, + transaction); + } + else + { + await connection.ExecuteAsync( + "UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId", + new { ProposalId = proposal.ProposalId }, + transaction); + } + + await transaction.CommitAsync(ct); + + logger.LogInformation( + "Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}", + proposal.ProposalId, + proposal.SessionId, + decision.Outcome); + + return new RescheduleVotingFinalizerResult( + proposal.ProposalId, + proposal.SessionId, + proposal.Title, + proposal.CurrentScheduledAt, + proposal.BatchId, + proposal.NotificationMode, + proposal.SourcePlatform, + proposal.VotingDeadlineAt, + decision, + selectedOption, + options, + votes, + participants); + } + + private sealed record ProposalRow( + Guid ProposalId, + Guid SessionId, + DateTimeOffset VotingDeadlineAt, + string SourcePlatform, + string Title, + DateTime CurrentScheduledAt, + Guid BatchId, + string NotificationMode); +} diff --git a/src/GmRelay.Shared/Platform/ISystemClock.cs b/src/GmRelay.Shared/Platform/ISystemClock.cs new file mode 100644 index 0000000..a0d0a65 --- /dev/null +++ b/src/GmRelay.Shared/Platform/ISystemClock.cs @@ -0,0 +1,6 @@ +namespace GmRelay.Shared.Platform; + +public interface ISystemClock +{ + DateTimeOffset UtcNow { get; } +} diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs index 7d0a3ed..380bb31 100644 --- a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramTopicIntegrationSmokeTests.cs @@ -54,7 +54,7 @@ public sealed class TelegramTopicIntegrationSmokeTests Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); - Assert.Contains("TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId)", rescheduleDeadlineService, StringComparison.Ordinal); + Assert.Contains("TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId)", rescheduleDeadlineService, StringComparison.Ordinal); } private static async Task ReadRepositoryFileAsync(string relativePath) From e93e777fb38379584364d3e2ffb2aa1547160635 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 12:02:26 +0300 Subject: [PATCH 04/12] feat(discord): add /reschedule slash command and handler --- .../Sessions/DiscordRescheduleCommand.cs | 117 +++++++++++ .../Sessions/DiscordRescheduleHandler.cs | 192 ++++++++++++++++++ src/GmRelay.DiscordBot/Program.cs | 1 + 3 files changed, 310 insertions(+) create mode 100644 src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs create mode 100644 src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs new file mode 100644 index 0000000..df31f7c --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs @@ -0,0 +1,117 @@ +namespace GmRelay.DiscordBot.Features.Sessions; + +using NetCord.Rest; +using NetCord.Services.ApplicationCommands; + +[SlashCommand("reschedule", "Initiate reschedule voting for a session")] +public class DiscordRescheduleCommand : ApplicationCommandModule +{ + private readonly DiscordRescheduleHandler _handler; + private readonly ILogger _logger; + + public DiscordRescheduleCommand(DiscordRescheduleHandler handler, ILogger logger) + { + _handler = handler; + _logger = logger; + } + + public async Task ExecuteAsync( + [SlashCommandParameter(Name = "session", Description = "Session ID to reschedule")] string sessionIdText, + [SlashCommandParameter(Name = "option1", Description = "First time option (YYYY-MM-DD HH:mm)")] string option1, + [SlashCommandParameter(Name = "option2", Description = "Second time option (YYYY-MM-DD HH:mm)")] string option2, + [SlashCommandParameter(Name = "option3", Description = "Third time option (optional)")] string? option3 = null, + [SlashCommandParameter(Name = "deadline", Description = "Voting deadline (YYYY-MM-DD HH:mm)")] string deadline = "") + { + var guild = Context.Guild + ?? throw new InvalidOperationException("This command can only be used in a guild."); + + if (!Guid.TryParse(sessionIdText, out var sessionId)) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message("❌ Некорректный ID сессии.")); + return; + } + + var options = new List { option1, option2 }; + if (!string.IsNullOrWhiteSpace(option3)) + options.Add(option3); + + var parsedOptions = new List(); + foreach (var opt in options) + { + var result = DiscordNewSessionHandler.ParseTimeInput(opt); + if (!result.IsSuccess) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message($"❌ {opt}: {result.Error}")); + return; + } + parsedOptions.Add(result.Value); + } + + var deadlineResult = DiscordNewSessionHandler.ParseTimeInput(deadline); + if (!deadlineResult.IsSuccess) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message($"❌ Дедлайн: {deadlineResult.Error}")); + return; + } + + if (deadlineResult.Value >= parsedOptions.Min()) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message("❌ Дедлайн должен быть раньше первого варианта времени.")); + return; + } + + var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id); + + try + { + var result = await _handler.HandleAsync( + guildId: guild.Id.ToString(), + channelId: Context.Channel.Id.ToString(), + userId: Context.User.Id, + userDisplayName: Context.User.GlobalName ?? Context.User.Username, + resolvedPermissions: resolvedPermissions, + guildOwnerId: guild.OwnerId, + sessionId: sessionId, + options: parsedOptions, + deadline: deadlineResult.Value, + CancellationToken.None); + + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message( + $"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC.")); + } + catch (UnauthorizedAccessException ex) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message($":no_entry: {ex.Message}")); + } + catch (InvalidOperationException ex) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message($":warning: {ex.Message}")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initiate reschedule for session {SessionId}", sessionId); + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message(":boom: Ошибка при запуске голосования.")); + } + } + + private static ulong GetResolvedPermissions(NetCord.Gateway.Guild guild, ulong userId) + { + if (!guild.Users.TryGetValue(userId, out var guildUser)) + return 0; + ulong resolved = 0; + foreach (var roleId in guildUser.RoleIds) + { + if (guild.Roles.TryGetValue(roleId, out var role)) + resolved |= (ulong)role.Permissions; + } + return resolved; + } +} diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs new file mode 100644 index 0000000..9ace0e2 --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs @@ -0,0 +1,192 @@ +namespace GmRelay.DiscordBot.Features.Sessions; + +using Dapper; +using GmRelay.DiscordBot.Infrastructure.Discord; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; +using GmRelay.Shared.Platform; +using NetCord; +using NetCord.Rest; +using Npgsql; + +public sealed record DiscordRescheduleResult(Guid ProposalId, IReadOnlyList Options, DateTimeOffset Deadline); + +public sealed class DiscordRescheduleHandler( + NpgsqlDataSource dataSource, + DiscordPermissionChecker permissionChecker, + RestClient restClient, + ILogger logger) +{ + public async Task HandleAsync( + string guildId, + string channelId, + ulong userId, + string userDisplayName, + ulong resolvedPermissions, + ulong guildOwnerId, + Guid sessionId, + IReadOnlyList options, + DateTimeOffset deadline, + CancellationToken ct) + { + // 1. Permission check + read-only validation (before Discord message) + await using var readConnection = await dataSource.OpenConnectionAsync(ct); + + var dbManagerUserIds = await readConnection.QueryAsync( + @"SELECT CAST(p.external_user_id AS BIGINT) + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + JOIN game_groups g ON g.id = gm.group_id + WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId", + new { GuildId = guildId }); + + if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions)) + { + throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут переносить сессии."); + } + + // 2. Ensure player exists + await readConnection.ExecuteAsync( + @"INSERT INTO players (display_name, platform, external_user_id, external_username) + VALUES (@Name, 'Discord', @UserId, @Name) + 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", + new { Name = userDisplayName, UserId = userId.ToString() }); + + // 3. Verify session exists + var session = await readConnection.QuerySingleOrDefaultAsync( + """ + SELECT s.title AS Title, s.scheduled_at AS CurrentScheduledAt + FROM sessions s + WHERE s.id = @SessionId AND s.status != @Cancelled + """, + new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled }); + + if (session is null) + throw new InvalidOperationException("Сессия не найдена или отменена."); + + // 4. Check no active proposal + var hasActive = await readConnection.ExecuteScalarAsync( + "SELECT EXISTS (SELECT 1 FROM reschedule_proposals WHERE session_id = @SessionId AND status IN ('AwaitingTime', 'Voting'))", + new { SessionId = sessionId }); + + if (hasActive) + throw new InvalidOperationException("Уже есть активный запрос на перенос этой сессии."); + + // 5. Load participants for rendering + var participants = (await readConnection.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 + """, + new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); + + // 6. Prepare proposal data + var proposalId = Guid.NewGuid(); + var optionDtos = options.Select((o, i) => new RescheduleOptionDto(Guid.NewGuid(), i + 1, o)).ToList(); + + // 7. Build and send Discord vote message BEFORE transaction + var (embed, actionRow) = BuildVoteMessage(session.Title, session.CurrentScheduledAt, deadline, optionDtos, participants, []); + + var channelIdUlong = ulong.Parse(channelId); + + // NOTE: Discord message is sent before DB transaction to avoid orphaned proposals + // if the send fails. There is a negligible race window where the message is visible + // before the DB commit; in practice users cannot click faster than the transaction commits. + var sentMessage = await restClient.SendMessageAsync( + channelIdUlong, + new MessageProperties() + .WithEmbeds(new[] { embed }) + .WithComponents(new[] { actionRow })); + + // 8. Create proposal + options + platform_messages in transaction + try + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + + await connection.ExecuteAsync( + """ + INSERT INTO reschedule_proposals (id, session_id, proposed_by, source_platform, proposed_by_external_user_id, status, voting_deadline_at) + VALUES (@Id, @SessionId, NULL, 'Discord', @ProposedBy, 'Voting', @Deadline) + """, + new { Id = proposalId, SessionId = sessionId, ProposedBy = userId.ToString(), Deadline = deadline.UtcDateTime }, + transaction); + + foreach (var option in optionDtos) + { + await connection.ExecuteAsync( + """ + INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order) + VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder) + """, + new { option.OptionId, ProposalId = proposalId, option.ProposedAt, option.DisplayOrder }, + transaction); + } + + await connection.ExecuteAsync( + """ + INSERT INTO platform_messages (platform, group_id, session_id, external_channel_id, external_message_id, purpose) + VALUES ('Discord', (SELECT id FROM game_groups WHERE platform = 'Discord' AND external_group_id = @GuildId), @SessionId, @ChannelId, @MessageId, 'reschedule_vote') + """, + new { GuildId = guildId, SessionId = sessionId, ChannelId = channelId, MessageId = sentMessage.Id.ToString() }, + transaction); + + await transaction.CommitAsync(ct); + } + catch (Exception ex) + { + logger.LogError(ex, "Transaction failed after Discord message sent; deleting orphaned message"); + try { await restClient.DeleteMessageAsync(channelIdUlong, sentMessage.Id); } catch { /* best effort */ } + throw; + } + + logger.LogInformation("Discord reschedule voting started for session {SessionId}, proposal {ProposalId}", sessionId, proposalId); + + return new DiscordRescheduleResult(proposalId, optionDtos, deadline); + } + + private static (EmbedProperties Embed, ActionRowProperties ActionRow) BuildVoteMessage( + string title, DateTime currentTime, DateTimeOffset deadline, + IReadOnlyList options, IReadOnlyList participants, IReadOnlyList votes) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)"); + sb.AppendLine($"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)"); + sb.AppendLine(); + sb.AppendLine("Выберите один из вариантов:"); + + foreach (var option in options.OrderBy(o => o.DisplayOrder)) + { + var count = votes.Count(v => v.OptionId == option.OptionId); + sb.AppendLine($"{option.DisplayOrder}. **{option.ProposedAt.FormatMoscow()}** (МСК) — {count} голосов"); + } + + if (participants.Count > 0) + { + sb.AppendLine(); + sb.AppendLine($"Голосов: {votes.Count}/{participants.Count}"); + } + + var embed = new EmbedProperties() + .WithTitle($"🔄 Перенос сессии «{title}»") + .WithDescription(sb.ToString()) + .WithColor(new NetCord.Color(0xFEE75C)); + + var actionRow = new ActionRowProperties(); + foreach (var option in options.OrderBy(o => o.DisplayOrder)) + { + actionRow.Add(new ButtonProperties( + $"reschedule_vote:{option.OptionId}", + $"{option.DisplayOrder}. {option.ProposedAt.ToOffset(TimeSpan.FromHours(3)):dd.MM HH:mm}", + ButtonStyle.Primary)); + } + + return (embed, actionRow); + } +} + +internal sealed record RescheduleSessionInfoDto(string Title, DateTime CurrentScheduledAt); diff --git a/src/GmRelay.DiscordBot/Program.cs b/src/GmRelay.DiscordBot/Program.cs index 79ddff8..1e7f15a 100644 --- a/src/GmRelay.DiscordBot/Program.cs +++ b/src/GmRelay.DiscordBot/Program.cs @@ -44,6 +44,7 @@ builder.Services.AddSingleton(sp => builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); From fdfc73ae9c66f3619c3e58ad26c17bf0998418ff Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 12:17:58 +0300 Subject: [PATCH 05/12] feat(discord): add reschedule vote button handler --- .../HandleRescheduleVoteHandler.cs | 7 - .../Sessions/DiscordRescheduleHandler.cs | 3 +- .../Sessions/DiscordRescheduleVoteHandler.cs | 130 ++++++++++++++++++ .../DiscordSessionInteractionModule.cs | 36 +++++ src/GmRelay.DiscordBot/Program.cs | 1 + .../RescheduleSession/RescheduleDtos.cs | 7 + 6 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs index a57ae68..1e5d356 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs @@ -14,13 +14,6 @@ public sealed record HandleRescheduleVoteCommand( long ChatId, int MessageId); -internal sealed record VoteProposalDto( - Guid Id, - Guid SessionId, - DateTimeOffset VotingDeadlineAt, - string Title, - DateTime CurrentScheduledAt); - public sealed class HandleRescheduleVoteHandler( NpgsqlDataSource dataSource, ITelegramBotClient bot, diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs index 9ace0e2..c31de08 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs @@ -149,7 +149,8 @@ public sealed class DiscordRescheduleHandler( return new DiscordRescheduleResult(proposalId, optionDtos, deadline); } - private static (EmbedProperties Embed, ActionRowProperties ActionRow) BuildVoteMessage( + // internal for now — temporary duplication until DiscordRescheduleVotingRenderer is extracted in Task 6 + internal static (EmbedProperties Embed, ActionRowProperties ActionRow) BuildVoteMessage( string title, DateTime currentTime, DateTimeOffset deadline, IReadOnlyList options, IReadOnlyList participants, IReadOnlyList votes) { diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs new file mode 100644 index 0000000..95390ee --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs @@ -0,0 +1,130 @@ +namespace GmRelay.DiscordBot.Features.Sessions; + +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; +using GmRelay.Shared.Platform; +using Npgsql; +using NetCord.Rest; + +public sealed record DiscordRescheduleVoteInput( + Guid OptionId, ulong UserId, string InteractionId, + string GuildId, string ChannelId, string MessageId); + +public sealed class DiscordRescheduleVoteHandler( + NpgsqlDataSource dataSource, + 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); + + // 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); + + if (proposal is null) + return "Голосование уже завершено или не найдено."; + + 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) = DiscordRescheduleHandler.BuildVoteMessage( + proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt, + options, participants, votes); + + var channelIdUlong = ulong.Parse(input.ChannelId); + var messageIdUlong = ulong.Parse(input.MessageId); + + try + { + await restClient.ModifyMessageAsync(channelIdUlong, messageIdUlong, options => + { + options.Embeds = new[] { embed }; + options.Components = new[] { actionRow }; + }); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id); + } + + return "Ваш голос учтён. До дедлайна его можно изменить."; + } +} diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs index 11f46d1..0d15f18 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs @@ -9,6 +9,7 @@ namespace GmRelay.DiscordBot.Features.Sessions; public sealed class DiscordSessionInteractionModule( JoinSessionHandler joinSessionHandler, LeaveSessionHandler leaveSessionHandler, + DiscordRescheduleVoteHandler voteHandler, DiscordInteractionReplyCache interactionReplies, ILogger logger) : ComponentInteractionModule { @@ -66,6 +67,41 @@ public sealed class DiscordSessionInteractionModule( await CompleteWithStoredReplyAsync(input.InteractionId); } + [ComponentInteraction("reschedule_vote")] + public async Task RescheduleVoteAsync(string optionId) + { + if (!Guid.TryParse(optionId, out var parsedOptionId)) + { + await RespondAsync(CreateEphemeralReply("Vote button is outdated.")); + return; + } + + var input = CreateInput(Guid.Empty); // sessionId not needed for vote routing + var voteInput = new DiscordRescheduleVoteInput( + parsedOptionId, + Context.User.Id, + Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), + input.GuildId, + input.ChannelId, + input.MessageId); + + await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)); + + string replyText; + try + { + replyText = await voteHandler.HandleAsync(voteInput, CancellationToken.None); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to handle Discord reschedule vote for option {OptionId}", parsedOptionId); + await CompleteResponseAsync("Не удалось обработать голос."); + return; + } + + await CompleteResponseAsync(replyText); + } + private DiscordSessionInteractionInput CreateInput(Guid sessionId) { var guild = Context.Guild diff --git a/src/GmRelay.DiscordBot/Program.cs b/src/GmRelay.DiscordBot/Program.cs index 1e7f15a..14817c8 100644 --- a/src/GmRelay.DiscordBot/Program.cs +++ b/src/GmRelay.DiscordBot/Program.cs @@ -45,6 +45,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/RescheduleSession/RescheduleDtos.cs b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleDtos.cs index 1e31194..711608f 100644 --- a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleDtos.cs +++ b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleDtos.cs @@ -5,6 +5,13 @@ public sealed record RescheduleOptionDto( int DisplayOrder, DateTimeOffset ProposedAt); +public sealed record VoteProposalDto( + Guid Id, + Guid SessionId, + DateTimeOffset VotingDeadlineAt, + string Title, + DateTime CurrentScheduledAt); + public sealed record RescheduleOptionVoteDto( Guid OptionId, Guid PlayerId, From 9712fe125be4be39ece6b57fbd96451952e1d660 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 12:23:25 +0300 Subject: [PATCH 06/12] feat(discord): add DiscordRescheduleVotingRenderer and replace inline helper --- .../Sessions/DiscordRescheduleHandler.cs | 42 +----------- .../Sessions/DiscordRescheduleVoteHandler.cs | 3 +- .../DiscordRescheduleVotingRenderer.cs | 67 +++++++++++++++++++ 3 files changed, 71 insertions(+), 41 deletions(-) create mode 100644 src/GmRelay.DiscordBot/Rendering/DiscordRescheduleVotingRenderer.cs diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs index c31de08..b843a04 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs @@ -2,6 +2,7 @@ namespace GmRelay.DiscordBot.Features.Sessions; using Dapper; using GmRelay.DiscordBot.Infrastructure.Discord; +using GmRelay.DiscordBot.Rendering; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; @@ -89,7 +90,7 @@ public sealed class DiscordRescheduleHandler( var optionDtos = options.Select((o, i) => new RescheduleOptionDto(Guid.NewGuid(), i + 1, o)).ToList(); // 7. Build and send Discord vote message BEFORE transaction - var (embed, actionRow) = BuildVoteMessage(session.Title, session.CurrentScheduledAt, deadline, optionDtos, participants, []); + var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(session.Title, session.CurrentScheduledAt, deadline, optionDtos, participants, []); var channelIdUlong = ulong.Parse(channelId); @@ -149,45 +150,6 @@ public sealed class DiscordRescheduleHandler( return new DiscordRescheduleResult(proposalId, optionDtos, deadline); } - // internal for now — temporary duplication until DiscordRescheduleVotingRenderer is extracted in Task 6 - internal static (EmbedProperties Embed, ActionRowProperties ActionRow) BuildVoteMessage( - string title, DateTime currentTime, DateTimeOffset deadline, - IReadOnlyList options, IReadOnlyList participants, IReadOnlyList votes) - { - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)"); - sb.AppendLine($"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)"); - sb.AppendLine(); - sb.AppendLine("Выберите один из вариантов:"); - - foreach (var option in options.OrderBy(o => o.DisplayOrder)) - { - var count = votes.Count(v => v.OptionId == option.OptionId); - sb.AppendLine($"{option.DisplayOrder}. **{option.ProposedAt.FormatMoscow()}** (МСК) — {count} голосов"); - } - - if (participants.Count > 0) - { - sb.AppendLine(); - sb.AppendLine($"Голосов: {votes.Count}/{participants.Count}"); - } - - var embed = new EmbedProperties() - .WithTitle($"🔄 Перенос сессии «{title}»") - .WithDescription(sb.ToString()) - .WithColor(new NetCord.Color(0xFEE75C)); - - var actionRow = new ActionRowProperties(); - foreach (var option in options.OrderBy(o => o.DisplayOrder)) - { - actionRow.Add(new ButtonProperties( - $"reschedule_vote:{option.OptionId}", - $"{option.DisplayOrder}. {option.ProposedAt.ToOffset(TimeSpan.FromHours(3)):dd.MM HH:mm}", - ButtonStyle.Primary)); - } - - return (embed, actionRow); - } } internal sealed record RescheduleSessionInfoDto(string Title, DateTime CurrentScheduledAt); diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs index 95390ee..5bf8885 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs @@ -1,6 +1,7 @@ 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; @@ -105,7 +106,7 @@ public sealed class DiscordRescheduleVoteHandler( await transaction.CommitAsync(ct); // 5. Re-render and update Discord vote message - var (embed, actionRow) = DiscordRescheduleHandler.BuildVoteMessage( + var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt, options, participants, votes); diff --git a/src/GmRelay.DiscordBot/Rendering/DiscordRescheduleVotingRenderer.cs b/src/GmRelay.DiscordBot/Rendering/DiscordRescheduleVotingRenderer.cs new file mode 100644 index 0000000..ac63401 --- /dev/null +++ b/src/GmRelay.DiscordBot/Rendering/DiscordRescheduleVotingRenderer.cs @@ -0,0 +1,67 @@ +namespace GmRelay.DiscordBot.Rendering; + +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; +using NetCord; +using NetCord.Rest; + +public static class DiscordRescheduleVotingRenderer +{ + public static (EmbedProperties Embed, ActionRowProperties ActionRow) Render( + string title, + DateTime currentTime, + DateTimeOffset deadline, + IReadOnlyList options, + IReadOnlyList participants, + IReadOnlyList votes) + { + var votesByOption = votes.GroupBy(v => v.OptionId).ToDictionary(g => g.Key, g => g.ToList()); + var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet(); + var pending = participants.Where(p => !votedPlayerIds.Contains(p.PlayerId)).Select(p => p.DisplayName).ToList(); + + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)"); + sb.AppendLine($"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)"); + sb.AppendLine(); + sb.AppendLine("Выберите один из вариантов:"); + + foreach (var option in options.OrderBy(o => o.DisplayOrder)) + { + var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []); + sb.AppendLine($"{option.DisplayOrder}. **{option.ProposedAt.FormatMoscow()}** (МСК) — {optionVotes.Count} голосов"); + if (optionVotes.Count > 0) + { + sb.AppendLine($" {string.Join(", ", optionVotes.Select(v => v.DisplayName))}"); + } + } + + if (pending.Count > 0) + { + sb.AppendLine(); + sb.AppendLine($"Не проголосовали: {string.Join(", ", pending)}"); + } + + sb.AppendLine(); + sb.AppendLine($"Голосов: {votedPlayerIds.Count}/{participants.Count}"); + sb.AppendLine("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется."); + + var embed = new EmbedProperties() + .WithTitle($"🔄 Перенос сессии «{title}»") + .WithDescription(sb.ToString()) + .WithColor(new Color(0xFEE75C)); + + var actionRow = new ActionRowProperties(); + foreach (var option in options.OrderBy(o => o.DisplayOrder)) + { + actionRow.Add(new ButtonProperties( + $"reschedule_vote:{option.OptionId}", + $"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}", + ButtonStyle.Primary)); + } + + return (embed, actionRow); + } + + private static string FormatButtonTime(DateTimeOffset utc) + => utc.ToOffset(TimeSpan.FromHours(3)).ToString("dd.MM HH:mm", System.Globalization.CultureInfo.InvariantCulture); +} From d871f2c142e065ae93bd897f92a75c8262943055 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 12:25:07 +0300 Subject: [PATCH 07/12] feat(discord): implement SendGroupMessageAsync in DiscordPlatformMessenger --- .../Infrastructure/Discord/DiscordPlatformMessenger.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs b/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs index 3d68f36..e4ab108 100644 --- a/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs +++ b/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs @@ -51,9 +51,15 @@ public sealed class DiscordPlatformMessenger( }); } - public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct) + public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct) { - return Task.CompletedTask; + var channelIdStr = group.ExternalChannelId ?? group.ExternalGroupId + ?? throw new InvalidOperationException("Group has no ExternalChannelId or ExternalGroupId."); + + if (!ulong.TryParse(channelIdStr, out var channelId)) + throw new InvalidOperationException($"Invalid Discord channel/group ID: '{channelIdStr}'."); + + await restClient.SendMessageAsync(channelId, htmlText); } public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) From 690aa0272f820c09ced3d2f1cca1f5b8f39811af Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 12:29:33 +0300 Subject: [PATCH 08/12] feat(discord): add reschedule voting deadline service --- .../DiscordRescheduleVotingDeadlineService.cs | 183 ++++++++++++++++++ .../Infrastructure/SystemClock.cs | 6 + src/GmRelay.DiscordBot/Program.cs | 5 + 3 files changed, 194 insertions(+) create mode 100644 src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs create mode 100644 src/GmRelay.DiscordBot/Infrastructure/SystemClock.cs diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs new file mode 100644 index 0000000..2f1b4be --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs @@ -0,0 +1,183 @@ +namespace GmRelay.DiscordBot.Features.Sessions; + +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; +using GmRelay.Shared.Platform; +using GmRelay.DiscordBot.Rendering; +using GmRelay.Shared.Rendering; +using NetCord; +using NetCord.Rest; +using Npgsql; + +public sealed class DiscordRescheduleVotingDeadlineService( + NpgsqlDataSource dataSource, + RescheduleVotingFinalizer finalizer, + RestClient restClient, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await ProcessDueProposals(stoppingToken); + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await ProcessDueProposals(stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { } + } + + private async Task ProcessDueProposals(CancellationToken ct) + { + try + { + var proposalIds = await finalizer.GetDueProposalIdsAsync(ct); + foreach (var id in proposalIds) + { + await TryFinalizeAsync(id, ct); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to process Discord reschedule proposals"); + } + } + + private async Task TryFinalizeAsync(Guid proposalId, CancellationToken ct) + { + try + { + var result = await finalizer.FinalizeAsync(proposalId, ct); + if (result is null) + return; + + if (result.SourcePlatform != "Discord") + return; + + // Update Discord vote message + await TryUpdateDiscordVoteMessage(result, ct); + + // If approved, update batch schedule + if (result.SelectedOption is not null) + { + await TryUpdateBatchScheduleAsync(result, ct); + } + + logger.LogInformation( + "Finalized Discord reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}", + proposalId, result.SessionId, result.Decision.Outcome); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to finalize Discord proposal {ProposalId}", proposalId); + } + } + + private async Task TryUpdateDiscordVoteMessage(RescheduleVotingFinalizerResult result, CancellationToken ct) + { + try + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + var msgRef = await connection.QuerySingleOrDefaultAsync( + """ + SELECT external_channel_id AS ExternalChannelId, external_message_id AS ExternalMessageId + FROM platform_messages + WHERE session_id = @SessionId AND purpose = 'reschedule_vote' AND platform = 'Discord' + ORDER BY created_at DESC + LIMIT 1 + """, + new { result.SessionId }); + + if (msgRef is null) + return; + + var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( + result.Title, result.CurrentScheduledAt, result.VotingDeadlineAt, + result.Options, result.Participants, result.Votes); + + var channelId = ulong.Parse(msgRef.ExternalChannelId); + var messageId = ulong.Parse(msgRef.ExternalMessageId); + + // Disable buttons after finalization + var disabledRow = new ActionRowProperties(); + foreach (var btn in actionRow.OfType()) + { + disabledRow.Add(new ButtonProperties(btn.CustomId, btn.Label ?? string.Empty, ButtonStyle.Secondary) { Disabled = true }); + } + + var resultText = result.SelectedOption is not null + ? $"Голосование завершено. Победил вариант {result.SelectedOption.DisplayOrder}: **{result.SelectedOption.ProposedAt.FormatMoscow()}** (МСК)." + : $"Голосование завершено. {result.Decision.Reason}"; + + var updatedEmbed = embed.WithDescription($"{embed.Description}\n\n{resultText}"); + + await restClient.ModifyMessageAsync(channelId, messageId, options => + { + options.Embeds = new[] { updatedEmbed }; + options.Components = new[] { disabledRow }; + }); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to update Discord vote message for session {SessionId}", result.SessionId); + } + } + + private async Task TryUpdateBatchScheduleAsync(RescheduleVotingFinalizerResult result, CancellationToken ct) + { + try + { + // Query batch schedule message ref + await using var connection = await dataSource.OpenConnectionAsync(ct); + var batchRef = await connection.QuerySingleOrDefaultAsync( + """ + SELECT external_channel_id AS ExternalChannelId, external_message_id AS ExternalMessageId + FROM platform_messages + WHERE batch_id = @BatchId AND purpose = 'schedule' AND platform = 'Discord' + ORDER BY created_at DESC + LIMIT 1 + """, + new { result.BatchId }); + + if (batchRef is null) + return; + + // Rebuild schedule view and update Discord message + var sessions = (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 { result.BatchId })).ToList(); + + var participants = (await connection.QueryAsync( + """ + SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, COALESCE(p.external_username, p.telegram_username) AS TelegramUsername, sp.registration_status AS RegistrationStatus + FROM session_participants sp + JOIN players p ON p.id = sp.player_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 + """, + new { result.BatchId })).ToList(); + + var view = SessionBatchViewBuilder.Build(result.Title, sessions, participants); + var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view); + + var channelId = ulong.Parse(batchRef.ExternalChannelId); + var messageId = ulong.Parse(batchRef.ExternalMessageId); + + await restClient.ModifyMessageAsync(channelId, messageId, options => + { + options.Embeds = embeds; + options.Components = actionRows; + }); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to update Discord batch schedule for session {SessionId}", result.SessionId); + } + } + + internal sealed record PlatformMessageRefDto(string ExternalChannelId, string ExternalMessageId); +} diff --git a/src/GmRelay.DiscordBot/Infrastructure/SystemClock.cs b/src/GmRelay.DiscordBot/Infrastructure/SystemClock.cs new file mode 100644 index 0000000..4dc286a --- /dev/null +++ b/src/GmRelay.DiscordBot/Infrastructure/SystemClock.cs @@ -0,0 +1,6 @@ +namespace GmRelay.DiscordBot.Infrastructure; + +public sealed class SystemClock : GmRelay.Shared.Platform.ISystemClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} diff --git a/src/GmRelay.DiscordBot/Program.cs b/src/GmRelay.DiscordBot/Program.cs index 14817c8..88e07ed 100644 --- a/src/GmRelay.DiscordBot/Program.cs +++ b/src/GmRelay.DiscordBot/Program.cs @@ -1,8 +1,10 @@ using GmRelay.DiscordBot; using GmRelay.DiscordBot.Features.Sessions; +using GmRelay.DiscordBot.Infrastructure; using GmRelay.DiscordBot.Infrastructure.Discord; using GmRelay.DiscordBot.Infrastructure.Logging; using GmRelay.Shared.Features.Sessions.CreateSession; +using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -51,6 +53,9 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services .AddDiscordGateway(options => From 1e9bf4ab259e794185d32ecdeb4a63c3273ab2e1 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 12:33:24 +0300 Subject: [PATCH 09/12] feat(telegram): set source_platform = 'Telegram' on reschedule proposals Ensures Telegram-initiated reschedule proposals are tagged with source_platform so the platform-neutral finalizer can distinguish them from Discord proposals. Co-Authored-By: Claude Opus 4.7 --- .../Sessions/RescheduleSession/InitiateRescheduleHandler.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs index 2e18ce9..625fb29 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs @@ -83,8 +83,8 @@ public sealed class InitiateRescheduleHandler( // 3. Create proposal in AwaitingTime status await connection.ExecuteAsync( """ - INSERT INTO reschedule_proposals (session_id, proposed_by, status) - VALUES (@SessionId, @GmId, 'AwaitingTime') + INSERT INTO reschedule_proposals (session_id, proposed_by, source_platform, status) + VALUES (@SessionId, @GmId, 'Telegram', 'AwaitingTime') """, new { command.SessionId, GmId = command.TelegramUserId }); From dda393c37223d54627740c89f279d8c087de5fbf Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 12:33:45 +0300 Subject: [PATCH 10/12] chore: bump version to 2.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synchronized across Directory.Build.props, compose.yaml, deploy.yml, and NavMenu.razor. Bump version → 2.6.0 Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- compose.yaml | 6 +++--- src/GmRelay.Web/Components/Layout/NavMenu.razor | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 866ed7d..8780090 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 2.5.0 + VERSION: 2.6.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 1e9ffab..f308714 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.5.0 + 2.6.0 net10.0 preview enable diff --git a/compose.yaml b/compose.yaml index c75a26c..75b335c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:2.5.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:2.6.0 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.5.0 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.6.0 restart: always depends_on: db: @@ -79,7 +79,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:2.5.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:2.6.0 restart: always depends_on: db: diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index b23560e..1c94201 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -56,7 +56,7 @@ - + From 35548a03cb46dcfc46b67824b509e126e9d2a7be Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 12:36:05 +0300 Subject: [PATCH 11/12] test(discord): update version assertions to 2.6.0 Co-Authored-By: Claude Opus 4.7 --- .../Discord/DiscordProjectStructureTests.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs index 0f6da97..c4f3370 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs @@ -61,7 +61,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:2.5.0", compose); + Assert.Contains("gmrelay-discord-bot:2.6.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); @@ -75,13 +75,13 @@ public sealed class DiscordProjectStructureTests { var repoRoot = GetRepoRoot(); - Assert.Contains("2.5.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); - Assert.Contains("VERSION: 2.5.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); - Assert.Contains("gmrelay-bot:2.5.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-web:2.5.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-discord-bot:2.5.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("2.6.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); + Assert.Contains("VERSION: 2.6.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); + Assert.Contains("gmrelay-bot:2.6.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-web:2.6.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-discord-bot:2.6.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains( - "v2.5.0", + "v2.6.0", File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor"))); } } From db9a931ed6ef7e80deaf6c502ff32d8684ef0bb2 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 12:48:25 +0300 Subject: [PATCH 12/12] fix(shared): filter due proposals by source_platform to prevent cross-platform race Both Telegram and Discord deadline services were querying ALL due proposals without filtering by source_platform. If the Telegram service reached a Discord proposal first, it finalized the DB state but skipped message handling. The Discord service then saw status != 'Voting' and never updated the Discord vote message. Fix: GetDueProposalIdsAsync now accepts a sourcePlatform parameter and filters at the DB level. Each service only processes its own platform's proposals. Co-Authored-By: Claude Opus 4.7 --- .../RescheduleSession/RescheduleVotingDeadlineService.cs | 2 +- .../Sessions/DiscordRescheduleVotingDeadlineService.cs | 2 +- .../Sessions/RescheduleSession/RescheduleVotingFinalizer.cs | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs index ffd21e8..e35c9e0 100644 --- a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs @@ -46,7 +46,7 @@ public sealed class RescheduleVotingDeadlineService( { try { - var proposalIds = await finalizer.GetDueProposalIdsAsync(ct); + var proposalIds = await finalizer.GetDueProposalIdsAsync("Telegram", ct); foreach (var proposalId in proposalIds) { diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs index 2f1b4be..bb88411 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs @@ -34,7 +34,7 @@ public sealed class DiscordRescheduleVotingDeadlineService( { try { - var proposalIds = await finalizer.GetDueProposalIdsAsync(ct); + var proposalIds = await finalizer.GetDueProposalIdsAsync("Discord", ct); foreach (var id in proposalIds) { await TryFinalizeAsync(id, ct); diff --git a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs index 8942b4b..bb00f4a 100644 --- a/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs +++ b/src/GmRelay.Shared/Features/Sessions/RescheduleSession/RescheduleVotingFinalizer.cs @@ -26,7 +26,7 @@ public sealed class RescheduleVotingFinalizer( ISystemClock clock, ILogger logger) { - public async Task> GetDueProposalIdsAsync(CancellationToken ct) + public async Task> GetDueProposalIdsAsync(string sourcePlatform, CancellationToken ct) { await using var connection = await dataSource.OpenConnectionAsync(ct); var proposalIds = (await connection.QueryAsync( @@ -36,10 +36,11 @@ public sealed class RescheduleVotingFinalizer( WHERE status = 'Voting' AND voting_deadline_at IS NOT NULL AND voting_deadline_at <= @Now + AND source_platform = @SourcePlatform ORDER BY voting_deadline_at LIMIT 25 """, - new { Now = clock.UtcNow.UtcDateTime })).ToList(); + new { Now = clock.UtcNow.UtcDateTime, SourcePlatform = sourcePlatform })).ToList(); return proposalIds; }