Compare commits

..

16 Commits

Author SHA1 Message Date
Toutsu 0c62631ab6 Merge pull request #87: feat(discord): implement reschedule voting via Discord interactions (issue #30)
Deploy Telegram Bot / build-and-push (push) Successful in 4m37s
Deploy Telegram Bot / scan-images (push) Successful in 1m25s
Deploy Telegram Bot / deploy (push) Successful in 14s
Database:
- Add source_platform and proposed_by_external_user_id to reschedule_proposals
- Make proposed_by nullable for Discord proposals

Shared:
- Extract platform-neutral RescheduleVoteRules, RescheduleVotingInput, RescheduleDtos
- Create RescheduleVotingFinalizer for cross-platform deadline handling

Telegram:
- Refactor RescheduleVotingDeadlineService to use RescheduleVotingFinalizer
- Tag Telegram proposals with source_platform = 'Telegram'

Discord:
- /reschedule slash command with time options and deadline
- DiscordRescheduleVoteHandler for button interactions
- DiscordRescheduleVotingRenderer for embeds and buttons
- DiscordRescheduleVotingDeadlineService for automatic finalization
- DiscordSessionInteractionModule routing for vote buttons

Version: 2.5.0 -> 2.6.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 13:12:26 +03:00
Toutsu db9a931ed6 fix(shared): filter due proposals by source_platform to prevent cross-platform race
PR Checks / test-and-build (pull_request) Successful in 6m11s
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 <noreply@anthropic.com>
2026-05-20 12:48:25 +03:00
Toutsu 35548a03cb test(discord): update version assertions to 2.6.0
PR Checks / test-and-build (pull_request) Successful in 6m27s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:05 +03:00
Toutsu dda393c372 chore: bump version to 2.6.0
Synchronized across Directory.Build.props, compose.yaml,
deploy.yml, and NavMenu.razor.

Bump version → 2.6.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:33:45 +03:00
Toutsu 1e9bf4ab25 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 <noreply@anthropic.com>
2026-05-20 12:33:24 +03:00
Toutsu 690aa0272f feat(discord): add reschedule voting deadline service 2026-05-20 12:29:33 +03:00
Toutsu d871f2c142 feat(discord): implement SendGroupMessageAsync in DiscordPlatformMessenger 2026-05-20 12:26:31 +03:00
Toutsu 9712fe125b feat(discord): add DiscordRescheduleVotingRenderer and replace inline helper 2026-05-20 12:23:25 +03:00
Toutsu fdfc73ae9c feat(discord): add reschedule vote button handler 2026-05-20 12:21:13 +03:00
Toutsu e93e777fb3 feat(discord): add /reschedule slash command and handler 2026-05-20 12:15:03 +03:00
Toutsu a13edf20af feat(shared): add RescheduleVotingFinalizer and ISystemClock 2026-05-20 11:54:53 +03:00
Toutsu fcd7de035f refactor(shared): extract reschedule voting types to Shared 2026-05-20 11:44:57 +03:00
Toutsu fb0c29eefe feat(db): add platform columns to reschedule_proposals 2026-05-20 11:41:25 +03:00
Toutsu 9ff5cc4a67 Merge pull request #86: feat(discord): enable session join leave buttons
Deploy Telegram Bot / build-and-push (push) Successful in 4m54s
Deploy Telegram Bot / scan-images (push) Successful in 1m22s
Deploy Telegram Bot / deploy (push) Successful in 15s
2026-05-20 09:09:51 +03:00
Toutsu 3251846001 fix(shared): enable dapper aot for session handlers
PR Checks / test-and-build (pull_request) Successful in 6m30s
2026-05-20 09:01:34 +03:00
Toutsu 39132be4e8 feat(discord): enable session join leave buttons
PR Checks / test-and-build (pull_request) Successful in 6m6s
Move neutral join/leave handlers into GmRelay.Shared so Telegram and Discord share capacity, waitlist, duplicate-click, and schedule-update behavior.

Add Discord component routing for join_session and leave_session buttons with deferred ephemeral replies and serialized schedule message updates.

Bump version to 2.5.0 and update Discord docs.

Refs #29
2026-05-19 14:13:48 +03:00
59 changed files with 3275 additions and 329 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 2.4.0 VERSION: 2.6.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>2.4.0</Version> <Version>2.6.0</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+23
View File
@@ -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
+8 -1
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v2.2.0`. **Текущая версия:** `v2.5.0`.
--- ---
@@ -24,6 +24,11 @@
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди. - **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков. - **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
### Discord Bot
- **Slash-команды расписания**: GM создаёт сессию через `/newsession` и публикует актуальное расписание через `/listsessions`.
- **Кнопки записи и выхода**: игроки нажимают Join/Leave в Discord-сообщении; бот отвечает ephemeral-сообщением и обновляет schedule message.
- **Лимиты и waitlist**: при заполненном составе игрок попадает в waitlist, а при выходе участника первый ожидающий автоматически продвигается в основной состав.
### 🌐 Web Dashboard (Blazor Server) ### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через Telegram**: Telegram Login Widget с HMAC-SHA256 валидацией. - **🔐 Авторизация через Telegram**: Telegram Login Widget с HMAC-SHA256 валидацией.
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram. - **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
@@ -108,6 +113,8 @@ docker compose up -d
1. Напишите боту `/start`. 1. Напишите боту `/start`.
2. Создайте группу через `/newgroup`. 2. Создайте группу через `/newgroup`.
3. Откройте Mini App или Web Dashboard для расширенного управления. 3. Откройте Mini App или Web Dashboard для расширенного управления.
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`.
5. В Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
## 💾 Backup и восстановление ## 💾 Backup и восстановление
+23
View File
@@ -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
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f crond -f
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.4.0 image: git.codeanddice.ru/toutsu/gmrelay-bot:2.6.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -67,7 +67,7 @@ services:
retries: 3 retries: 3
discord: discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.4.0 image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.6.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -79,7 +79,7 @@ services:
- gmrelay - gmrelay
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:2.4.0 image: git.codeanddice.ru/toutsu/gmrelay-web:2.6.0
restart: always restart: always
depends_on: depends_on:
db: db:
+71 -39
View File
@@ -1,4 +1,4 @@
# GM-Relay C4 Model # GM-Relay - C4 Model
## Level 1: System Context ## Level 1: System Context
@@ -6,19 +6,24 @@
C4Context C4Context
title GM-Relay System Context title GM-Relay System Context
Person(gm, "Game Master", "Создаёт сессии, управляет расписанием игр") Person(gm, "Game Master", "Creates sessions and manages schedules")
Person(player, "Player", "Подтверждает участие через inline-кнопки") Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
System(gmrelay, "GM-Relay Bot", "Telegram Worker Service на Raspberry Pi. Управляет подтверждениями, рассылает напоминания и ссылки.") System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, and shared scheduling logic")
System_Ext(telegram, "Telegram Bot API", "Long Polling. Сообщения, inline keyboards, callback queries.") System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
SystemDb_Ext(postgres, "PostgreSQL", "Сессии, игроки, RSVP-статусы") System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities")
Rel(gm, telegram, "Команды бота (/newsession)") Rel(gm, telegram, "Creates and manages sessions")
Rel(player, telegram, "Нажимает кнопки (✅ Буду / ❌ Не смогу)") Rel(gm, discord, "Uses /newsession and /listsessions")
Rel(telegram, gmrelay, "Updates (Long Polling)") Rel(player, telegram, "Uses inline buttons")
Rel(player, discord, "Uses Join/Leave buttons")
Rel(telegram, gmrelay, "Updates via long polling")
Rel(discord, gmrelay, "Gateway events and component interactions")
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery") Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
Rel(gmrelay, postgres, "SQL (Npgsql + Dapper)") Rel(gmrelay, discord, "Send/edit schedule messages and ephemeral interaction replies")
Rel(gmrelay, postgres, "SQL via Npgsql and Dapper")
``` ```
## Level 2: Container ## Level 2: Container
@@ -30,49 +35,76 @@ C4Container
Person(gm, "Game Master") Person(gm, "Game Master")
Person(player, "Player") Person(player, "Player")
System_Boundary(pi, "Raspberry Pi 5") { System_Boundary(runtime, "Docker Compose / Aspire runtime") {
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Long polling, обработка команд и callback queries, планировщик") Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
ContainerDb(db, "PostgreSQL 16", "Database", "sessions, players, session_participants, game_groups") Container(discordBot, "GmRelay.DiscordBot", "Worker Service, .NET 10", "NetCord Gateway, slash commands, Join/Leave button interactions")
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, editing and stats")
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, and platform-neutral join/leave handlers")
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, platform identities")
} }
System_Ext(telegram, "Telegram Bot API") System_Ext(telegram, "Telegram Bot API")
System_Ext(discord, "Discord Gateway and REST API")
Rel(gm, telegram, "Commands") Rel(gm, telegram, "Commands")
Rel(player, telegram, "Callback Queries") Rel(gm, discord, "Slash commands")
Rel(telegram, bot, "GetUpdates (Long Polling)") Rel(player, telegram, "Callback queries")
Rel(player, discord, "Button interactions")
Rel(telegram, bot, "GetUpdates")
Rel(discord, discordBot, "Gateway events")
Rel(bot, telegram, "Bot API calls") Rel(bot, telegram, "Bot API calls")
Rel(discordBot, discord, "REST send/edit/reply calls")
Rel(bot, shared, "Uses shared renderers and join/leave handlers")
Rel(discordBot, shared, "Uses shared renderers and join/leave handlers")
Rel(web, shared, "Uses shared domain and rendering models")
Rel(bot, db, "Npgsql + Dapper.AOT") Rel(bot, db, "Npgsql + Dapper.AOT")
Rel(discordBot, db, "Npgsql + Dapper")
Rel(web, db, "Npgsql + Dapper")
``` ```
## Level 3: Component (GmRelay.Bot) ## Level 3: Component - Session Interactions
```mermaid ```mermaid
C4Component C4Component
title GmRelay.Bot Components title Platform-Neutral Session Interactions
Container_Boundary(bot, "GmRelay.Bot") { Container_Boundary(shared, "GmRelay.Shared") {
Component(polling, "TelegramBotService", "BackgroundService", "Long polling loop, получает Updates") Component(join, "JoinSessionHandler", "Feature handler", "Adds players as Active or Waitlisted with session row locking")
Component(router, "UpdateRouter", "C#", "Маршрутизирует Update → Handler по типу") Component(leave, "LeaveSessionHandler", "Feature handler", "Removes players and promotes the first waitlisted player when capacity allows")
Component(scheduler, "SessionSchedulerService", "BackgroundService", "PeriodicTimer(60s): T-24ч и T-5мин триггеры") Component(updateLock, "ScheduleMessageUpdateLock", "In-memory keyed lock", "Serializes DB changes and schedule message edits per platform message")
Component(migrator, "DbMigrator", "DbUp", "SQL миграции при старте") Component(renderer, "SessionBatchViewBuilder", "Renderer model builder", "Builds platform-neutral schedule views and actions")
Component(confirm, "SendConfirmationHandler", "Feature", "Отправляет inline keyboard за 24ч")
Component(rsvp, "HandleRsvpHandler", "Feature", "Обрабатывает ✅/❌, проверяет all-confirmed")
Component(link, "SendJoinLinkHandler", "Feature", "Отправляет join link за 5 мин")
} }
System_Ext(telegram, "Telegram Bot API") Container_Boundary(discordBot, "GmRelay.DiscordBot") {
ContainerDb(db, "PostgreSQL") Component(discordModule, "DiscordSessionInteractionModule", "NetCord component module", "Maps join_session/leave_session buttons to neutral commands")
Component(discordMessenger, "DiscordPlatformMessenger", "IPlatformMessenger", "Edits Discord schedule messages and stores interaction replies")
}
Rel(polling, router, "Update") Container_Boundary(bot, "GmRelay.Bot") {
Rel(router, rsvp, "CallbackQuery rsvp:*") Component(updateRouter, "UpdateRouter", "Telegram adapter", "Maps callback queries to neutral commands")
Rel(scheduler, confirm, "T-24h trigger") Component(telegramMessenger, "TelegramPlatformMessenger", "IPlatformMessenger", "Edits Telegram schedule messages and answers callback queries")
Rel(scheduler, link, "T-5min trigger") }
Rel(confirm, telegram, "SendMessage + InlineKeyboard")
Rel(rsvp, telegram, "EditMessage + AnswerCallback") ContainerDb(db, "PostgreSQL")
Rel(link, telegram, "SendMessage + user mentions") System_Ext(telegram, "Telegram Bot API")
Rel(confirm, db, "SELECT/UPDATE sessions") System_Ext(discord, "Discord Gateway and REST API")
Rel(rsvp, db, "UPDATE participants, SELECT counts")
Rel(link, db, "SELECT confirmed players") Rel(discord, discordModule, "Button interaction")
Rel(migrator, db, "DDL migrations") Rel(discordModule, join, "JoinSessionCommand")
Rel(discordModule, leave, "LeaveSessionCommand")
Rel(discordModule, discord, "Deferred ephemeral reply, then modify response")
Rel(updateRouter, join, "JoinSessionCommand")
Rel(updateRouter, leave, "LeaveSessionCommand")
Rel(join, updateLock, "Acquire by PlatformMessageRef")
Rel(leave, updateLock, "Acquire by PlatformMessageRef")
Rel(join, db, "SELECT FOR UPDATE, INSERT participant")
Rel(leave, db, "SELECT FOR UPDATE, DELETE/promote participant")
Rel(join, renderer, "Build updated schedule view")
Rel(leave, renderer, "Build updated schedule view")
Rel(join, discordMessenger, "Update Discord schedule when command is Discord")
Rel(leave, discordMessenger, "Update Discord schedule when command is Discord")
Rel(join, telegramMessenger, "Update Telegram schedule when command is Telegram")
Rel(leave, telegramMessenger, "Update Telegram schedule when command is Telegram")
Rel(discordMessenger, discord, "ModifyMessage + ephemeral text")
Rel(telegramMessenger, telegram, "EditMessage + AnswerCallbackQuery")
``` ```
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
using Dapper; using Dapper;
using GmRelay.Bot.Features.Notifications; using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
@@ -17,12 +18,6 @@ internal sealed record AwaitingProposalDto(
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt, Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode); Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode);
internal sealed record VoteParticipantDto(
Guid PlayerId,
string DisplayName,
string? TelegramUsername,
long TelegramId = 0);
// ── Handler ────────────────────────────────────────────────────────── // ── Handler ──────────────────────────────────────────────────────────
/// <summary> /// <summary>
@@ -1,5 +1,6 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
@@ -13,13 +14,6 @@ public sealed record HandleRescheduleVoteCommand(
long ChatId, long ChatId,
int MessageId); int MessageId);
internal sealed record VoteProposalDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt);
public sealed class HandleRescheduleVoteHandler( public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
@@ -83,8 +83,8 @@ public sealed class InitiateRescheduleHandler(
// 3. Create proposal in AwaitingTime status // 3. Create proposal in AwaitingTime status
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
INSERT INTO reschedule_proposals (session_id, proposed_by, status) INSERT INTO reschedule_proposals (session_id, proposed_by, source_platform, status)
VALUES (@SessionId, @GmId, 'AwaitingTime') VALUES (@SessionId, @GmId, 'Telegram', 'AwaitingTime')
""", """,
new { command.SessionId, GmId = command.TelegramUserId }); new { command.SessionId, GmId = command.TelegramUserId });
@@ -1,7 +1,7 @@
using Dapper; using Dapper;
using GmRelay.Bot.Features.Notifications; using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
@@ -11,25 +11,18 @@ using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record DueRescheduleProposalDto( internal sealed record TelegramProposalFieldsDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
int? BatchMessageId,
int? VoteMessageId, int? VoteMessageId,
int? BatchMessageId,
long TelegramChatId, long TelegramChatId,
int? ThreadId, int? ThreadId);
string NotificationMode);
public sealed class RescheduleVotingDeadlineService( public sealed class RescheduleVotingDeadlineService(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
IPlatformMessenger messenger, IPlatformMessenger messenger,
DirectSessionNotificationSender directSender, DirectSessionNotificationSender directSender,
ISystemClock clock, RescheduleVotingFinalizer finalizer,
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -53,18 +46,7 @@ public sealed class RescheduleVotingDeadlineService(
{ {
try try
{ {
await using var connection = await dataSource.OpenConnectionAsync(ct); var proposalIds = await finalizer.GetDueProposalIdsAsync("Telegram", ct);
var proposalIds = (await connection.QueryAsync<Guid>(
"""
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();
foreach (var proposalId in proposalIds) foreach (var proposalId in proposalIds)
{ {
@@ -82,212 +64,105 @@ public sealed class RescheduleVotingDeadlineService(
private async Task FinalizeProposal(Guid proposalId, CancellationToken ct) private async Task FinalizeProposal(Guid proposalId, CancellationToken ct)
{ {
await using var connection = await dataSource.OpenConnectionAsync(ct); var result = await finalizer.FinalizeAsync(proposalId, ct);
await using var transaction = await connection.BeginTransactionAsync(ct); if (result is null)
return;
var proposal = await connection.QuerySingleOrDefaultAsync<DueRescheduleProposalDto>( 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<TelegramProposalFieldsDto>(
""" """
SELECT rp.id AS Id, SELECT rp.vote_message_id AS VoteMessageId,
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,
s.batch_message_id AS BatchMessageId, s.batch_message_id AS BatchMessageId,
s.notification_mode AS NotificationMode, g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId, s.thread_id AS ThreadId
g.telegram_chat_id AS TelegramChatId
FROM reschedule_proposals rp FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE rp.id = @ProposalId 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 }, new { ProposalId = proposalId });
transaction);
if (proposal is null) if (telegramFields is null)
{
logger.LogWarning("Could not find Telegram fields for proposal {ProposalId}", proposalId);
return; return;
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
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<RescheduleOptionDto>(
"""
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<RescheduleOptionVoteDto>(
"""
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)) .Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName))
.ToList(); .ToList();
await transaction.CommitAsync(ct); await TryUpdateVoteMessage(result, telegramFields, ct);
await TryUpdateVoteMessage(proposal, options, participants, votes, decision, selectedOption, ct); if (result.SelectedOption is not null)
if (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()) if (mode.ShouldSendDirectMessages())
{ {
await SendDirectResult(proposal, directRecipients, decision, selectedOption, ct); await SendDirectResult(result, directRecipients, ct);
} }
logger.LogInformation( logger.LogInformation(
"Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}", "Updated Telegram messages for finalized reschedule proposal {ProposalId} for session {SessionId}",
proposal.Id, result.ProposalId,
proposal.SessionId, result.SessionId);
decision.Outcome);
} }
private async Task TryUpdateVoteMessage( private async Task TryUpdateVoteMessage(
DueRescheduleProposalDto proposal, RescheduleVotingFinalizerResult result,
IReadOnlyList<RescheduleOptionDto> options, TelegramProposalFieldsDto telegramFields,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyList<RescheduleOptionVoteDto> votes,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
CancellationToken ct) CancellationToken ct)
{ {
if (proposal.VoteMessageId is null) if (telegramFields.VoteMessageId is null)
return; return;
try try
{ {
var resultText = selectedOption is not null var resultText = result.SelectedOption is not null
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {selectedOption.DisplayOrder}: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)." ? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {result.SelectedOption.DisplayOrder}: <b>{result.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(decision.Reason)}"; : $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(result.Decision.Reason)}";
var text = $""" var text = $"""
{HandleRescheduleTimeInputHandler.BuildVotingMessage( {HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title, result.Title,
proposal.CurrentScheduledAt, result.CurrentScheduledAt,
proposal.VotingDeadlineAt, result.VotingDeadlineAt,
options, result.Options,
participants, result.Participants,
votes)} result.Votes)}
{resultText} {resultText}
"""; """;
await bot.EditMessageText( await bot.EditMessageText(
chatId: proposal.TelegramChatId, chatId: telegramFields.TelegramChatId,
messageId: proposal.VoteMessageId.Value, messageId: telegramFields.VoteMessageId.Value,
text: text, text: text,
parseMode: ParseMode.Html, parseMode: ParseMode.Html,
cancellationToken: ct); cancellationToken: ct);
} }
catch (Exception ex) 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 try
{ {
@@ -295,7 +170,7 @@ public sealed class RescheduleVotingDeadlineService(
var batchSessions = (await connection.QueryAsync<SessionBatchDto>( var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"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", "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<ParticipantBatchDto>( var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
""" """
@@ -309,60 +184,58 @@ public sealed class RescheduleVotingDeadlineService(
WHERE s.batch_id = @BatchId AND sp.is_gm = false 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 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( await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage( new PlatformScheduleMessage(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId), TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
view, view,
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)), TelegramPlatformIds.Message(telegramFields.TelegramChatId, telegramFields.ThreadId, telegramFields.BatchMessageId.Value)),
ct); ct);
} }
else else
{ {
await messenger.SendGroupMessageAsync( await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId), TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
$"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».", $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(result.Title)}».",
ct); ct);
} }
} }
catch (Exception ex) 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( private async Task SendDirectResult(
DueRescheduleProposalDto proposal, RescheduleVotingFinalizerResult result,
IReadOnlyList<DirectNotificationRecipient> recipients, IReadOnlyList<DirectNotificationRecipient> recipients,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
CancellationToken ct) CancellationToken ct)
{ {
var htmlText = selectedOption is not null var htmlText = result.SelectedOption is not null
? $""" ? $"""
✅ <b>Сессия перенесена по итогам голосования</b> ✅ <b>Сессия перенесена по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b> 📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
📅 Новое время: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК) 📅 Новое время: <b>{result.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)
""" """
: $""" : $"""
❌ <b>Перенос сессии отклонён по итогам голосования</b> ❌ <b>Перенос сессии отклонён по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b> 📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
📅 Время остаётся прежним: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК) 📅 Время остаётся прежним: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
Причина: {System.Net.WebUtility.HtmlEncode(decision.Reason)} Причина: {System.Net.WebUtility.HtmlEncode(result.Decision.Reason)}
"""; """;
await directSender.SendAsync( await directSender.SendAsync(
recipients, recipients,
htmlText, htmlText,
selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected", result.SelectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
proposal.SessionId, result.SessionId,
ct); ct);
} }
} }
@@ -1,9 +1,6 @@
namespace GmRelay.Bot.Infrastructure.Scheduling; using GmRelay.Shared.Platform;
public interface ISystemClock namespace GmRelay.Bot.Infrastructure.Scheduling;
{
DateTimeOffset UtcNow { get; }
}
public sealed class SystemClock : ISystemClock public sealed class SystemClock : ISystemClock
{ {
@@ -1,6 +1,7 @@
using GmRelay.Bot.Features.Confirmation.SendConfirmation; using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Reminders.SendJoinLink; using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Reminders.SendOneHourReminder; using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Infrastructure.Scheduling; namespace GmRelay.Bot.Infrastructure.Scheduling;
@@ -1,5 +1,6 @@
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment // ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Confirmation.HandleRsvp; using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.CreateSession;
@@ -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;
+4
View File
@@ -6,10 +6,12 @@ using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Database; using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Health; using GmRelay.Bot.Infrastructure.Health;
using GmRelay.Bot.Infrastructure.Logging; using GmRelay.Bot.Infrastructure.Logging;
using GmRelay.Bot.Infrastructure.Scheduling; using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
@@ -63,6 +65,7 @@ builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<
builder.Services.AddSingleton<SendOneHourReminderHandler>(); builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>()); builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<CreateSessionHandler>(); builder.Services.AddSingleton<CreateSessionHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>(); builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>(); builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>(); builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
@@ -73,6 +76,7 @@ builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.Expor
builder.Services.AddSingleton<InitiateRescheduleHandler>(); builder.Services.AddSingleton<InitiateRescheduleHandler>();
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>(); builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<HandleRescheduleVoteHandler>(); builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
// ── Telegram infrastructure ────────────────────────────────────────── // ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>(); builder.Services.AddSingleton<UpdateRouter>();
+6 -1
View File
@@ -661,7 +661,12 @@
} }
}, },
"gmrelay.shared": { "gmrelay.shared": {
"type": "Project" "type": "Project",
"dependencies": {
"Dapper": "[2.1.72, )",
"Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )",
"Npgsql": "[10.0.2, )"
}
} }
}, },
"net10.0/win-x64": { "net10.0/win-x64": {
@@ -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<SlashCommandContext>
{
private readonly DiscordRescheduleHandler _handler;
private readonly ILogger<DiscordRescheduleCommand> _logger;
public DiscordRescheduleCommand(DiscordRescheduleHandler handler, ILogger<DiscordRescheduleCommand> 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<string> { option1, option2 };
if (!string.IsNullOrWhiteSpace(option3))
options.Add(option3);
var parsedOptions = new List<DateTimeOffset>();
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;
}
}
@@ -0,0 +1,155 @@
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;
using NetCord;
using NetCord.Rest;
using Npgsql;
public sealed record DiscordRescheduleResult(Guid ProposalId, IReadOnlyList<RescheduleOptionDto> Options, DateTimeOffset Deadline);
public sealed class DiscordRescheduleHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
RestClient restClient,
ILogger<DiscordRescheduleHandler> logger)
{
public async Task<DiscordRescheduleResult> HandleAsync(
string guildId,
string channelId,
ulong userId,
string userDisplayName,
ulong resolvedPermissions,
ulong guildOwnerId,
Guid sessionId,
IReadOnlyList<DateTimeOffset> 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<ulong>(
@"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<RescheduleSessionInfoDto>(
"""
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<bool>(
"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<VoteParticipantDto>(
"""
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) = DiscordRescheduleVotingRenderer.Render(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);
}
}
internal sealed record RescheduleSessionInfoDto(string Title, DateTime CurrentScheduledAt);
@@ -0,0 +1,131 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using Dapper;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Npgsql;
using NetCord.Rest;
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<DiscordRescheduleVoteHandler> logger)
{
public async Task<string> 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<VoteProposalDto>(
"""
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<Guid?>(
"""
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<VoteParticipantDto>(
"""
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<RescheduleOptionDto>(
"""
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<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
// 5. Re-render and update Discord vote message
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt,
options, participants, votes);
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 "Ваш голос учтён. До дедлайна его можно изменить.";
}
}
@@ -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<DiscordRescheduleVotingDeadlineService> 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("Discord", 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<PlatformMessageRefDto>(
"""
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<ButtonProperties>())
{
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<PlatformMessageRefDto>(
"""
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<SessionBatchDto>(
"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<ParticipantBatchDto>(
"""
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);
}
@@ -0,0 +1,64 @@
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record DiscordSessionInteractionInput(
Guid SessionId,
string InteractionId,
string GuildId,
string ChannelId,
string MessageId,
ulong UserId,
string Username,
string? DisplayName);
public static class DiscordSessionInteractionMapper
{
public static bool TryParseCustomId(string customId, string expectedAction, out Guid sessionId)
{
sessionId = default;
var parts = customId.Split(':', 2);
return parts.Length == 2
&& string.Equals(parts[0], expectedAction, StringComparison.Ordinal)
&& Guid.TryParse(parts[1], out sessionId);
}
public static JoinSessionCommand CreateJoinCommand(DiscordSessionInteractionInput input) =>
new(
SessionId: input.SessionId,
User: CreateUser(input),
InteractionId: input.InteractionId,
Group: CreateGroup(input),
ScheduleMessage: CreateMessageRef(input));
public static LeaveSessionCommand CreateLeaveCommand(DiscordSessionInteractionInput input) =>
new(
SessionId: input.SessionId,
User: CreateUser(input),
InteractionId: input.InteractionId,
Group: CreateGroup(input),
ScheduleMessage: CreateMessageRef(input));
private static PlatformUser CreateUser(DiscordSessionInteractionInput input) =>
new(
PlatformKind.Discord,
input.UserId.ToString(System.Globalization.CultureInfo.InvariantCulture),
string.IsNullOrWhiteSpace(input.DisplayName) ? input.Username : input.DisplayName,
input.Username);
private static PlatformGroup CreateGroup(DiscordSessionInteractionInput input) =>
new(
PlatformKind.Discord,
input.GuildId,
input.GuildId,
input.ChannelId);
private static PlatformMessageRef CreateMessageRef(DiscordSessionInteractionInput input) =>
new(
PlatformKind.Discord,
input.GuildId,
null,
input.MessageId);
}
@@ -0,0 +1,137 @@
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Features.Sessions.CreateSession;
using NetCord;
using NetCord.Rest;
using NetCord.Services.ComponentInteractions;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed class DiscordSessionInteractionModule(
JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
DiscordRescheduleVoteHandler voteHandler,
DiscordInteractionReplyCache interactionReplies,
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
{
[ComponentInteraction("join_session")]
public async Task JoinAsync(string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
try
{
await joinSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateJoinCommand(input),
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
}
[ComponentInteraction("leave_session")]
public async Task LeaveAsync(string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
try
{
await leaveSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateLeaveCommand(input),
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
return;
}
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
?? throw new InvalidOperationException("Session buttons can only be used in a guild.");
var message = Context.Interaction.Message
?? throw new InvalidOperationException("Session button interaction must include a message.");
return new DiscordSessionInteractionInput(
SessionId: sessionId,
InteractionId: Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
GuildId: guild.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
ChannelId: Context.Channel.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
MessageId: message.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
UserId: Context.User.Id,
Username: Context.User.Username,
DisplayName: Context.User.GlobalName);
}
private async Task CompleteWithStoredReplyAsync(string interactionId)
{
var reply = interactionReplies.Take(interactionId);
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
}
private Task CompleteResponseAsync(string text) =>
ModifyResponseAsync(options => options.Content = text);
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent(text)
.WithFlags(MessageFlags.Ephemeral));
}
@@ -0,0 +1,17 @@
using System.Collections.Concurrent;
using GmRelay.Shared.Platform;
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordInteractionReplyCache
{
private readonly ConcurrentDictionary<string, PlatformInteractionReply> replies = new(StringComparer.Ordinal);
public void Store(PlatformInteractionReply reply) =>
replies[reply.InteractionId] = reply;
public PlatformInteractionReply? Take(string interactionId) =>
replies.TryRemove(interactionId, out var reply)
? reply
: null;
}
@@ -6,7 +6,9 @@ using NetCord.Rest;
namespace GmRelay.DiscordBot.Infrastructure.Discord; namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformMessenger public sealed class DiscordPlatformMessenger(
RestClient restClient,
DiscordInteractionReplyCache interactionReplies) : IPlatformMessenger
{ {
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{ {
@@ -49,9 +51,15 @@ public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformM
}); });
} }
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) public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
@@ -61,6 +69,7 @@ public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformM
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct)
{ {
interactionReplies.Store(reply);
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -0,0 +1,6 @@
namespace GmRelay.DiscordBot.Infrastructure;
public sealed class SystemClock : GmRelay.Shared.Platform.ISystemClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
+12
View File
@@ -1,7 +1,10 @@
using GmRelay.DiscordBot; using GmRelay.DiscordBot;
using GmRelay.DiscordBot.Features.Sessions; using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Infrastructure;
using GmRelay.DiscordBot.Infrastructure.Discord; using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Infrastructure.Logging; using GmRelay.DiscordBot.Infrastructure.Logging;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -43,7 +46,16 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
builder.Services.AddSingleton<DiscordPermissionChecker>(); builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>(); builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>(); builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<DiscordRescheduleHandler>();
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<DiscordInteractionReplyCache>();
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>(); builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
builder.Services.AddSingleton<ISystemClock, SystemClock>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
builder.Services builder.Services
.AddDiscordGateway(options => .AddDiscordGateway(options =>
@@ -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<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyList<RescheduleOptionVoteDto> 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);
}
+6 -1
View File
@@ -666,7 +666,12 @@
} }
}, },
"gmrelay.shared": { "gmrelay.shared": {
"type": "Project" "type": "Project",
"dependencies": {
"Dapper": "[2.1.72, )",
"Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )",
"Npgsql": "[10.0.2, )"
}
} }
} }
} }
+1
View File
@@ -0,0 +1 @@
[module: Dapper.DapperAot]
@@ -4,8 +4,9 @@ using Npgsql;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record JoinSessionCommand( public sealed record JoinSessionCommand(
Guid SessionId, Guid SessionId,
@@ -15,15 +16,17 @@ public sealed record JoinSessionCommand(
PlatformMessageRef ScheduleMessage); PlatformMessageRef ScheduleMessage);
// DTOs for AOT compilation // DTOs for AOT compilation
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxPlayers); internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
public sealed class JoinSessionHandler( public sealed class JoinSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
IPlatformMessenger messenger, IPlatformMessenger messenger,
IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<JoinSessionHandler> logger) ILogger<JoinSessionHandler> logger)
{ {
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct) public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
{ {
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false; var transactionCommitted = false;
@@ -64,7 +67,7 @@ public sealed class JoinSessionHandler(
// 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав. // 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав.
var batchInfo = await connection.QuerySingleOrDefaultAsync<JoinSessionBatchDto>( var batchInfo = await connection.QuerySingleOrDefaultAsync<JoinSessionBatchDto>(
@"SELECT batch_id as BatchId, title as Title, max_players as MaxPlayers @"SELECT batch_id as BatchId, title as Title, status as Status, max_players as MaxPlayers
FROM sessions FROM sessions
WHERE id = @SessionId WHERE id = @SessionId
FOR UPDATE", FOR UPDATE",
@@ -78,6 +81,13 @@ public sealed class JoinSessionHandler(
return; return;
} }
if (SessionStatus.IsCancelled(batchInfo.Status))
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
return;
}
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>( var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
""" """
SELECT sp.registration_status SELECT sp.registration_status
@@ -2,9 +2,10 @@ using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging;
using Npgsql; using Npgsql;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record LeaveSessionCommand( public sealed record LeaveSessionCommand(
Guid SessionId, Guid SessionId,
@@ -20,10 +21,12 @@ internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string Di
public sealed class LeaveSessionHandler( public sealed class LeaveSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
IPlatformMessenger messenger, IPlatformMessenger messenger,
IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<LeaveSessionHandler> logger) ILogger<LeaveSessionHandler> logger)
{ {
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct) public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
{ {
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false; var transactionCommitted = false;
@@ -0,0 +1,39 @@
using System.Collections.Concurrent;
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public interface IScheduleMessageUpdateLock
{
ValueTask<IAsyncDisposable> AcquireAsync(PlatformMessageRef scheduleMessage, CancellationToken ct);
}
public sealed class ScheduleMessageUpdateLock : IScheduleMessageUpdateLock
{
private readonly ConcurrentDictionary<string, SemaphoreSlim> locks = new(StringComparer.Ordinal);
public async ValueTask<IAsyncDisposable> AcquireAsync(PlatformMessageRef scheduleMessage, CancellationToken ct)
{
var key = CreateKey(scheduleMessage);
var semaphore = locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync(ct);
return new Releaser(semaphore);
}
private static string CreateKey(PlatformMessageRef scheduleMessage) =>
string.Join(
'\u001F',
scheduleMessage.Platform.ToString(),
scheduleMessage.ExternalGroupId,
scheduleMessage.ExternalThreadId ?? string.Empty,
scheduleMessage.ExternalMessageId);
private sealed class Releaser(SemaphoreSlim semaphore) : IAsyncDisposable
{
public ValueTask DisposeAsync()
{
semaphore.Release();
return ValueTask.CompletedTask;
}
}
}
@@ -0,0 +1,29 @@
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record RescheduleOptionDto(
Guid OptionId,
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,
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);
@@ -1,13 +1,13 @@
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
internal enum RescheduleVoteOutcome public enum RescheduleVoteOutcome
{ {
Pending, Pending,
Rejected, Rejected,
Approved Approved
} }
internal sealed record RescheduleVoteDecision( public sealed record RescheduleVoteDecision(
RescheduleVoteOutcome Outcome, RescheduleVoteOutcome Outcome,
string Reason, string Reason,
Guid? SelectedOptionId = null, Guid? SelectedOptionId = null,
@@ -15,7 +15,7 @@ internal sealed record RescheduleVoteDecision(
bool ShouldRescheduleSession = false, bool ShouldRescheduleSession = false,
bool ShouldResetParticipantRsvps = false); bool ShouldResetParticipantRsvps = false);
internal static class RescheduleVoteRules public static class RescheduleVoteRules
{ {
public static RescheduleVoteDecision SelectWinner(IReadOnlyList<RescheduleOptionVoteCount> voteCounts) public static RescheduleVoteDecision SelectWinner(IReadOnlyList<RescheduleOptionVoteCount> voteCounts)
{ {
@@ -49,8 +49,8 @@ internal static class RescheduleVoteRules
{ {
return new RescheduleVoteDecision( return new RescheduleVoteDecision(
Outcome: RescheduleVoteOutcome.Rejected, 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.", Reason: "Один из участников отклонил перенос.",
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."); CallbackText: "Вы проголосовали против переноса.");
} }
var everyoneApproved = approvedParticipants == totalParticipants; var everyoneApproved = approvedParticipants == totalParticipants;
@@ -58,11 +58,11 @@ internal static class RescheduleVoteRules
return new RescheduleVoteDecision( return new RescheduleVoteDecision(
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending, Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending,
Reason: everyoneApproved 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 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, ShouldRescheduleSession: everyoneApproved,
ShouldResetParticipantRsvps: everyoneApproved); ShouldResetParticipantRsvps: everyoneApproved);
} }
@@ -0,0 +1,215 @@
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<RescheduleOptionDto> Options,
IReadOnlyList<RescheduleOptionVoteDto> Votes,
IReadOnlyList<VoteParticipantDto> Participants);
public sealed class RescheduleVotingFinalizer(
NpgsqlDataSource dataSource,
ISystemClock clock,
ILogger<RescheduleVotingFinalizer> logger)
{
public async Task<IReadOnlyList<Guid>> GetDueProposalIdsAsync(string sourcePlatform, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var proposalIds = (await connection.QueryAsync<Guid>(
"""
SELECT id
FROM reschedule_proposals
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, SourcePlatform = sourcePlatform })).ToList();
return proposalIds;
}
public async Task<RescheduleVotingFinalizerResult?> 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<ProposalRow>(
"""
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<VoteParticipantDto>(
"""
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<RescheduleOptionDto>(
"""
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<RescheduleOptionVoteDto>(
"""
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);
}
@@ -1,9 +1,9 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using GmRelay.Shared.Domain; 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<DateTimeOffset> Options, IReadOnlyList<DateTimeOffset> Options,
DateTimeOffset Deadline) DateTimeOffset Deadline)
{ {
@@ -93,18 +93,3 @@ internal sealed record RescheduleVotingInput(
|| normalized.StartsWith("до:", StringComparison.Ordinal); || 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);
+8
View File
@@ -5,6 +5,14 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Dapper.AOT" Version="1.0.48" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup>
</Project> </Project>
@@ -0,0 +1,6 @@
namespace GmRelay.Shared.Platform;
public interface ISystemClock
{
DateTimeOffset UtcNow { get; }
}
+35
View File
@@ -2,11 +2,46 @@
"version": 1, "version": 1,
"dependencies": { "dependencies": {
"net10.0": { "net10.0": {
"Dapper": {
"type": "Direct",
"requested": "[2.1.72, )",
"resolved": "2.1.72",
"contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
},
"Dapper.AOT": {
"type": "Direct",
"requested": "[1.0.48, )",
"resolved": "1.0.48",
"contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA=="
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Npgsql": {
"type": "Direct",
"requested": "[10.0.2, )",
"resolved": "10.0.2",
"contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
}
},
"SecurityCodeScan.VS2019": { "SecurityCodeScan.VS2019": {
"type": "Direct", "type": "Direct",
"requested": "[5.6.7, )", "requested": "[5.6.7, )",
"resolved": "5.6.7", "resolved": "5.6.7",
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ==" "contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA=="
} }
} }
} }
@@ -56,7 +56,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v2.4.0</div> <div class="nav-version">v2.6.0</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
+5 -1
View File
@@ -243,7 +243,11 @@
} }
}, },
"gmrelay.shared": { "gmrelay.shared": {
"type": "Project" "type": "Project",
"dependencies": {
"Dapper": "[2.1.72, )",
"Npgsql": "[10.0.2, )"
}
} }
} }
} }
@@ -7,9 +7,13 @@ namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordPlatformMessengerTests public sealed class DiscordPlatformMessengerTests
{ {
[Fact] [Fact]
public void Constructor_ShouldAcceptRestClient() public void Constructor_ShouldAcceptRestClientAndReplyCache()
{ {
var constructor = typeof(DiscordPlatformMessenger).GetConstructor(new[] { typeof(NetCord.Rest.RestClient) }); var constructor = typeof(DiscordPlatformMessenger).GetConstructor(new[]
{
typeof(NetCord.Rest.RestClient),
typeof(DiscordInteractionReplyCache)
});
Assert.NotNull(constructor); Assert.NotNull(constructor);
} }
@@ -18,4 +22,30 @@ public sealed class DiscordPlatformMessengerTests
{ {
Assert.True(typeof(IPlatformMessenger).IsAssignableFrom(typeof(DiscordPlatformMessenger))); Assert.True(typeof(IPlatformMessenger).IsAssignableFrom(typeof(DiscordPlatformMessenger)));
} }
[Fact]
public async Task AnswerInteractionAsync_ShouldStoreReplyForComponentModule()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs");
Assert.Contains("DiscordInteractionReplyCache", source, StringComparison.Ordinal);
Assert.Contains("interactionReplies.Store(reply)", source, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
} }
@@ -61,7 +61,7 @@ public sealed class DiscordProjectStructureTests
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml")); var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")); var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
Assert.Contains("gmrelay-discord-bot:2.4.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("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy); Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
Assert.Contains("DISCORD_BOT_TOKEN", deploy); Assert.Contains("DISCORD_BOT_TOKEN", deploy);
@@ -75,13 +75,13 @@ public sealed class DiscordProjectStructureTests
{ {
var repoRoot = GetRepoRoot(); var repoRoot = GetRepoRoot();
Assert.Contains("<Version>2.4.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); Assert.Contains("<Version>2.6.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 2.4.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); Assert.Contains("VERSION: 2.6.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:2.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains("gmrelay-bot:2.6.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:2.4.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.4.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( Assert.Contains(
"v2.4.0", "v2.6.0",
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor"))); File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
} }
} }
@@ -0,0 +1,96 @@
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordSessionInteractionMapperTests
{
[Fact]
public void TryParseCustomId_WhenActionAndSessionIdMatch_ReturnsSessionId()
{
var sessionId = Guid.NewGuid();
var result = DiscordSessionInteractionMapper.TryParseCustomId(
$"join_session:{sessionId}",
"join_session",
out var parsedSessionId);
Assert.True(result);
Assert.Equal(sessionId, parsedSessionId);
}
[Fact]
public void TryParseCustomId_WhenActionDoesNotMatch_ReturnsFalse()
{
var result = DiscordSessionInteractionMapper.TryParseCustomId(
$"leave_session:{Guid.NewGuid()}",
"join_session",
out _);
Assert.False(result);
}
[Fact]
public void TryParseCustomId_WhenSessionIdIsInvalid_ReturnsFalse()
{
var result = DiscordSessionInteractionMapper.TryParseCustomId(
"join_session:not-a-guid",
"join_session",
out _);
Assert.False(result);
}
[Fact]
public void CreateJoinCommand_ShouldBuildPlatformNeutralDiscordCommand()
{
var sessionId = Guid.NewGuid();
var input = CreateInput(sessionId, displayName: "Alice GM");
JoinSessionCommand command = DiscordSessionInteractionMapper.CreateJoinCommand(input);
Assert.Equal(sessionId, command.SessionId);
Assert.Equal("interaction-1", command.InteractionId);
Assert.Equal(PlatformKind.Discord, command.User.Platform);
Assert.Equal("42", command.User.ExternalUserId);
Assert.Equal("Alice GM", command.User.DisplayName);
Assert.Equal("alice", command.User.ExternalUsername);
Assert.Equal(PlatformKind.Discord, command.Group.Platform);
Assert.Equal("guild-1", command.Group.ExternalGroupId);
Assert.Equal("channel-1", command.Group.ExternalChannelId);
Assert.Equal(PlatformKind.Discord, command.ScheduleMessage.Platform);
Assert.Equal("guild-1", command.ScheduleMessage.ExternalGroupId);
Assert.Equal("message-1", command.ScheduleMessage.ExternalMessageId);
}
[Fact]
public void CreateLeaveCommand_ShouldBuildPlatformNeutralDiscordCommand()
{
var sessionId = Guid.NewGuid();
var input = CreateInput(sessionId, displayName: null);
LeaveSessionCommand command = DiscordSessionInteractionMapper.CreateLeaveCommand(input);
Assert.Equal(sessionId, command.SessionId);
Assert.Equal("interaction-1", command.InteractionId);
Assert.Equal(PlatformKind.Discord, command.User.Platform);
Assert.Equal("42", command.User.ExternalUserId);
Assert.Equal("alice", command.User.DisplayName);
Assert.Equal("alice", command.User.ExternalUsername);
Assert.Equal("guild-1", command.Group.ExternalGroupId);
Assert.Equal("channel-1", command.Group.ExternalChannelId);
Assert.Equal("message-1", command.ScheduleMessage.ExternalMessageId);
}
private static DiscordSessionInteractionInput CreateInput(Guid sessionId, string? displayName)
=> new(
SessionId: sessionId,
InteractionId: "interaction-1",
GuildId: "guild-1",
ChannelId: "channel-1",
MessageId: "message-1",
UserId: 42,
Username: "alice",
DisplayName: displayName);
}
@@ -0,0 +1,40 @@
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordSessionInteractionModuleSourceTests
{
[Fact]
public async Task Module_ShouldRouteJoinAndLeaveButtonsToNeutralHandlers()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
Assert.Contains("ComponentInteractionModule<ButtonInteractionContext>", source, StringComparison.Ordinal);
Assert.Contains("[ComponentInteraction(\"join_session\")]", source, StringComparison.Ordinal);
Assert.Contains("[ComponentInteraction(\"leave_session\")]", source, StringComparison.Ordinal);
Assert.Contains("JoinSessionHandler", source, StringComparison.Ordinal);
Assert.Contains("LeaveSessionHandler", source, StringComparison.Ordinal);
Assert.Contains("DiscordSessionInteractionMapper.CreateJoinCommand", source, StringComparison.Ordinal);
Assert.Contains("DiscordSessionInteractionMapper.CreateLeaveCommand", source, StringComparison.Ordinal);
Assert.Contains("RespondAsync", source, StringComparison.Ordinal);
Assert.Contains("InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)", source, StringComparison.Ordinal);
Assert.Contains("ModifyResponseAsync", source, StringComparison.Ordinal);
Assert.Contains("Не удалось обработать кнопку.", source, StringComparison.Ordinal);
Assert.Contains("MessageFlags.Ephemeral", source, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -77,6 +77,8 @@ public sealed class DiscordStartupTests
var program = ReadProgram(); var program = ReadProgram();
Assert.Contains("DiscordListSessionsHandler", program); Assert.Contains("DiscordListSessionsHandler", program);
Assert.Contains("DiscordNewSessionHandler", program); Assert.Contains("DiscordNewSessionHandler", program);
Assert.Contains("JoinSessionHandler", program);
Assert.Contains("LeaveSessionHandler", program);
Assert.Contains("DiscordPermissionChecker", program); Assert.Contains("DiscordPermissionChecker", program);
Assert.Contains("DiscordPlatformMessenger", program); Assert.Contains("DiscordPlatformMessenger", program);
Assert.Contains("IPlatformMessenger", program); Assert.Contains("IPlatformMessenger", program);
@@ -1,6 +1,7 @@
using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups; using Telegram.Bot.Types.ReplyMarkups;
using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Bot.Infrastructure.Telegram;
@@ -1,4 +1,4 @@
using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession; namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
@@ -5,7 +5,7 @@ public sealed class PlatformNeutralSessionInteractionSqlTests
[Fact] [Fact]
public async Task JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity() public async Task JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity()
{ {
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs"); var handler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs");
Assert.Contains("platform, external_user_id", handler, StringComparison.Ordinal); Assert.Contains("platform, external_user_id", handler, StringComparison.Ordinal);
Assert.Contains("ON CONFLICT (platform, external_user_id)", handler, StringComparison.Ordinal); Assert.Contains("ON CONFLICT (platform, external_user_id)", handler, StringComparison.Ordinal);
@@ -16,10 +16,20 @@ public sealed class PlatformNeutralSessionInteractionSqlTests
Assert.DoesNotContain("command.TelegramUsername", handler, StringComparison.Ordinal); Assert.DoesNotContain("command.TelegramUsername", handler, StringComparison.Ordinal);
} }
[Fact]
public async Task JoinSessionHandler_ShouldRejectCancelledSessionsBeforeInsert()
{
var handler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs");
Assert.Contains("Status", handler, StringComparison.Ordinal);
Assert.Contains("SessionStatus.IsCancelled(batchInfo.Status)", handler, StringComparison.Ordinal);
Assert.Contains("Сессия уже отменена.", handler, StringComparison.Ordinal);
}
[Fact] [Fact]
public async Task LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity() public async Task LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity()
{ {
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs"); var handler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
Assert.Contains("p.platform = @Platform", handler, StringComparison.Ordinal); Assert.Contains("p.platform = @Platform", handler, StringComparison.Ordinal);
Assert.Contains("p.external_user_id = @ExternalUserId", handler, StringComparison.Ordinal); Assert.Contains("p.external_user_id = @ExternalUserId", handler, StringComparison.Ordinal);
@@ -31,8 +41,8 @@ public sealed class PlatformNeutralSessionInteractionSqlTests
[Fact] [Fact]
public async Task SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference() public async Task SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference()
{ {
var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs"); var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs");
var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs"); var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
Assert.Contains("new PlatformScheduleMessage(", joinHandler, StringComparison.Ordinal); Assert.Contains("new PlatformScheduleMessage(", joinHandler, StringComparison.Ordinal);
Assert.Contains("command.Group", joinHandler, StringComparison.Ordinal); Assert.Contains("command.Group", joinHandler, StringComparison.Ordinal);
@@ -42,6 +52,16 @@ public sealed class PlatformNeutralSessionInteractionSqlTests
Assert.Contains("command.ScheduleMessage", leaveHandler, StringComparison.Ordinal); Assert.Contains("command.ScheduleMessage", leaveHandler, StringComparison.Ordinal);
} }
[Fact]
public async Task SessionInteractionHandlers_ShouldSerializeScheduleMessageUpdates()
{
var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs");
var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
Assert.Contains("scheduleUpdateLock.AcquireAsync(command.ScheduleMessage", joinHandler, StringComparison.Ordinal);
Assert.Contains("scheduleUpdateLock.AcquireAsync(command.ScheduleMessage", leaveHandler, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath) private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{ {
var directory = new DirectoryInfo(AppContext.BaseDirectory); var directory = new DirectoryInfo(AppContext.BaseDirectory);
@@ -0,0 +1,66 @@
using System.Xml.Linq;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class SharedDapperAotConfigurationTests
{
[Fact]
public void SharedProject_ShouldEnableDapperAotForSessionInteractionHandlers()
{
var repoRoot = FindRepositoryRoot();
var sharedProjectPath = Path.Combine(repoRoot, "src", "GmRelay.Shared", "GmRelay.Shared.csproj");
var joinHandler = File.ReadAllText(Path.Combine(
repoRoot,
"src",
"GmRelay.Shared",
"Features",
"Sessions",
"CreateSession",
"JoinSessionHandler.cs"));
var leaveHandler = File.ReadAllText(Path.Combine(
repoRoot,
"src",
"GmRelay.Shared",
"Features",
"Sessions",
"CreateSession",
"LeaveSessionHandler.cs"));
Assert.Contains("using Dapper;", joinHandler, StringComparison.Ordinal);
Assert.Contains("using Dapper;", leaveHandler, StringComparison.Ordinal);
var project = XDocument.Load(sharedProjectPath);
var packageReferences = project
.Descendants("PackageReference")
.Select(reference => reference.Attribute("Include")?.Value)
.ToArray();
var interceptorNamespaces = project
.Descendants("InterceptorsPreviewNamespaces")
.Select(element => element.Value)
.ToArray();
var moduleAttributeFiles = Directory
.EnumerateFiles(Path.GetDirectoryName(sharedProjectPath)!, "*.cs", SearchOption.AllDirectories)
.Select(File.ReadAllText)
.ToArray();
Assert.Contains("Dapper.AOT", packageReferences);
Assert.Contains(interceptorNamespaces, value => value.Contains("Dapper.AOT", StringComparison.Ordinal));
Assert.Contains(moduleAttributeFiles, source => source.Contains("[module: Dapper.DapperAot]", StringComparison.Ordinal));
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "Directory.Build.props")))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new InvalidOperationException("Could not locate repository root.");
}
}
@@ -1,4 +1,5 @@
using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
@@ -1,4 +1,4 @@
using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Shared.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
@@ -74,7 +74,7 @@ public sealed class PlatformIdentityMigrationTests
[Fact] [Fact]
public async Task JoinSessionHandler_ShouldDualWritePlatformIdentity() public async Task JoinSessionHandler_ShouldDualWritePlatformIdentity()
{ {
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs"); var handler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs");
Assert.Contains("external_user_id", handler, StringComparison.Ordinal); Assert.Contains("external_user_id", handler, StringComparison.Ordinal);
Assert.Contains("external_username", handler, StringComparison.Ordinal); Assert.Contains("external_username", handler, StringComparison.Ordinal);
@@ -12,8 +12,8 @@ public sealed class TelegramPlatformMessengerSourceTests
} }
[Theory] [Theory]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs")] [InlineData("src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs")] [InlineData("src/GmRelay.Shared/Features/Sessions/CreateSession/LeaveSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs")]
@@ -54,7 +54,7 @@ public sealed class TelegramTopicIntegrationSmokeTests
Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
Assert.Contains("s.thread_id AS 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<string> ReadRepositoryFileAsync(string relativePath) private static async Task<string> ReadRepositoryFileAsync(string relativePath)
@@ -0,0 +1,41 @@
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Platform;
public sealed class ScheduleMessageUpdateLockTests
{
[Fact]
public async Task AcquireAsync_ShouldSerializeSameScheduleMessage()
{
var updateLock = new ScheduleMessageUpdateLock();
var message = CreateMessage("message-1");
var first = await updateLock.AcquireAsync(message, CancellationToken.None);
var secondTask = updateLock.AcquireAsync(message, CancellationToken.None).AsTask();
Assert.False(secondTask.IsCompleted);
await first.DisposeAsync();
var second = await secondTask.WaitAsync(TimeSpan.FromSeconds(1));
await second.DisposeAsync();
}
[Fact]
public async Task AcquireAsync_ShouldNotBlockDifferentScheduleMessages()
{
var updateLock = new ScheduleMessageUpdateLock();
var first = await updateLock.AcquireAsync(CreateMessage("message-1"), CancellationToken.None);
var secondTask = updateLock.AcquireAsync(CreateMessage("message-2"), CancellationToken.None).AsTask();
Assert.True(secondTask.IsCompleted);
await first.DisposeAsync();
var second = await secondTask;
await second.DisposeAsync();
}
private static PlatformMessageRef CreateMessage(string messageId) =>
new(PlatformKind.Discord, "guild-1", null, messageId);
}
+11 -7
View File
@@ -392,8 +392,8 @@
"Aspire.Npgsql": "[13.2.2, )", "Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )", "Dapper": "[2.1.72, )",
"Dapper.AOT": "[1.0.48, )", "Dapper.AOT": "[1.0.48, )",
"GmRelay.ServiceDefaults": "[2.3.0, )", "GmRelay.ServiceDefaults": "[2.5.0, )",
"GmRelay.Shared": "[2.3.0, )", "GmRelay.Shared": "[2.5.0, )",
"Npgsql": "[10.0.2, )", "Npgsql": "[10.0.2, )",
"Telegram.Bot": "[22.9.5.3, )", "Telegram.Bot": "[22.9.5.3, )",
"dbup-postgresql": "[7.0.1, )" "dbup-postgresql": "[7.0.1, )"
@@ -404,8 +404,8 @@
"dependencies": { "dependencies": {
"Aspire.Npgsql": "[13.2.2, )", "Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )", "Dapper": "[2.1.72, )",
"GmRelay.ServiceDefaults": "[2.3.0, )", "GmRelay.ServiceDefaults": "[2.5.0, )",
"GmRelay.Shared": "[2.3.0, )", "GmRelay.Shared": "[2.5.0, )",
"NetCord.Hosting": "[1.0.0-alpha.489, )", "NetCord.Hosting": "[1.0.0-alpha.489, )",
"NetCord.Hosting.Services": "[1.0.0-alpha.489, )", "NetCord.Hosting.Services": "[1.0.0-alpha.489, )",
"NetCord.Services": "[1.0.0-alpha.489, )", "NetCord.Services": "[1.0.0-alpha.489, )",
@@ -425,15 +425,19 @@
} }
}, },
"gmrelay.shared": { "gmrelay.shared": {
"type": "Project" "type": "Project",
"dependencies": {
"Dapper": "[2.1.72, )",
"Npgsql": "[10.0.2, )"
}
}, },
"gmrelay.web": { "gmrelay.web": {
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {
"Aspire.Npgsql": "[13.2.2, )", "Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )", "Dapper": "[2.1.72, )",
"GmRelay.ServiceDefaults": "[2.3.0, )", "GmRelay.ServiceDefaults": "[2.5.0, )",
"GmRelay.Shared": "[2.3.0, )", "GmRelay.Shared": "[2.5.0, )",
"Npgsql": "[10.0.2, )", "Npgsql": "[10.0.2, )",
"Telegram.Bot": "[22.9.6.1, )" "Telegram.Bot": "[22.9.6.1, )"
} }