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
This commit is contained in:
2026-05-19 14:13:48 +03:00
parent 90da33154c
commit 39132be4e8
32 changed files with 644 additions and 78 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 2.4.0 VERSION: 2.5.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.5.0</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+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 и восстановление
+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.5.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.5.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.5.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")
``` ```
@@ -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;
+2
View File
@@ -10,6 +10,7 @@ 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 +64,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>();
+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,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,101 @@
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,
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);
}
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)
{ {
@@ -61,6 +63,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;
} }
+5
View File
@@ -2,6 +2,7 @@ using GmRelay.DiscordBot;
using GmRelay.DiscordBot.Features.Sessions; using GmRelay.DiscordBot.Features.Sessions;
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.Platform; using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -43,6 +44,10 @@ 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<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 builder.Services
+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, )"
}
} }
} }
} }
@@ -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;
}
}
}
+6
View File
@@ -7,4 +7,10 @@
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup>
</Project> </Project>
+29
View File
@@ -2,11 +2,40 @@
"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=="
},
"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.5.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.5.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.5.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.5.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.5.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.5.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.5.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains( Assert.Contains(
"v2.4.0", "v2.5.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,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);
@@ -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")]
@@ -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, )"
} }