Compare commits

..

4 Commits

Author SHA1 Message Date
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
Toutsu 90da33154c Merge pull request #85: feat(discord): implement /newsession and /listsessions (issue #28)
Deploy Telegram Bot / build-and-push (push) Successful in 4m19s
Deploy Telegram Bot / scan-images (push) Successful in 1m17s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-19 12:53:35 +03:00
34 changed files with 719 additions and 78 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 2.4.0
VERSION: 2.5.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>2.4.0</Version>
<Version>2.5.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+8 -1
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v2.2.0`.
**Текущая версия:** `v2.5.0`.
---
@@ -24,6 +24,11 @@
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
### Discord Bot
- **Slash-команды расписания**: GM создаёт сессию через `/newsession` и публикует актуальное расписание через `/listsessions`.
- **Кнопки записи и выхода**: игроки нажимают Join/Leave в Discord-сообщении; бот отвечает ephemeral-сообщением и обновляет schedule message.
- **Лимиты и waitlist**: при заполненном составе игрок попадает в waitlist, а при выходе участника первый ожидающий автоматически продвигается в основной состав.
### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через Telegram**: Telegram Login Widget с HMAC-SHA256 валидацией.
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
@@ -108,6 +113,8 @@ docker compose up -d
1. Напишите боту `/start`.
2. Создайте группу через `/newgroup`.
3. Откройте Mini App или Web Dashboard для расширенного управления.
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`.
5. В Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
## 💾 Backup и восстановление
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.4.0
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.5.0
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
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
depends_on:
db:
@@ -79,7 +79,7 @@ services:
- gmrelay
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:2.4.0
image: git.codeanddice.ru/toutsu/gmrelay-web:2.5.0
restart: always
depends_on:
db:
+71 -39
View File
@@ -1,4 +1,4 @@
# GM-Relay C4 Model
# GM-Relay - C4 Model
## Level 1: System Context
@@ -6,19 +6,24 @@
C4Context
title GM-Relay System Context
Person(gm, "Game Master", "Создаёт сессии, управляет расписанием игр")
Person(player, "Player", "Подтверждает участие через inline-кнопки")
Person(gm, "Game Master", "Creates sessions and manages schedules")
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.")
SystemDb_Ext(postgres, "PostgreSQL", "Сессии, игроки, RSVP-статусы")
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
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(player, telegram, "Нажимает кнопки (✅ Буду / ❌ Не смогу)")
Rel(telegram, gmrelay, "Updates (Long Polling)")
Rel(gm, telegram, "Creates and manages sessions")
Rel(gm, discord, "Uses /newsession and /listsessions")
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, 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
@@ -30,49 +35,76 @@ C4Container
Person(gm, "Game Master")
Person(player, "Player")
System_Boundary(pi, "Raspberry Pi 5") {
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Long polling, обработка команд и callback queries, планировщик")
ContainerDb(db, "PostgreSQL 16", "Database", "sessions, players, session_participants, game_groups")
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
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(discord, "Discord Gateway and REST API")
Rel(gm, telegram, "Commands")
Rel(player, telegram, "Callback Queries")
Rel(telegram, bot, "GetUpdates (Long Polling)")
Rel(gm, discord, "Slash commands")
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(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(discordBot, db, "Npgsql + Dapper")
Rel(web, db, "Npgsql + Dapper")
```
## Level 3: Component (GmRelay.Bot)
## Level 3: Component - Session Interactions
```mermaid
C4Component
title GmRelay.Bot Components
title Platform-Neutral Session Interactions
Container_Boundary(bot, "GmRelay.Bot") {
Component(polling, "TelegramBotService", "BackgroundService", "Long polling loop, получает Updates")
Component(router, "UpdateRouter", "C#", "Маршрутизирует Update → Handler по типу")
Component(scheduler, "SessionSchedulerService", "BackgroundService", "PeriodicTimer(60s): T-24ч и T-5мин триггеры")
Component(migrator, "DbMigrator", "DbUp", "SQL миграции при старте")
Component(confirm, "SendConfirmationHandler", "Feature", "Отправляет inline keyboard за 24ч")
Component(rsvp, "HandleRsvpHandler", "Feature", "Обрабатывает ✅/❌, проверяет all-confirmed")
Component(link, "SendJoinLinkHandler", "Feature", "Отправляет join link за 5 мин")
Container_Boundary(shared, "GmRelay.Shared") {
Component(join, "JoinSessionHandler", "Feature handler", "Adds players as Active or Waitlisted with session row locking")
Component(leave, "LeaveSessionHandler", "Feature handler", "Removes players and promotes the first waitlisted player when capacity allows")
Component(updateLock, "ScheduleMessageUpdateLock", "In-memory keyed lock", "Serializes DB changes and schedule message edits per platform message")
Component(renderer, "SessionBatchViewBuilder", "Renderer model builder", "Builds platform-neutral schedule views and actions")
}
System_Ext(telegram, "Telegram Bot API")
ContainerDb(db, "PostgreSQL")
Container_Boundary(discordBot, "GmRelay.DiscordBot") {
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")
Rel(router, rsvp, "CallbackQuery rsvp:*")
Rel(scheduler, confirm, "T-24h trigger")
Rel(scheduler, link, "T-5min trigger")
Rel(confirm, telegram, "SendMessage + InlineKeyboard")
Rel(rsvp, telegram, "EditMessage + AnswerCallback")
Rel(link, telegram, "SendMessage + user mentions")
Rel(confirm, db, "SELECT/UPDATE sessions")
Rel(rsvp, db, "UPDATE participants, SELECT counts")
Rel(link, db, "SELECT confirmed players")
Rel(migrator, db, "DDL migrations")
Container_Boundary(bot, "GmRelay.Bot") {
Component(updateRouter, "UpdateRouter", "Telegram adapter", "Maps callback queries to neutral commands")
Component(telegramMessenger, "TelegramPlatformMessenger", "IPlatformMessenger", "Edits Telegram schedule messages and answers callback queries")
}
ContainerDb(db, "PostgreSQL")
System_Ext(telegram, "Telegram Bot API")
System_Ext(discord, "Discord Gateway and REST API")
Rel(discord, discordModule, "Button interaction")
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
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
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.Scheduling;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
@@ -63,6 +64,7 @@ builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<
builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<CreateSessionHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
+6 -1
View File
@@ -661,7 +661,12 @@
}
},
"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": {
@@ -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;
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)
{
@@ -61,6 +63,7 @@ public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformM
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct)
{
interactionReplies.Store(reply);
return Task.CompletedTask;
}
+5
View File
@@ -2,6 +2,7 @@ using GmRelay.DiscordBot;
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Infrastructure.Logging;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -43,6 +44,10 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
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
+6 -1
View File
@@ -666,7 +666,12 @@
}
},
"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.Platform;
using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record JoinSessionCommand(
Guid SessionId,
@@ -15,15 +16,17 @@ public sealed record JoinSessionCommand(
PlatformMessageRef ScheduleMessage);
// 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(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<JoinSessionHandler> logger)
{
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 transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false;
@@ -64,7 +67,7 @@ public sealed class JoinSessionHandler(
// 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав.
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
WHERE id = @SessionId
FOR UPDATE",
@@ -78,6 +81,13 @@ public sealed class JoinSessionHandler(
return;
}
if (SessionStatus.IsCancelled(batchInfo.Status))
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
return;
}
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
"""
SELECT sp.registration_status
@@ -2,9 +2,10 @@ using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record LeaveSessionCommand(
Guid SessionId,
@@ -20,10 +21,12 @@ internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string Di
public sealed class LeaveSessionHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<LeaveSessionHandler> logger)
{
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 transaction = await connection.BeginTransactionAsync(ct);
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;
}
}
}
+8
View File
@@ -5,6 +5,14 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>
</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>
+35
View File
@@ -2,11 +2,46 @@
"version": 1,
"dependencies": {
"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": {
"type": "Direct",
"requested": "[5.6.7, )",
"resolved": "5.6.7",
"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>
</form>
<div class="nav-version">v2.4.0</div>
<div class="nav-version">v2.5.0</div>
</div>
</Authorized>
<NotAuthorized>
+5 -1
View File
@@ -243,7 +243,11 @@
}
},
"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
{
[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);
}
@@ -18,4 +22,30 @@ public sealed class DiscordPlatformMessengerTests
{
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 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("src/GmRelay.DiscordBot/Dockerfile", deploy);
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
@@ -75,13 +75,13 @@ public sealed class DiscordProjectStructureTests
{
var repoRoot = GetRepoRoot();
Assert.Contains("<Version>2.4.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("gmrelay-bot:2.4.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-discord-bot:2.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("<Version>2.5.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 2.5.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:2.5.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:2.5.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:2.5.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains(
"v2.4.0",
"v2.5.0",
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();
Assert.Contains("DiscordListSessionsHandler", program);
Assert.Contains("DiscordNewSessionHandler", program);
Assert.Contains("JoinSessionHandler", program);
Assert.Contains("LeaveSessionHandler", program);
Assert.Contains("DiscordPermissionChecker", program);
Assert.Contains("DiscordPlatformMessenger", 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;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
@@ -5,7 +5,7 @@ public sealed class PlatformNeutralSessionInteractionSqlTests
[Fact]
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("ON CONFLICT (platform, external_user_id)", handler, StringComparison.Ordinal);
@@ -16,10 +16,20 @@ public sealed class PlatformNeutralSessionInteractionSqlTests
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]
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.external_user_id = @ExternalUserId", handler, StringComparison.Ordinal);
@@ -31,8 +41,8 @@ public sealed class PlatformNeutralSessionInteractionSqlTests
[Fact]
public async Task SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference()
{
var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
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("new PlatformScheduleMessage(", 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);
}
[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)
{
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.");
}
}
@@ -74,7 +74,7 @@ public sealed class PlatformIdentityMigrationTests
[Fact]
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_username", handler, StringComparison.Ordinal);
@@ -12,8 +12,8 @@ public sealed class TelegramPlatformMessengerSourceTests
}
[Theory]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs")]
[InlineData("src/GmRelay.Shared/Features/Sessions/CreateSession/JoinSessionHandler.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/PromoteWaitlistedPlayerHandler.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, )",
"Dapper": "[2.1.72, )",
"Dapper.AOT": "[1.0.48, )",
"GmRelay.ServiceDefaults": "[2.3.0, )",
"GmRelay.Shared": "[2.3.0, )",
"GmRelay.ServiceDefaults": "[2.5.0, )",
"GmRelay.Shared": "[2.5.0, )",
"Npgsql": "[10.0.2, )",
"Telegram.Bot": "[22.9.5.3, )",
"dbup-postgresql": "[7.0.1, )"
@@ -404,8 +404,8 @@
"dependencies": {
"Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )",
"GmRelay.ServiceDefaults": "[2.3.0, )",
"GmRelay.Shared": "[2.3.0, )",
"GmRelay.ServiceDefaults": "[2.5.0, )",
"GmRelay.Shared": "[2.5.0, )",
"NetCord.Hosting": "[1.0.0-alpha.489, )",
"NetCord.Hosting.Services": "[1.0.0-alpha.489, )",
"NetCord.Services": "[1.0.0-alpha.489, )",
@@ -425,15 +425,19 @@
}
},
"gmrelay.shared": {
"type": "Project"
"type": "Project",
"dependencies": {
"Dapper": "[2.1.72, )",
"Npgsql": "[10.0.2, )"
}
},
"gmrelay.web": {
"type": "Project",
"dependencies": {
"Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )",
"GmRelay.ServiceDefaults": "[2.3.0, )",
"GmRelay.Shared": "[2.3.0, )",
"GmRelay.ServiceDefaults": "[2.5.0, )",
"GmRelay.Shared": "[2.5.0, )",
"Npgsql": "[10.0.2, )",
"Telegram.Bot": "[22.9.6.1, )"
}