refactor: make session join leave platform-neutral
PR Checks / test-and-build (pull_request) Successful in 5m3s

Convert join/leave interaction commands to PlatformUser, PlatformGroup, and PlatformMessageRef. Persist and look up participants by platform identity while keeping Telegram callbacks intact. Add V017 migration and TDD coverage. Bump version to 2.1.1.
This commit is contained in:
2026-05-18 13:30:48 +03:00
parent cb515b0e05
commit e791fc2f4a
12 changed files with 803 additions and 56 deletions
@@ -1,20 +1,18 @@
using System.Globalization;
using Dapper;
using Npgsql;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
public sealed record JoinSessionCommand(
Guid SessionId,
long TelegramUserId,
string DisplayName,
string? TelegramUsername,
string CallbackQueryId,
long ChatId,
int MessageId);
PlatformUser User,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
// DTOs for AOT compilation
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxPlayers);
@@ -33,17 +31,35 @@ public sealed class JoinSessionHandler(
try
{
// 1. Убеждаемся, что игрок есть в базе
var platform = command.User.Platform.ToString();
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
: (long?)null;
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
? command.User.ExternalUsername
: null;
var playerId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TgId, @Name, @Username, 'Telegram', @TgId::TEXT, @Username)
ON CONFLICT (telegram_id) DO UPDATE
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username,
platform = COALESCE(players.platform, 'Telegram'),
external_user_id = COALESCE(players.external_user_id, EXCLUDED.telegram_id::TEXT),
external_username = COALESCE(players.external_username, EXCLUDED.telegram_username)
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
platform = EXCLUDED.platform,
external_user_id = EXCLUDED.external_user_id,
external_username = EXCLUDED.external_username
RETURNING id;",
new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername },
new
{
LegacyTelegramId = legacyTelegramId,
Name = command.User.DisplayName,
LegacyTelegramUsername = legacyTelegramUsername,
Platform = platform,
command.User.ExternalUserId,
command.User.ExternalUsername
},
transaction);
// 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав.
@@ -58,7 +74,7 @@ public sealed class JoinSessionHandler(
if (batchInfo is null)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
return;
}
@@ -79,7 +95,7 @@ public sealed class JoinSessionHandler(
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы уже в листе ожидания!"
: "Вы уже записаны!";
await AnswerAsync(command.CallbackQueryId, alreadyText, ct);
await AnswerAsync(command.InteractionId, alreadyText, ct);
return;
}
@@ -113,7 +129,7 @@ public sealed class JoinSessionHandler(
if (inserted == 0)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Вы уже записаны!", ct);
await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
return;
}
@@ -128,7 +144,7 @@ public sealed class JoinSessionHandler(
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId,
p.display_name as DisplayName,
p.telegram_username as TelegramUsername,
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
sp.registration_status as RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -144,15 +160,15 @@ public sealed class JoinSessionHandler(
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(command.ChatId),
command.Group,
view,
TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)),
command.ScheduleMessage),
ct);
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Основной состав заполнен. Вы добавлены в лист ожидания."
: "Вы успешно записаны!";
await AnswerAsync(command.CallbackQueryId, callbackText, ct);
await AnswerAsync(command.InteractionId, callbackText, ct);
}
catch (Exception ex)
{
@@ -165,10 +181,10 @@ public sealed class JoinSessionHandler(
var errorText = transactionCommitted
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
: "Произошла ошибка при регистрации.";
await AnswerAsync(command.CallbackQueryId, errorText, ct);
await AnswerAsync(command.InteractionId, errorText, ct);
}
}
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text), ct);
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
}
@@ -3,16 +3,15 @@ using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
public sealed record LeaveSessionCommand(
Guid SessionId,
long TelegramUserId,
string CallbackQueryId,
long ChatId,
int MessageId);
PlatformUser User,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
@@ -47,17 +46,19 @@ public sealed class LeaveSessionHandler(
if (session is null)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
return;
}
if (SessionStatus.IsCancelled(session.Status))
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Сессия уже отменена.", ct);
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
return;
}
var platform = command.User.Platform.ToString();
var participant = await connection.QuerySingleOrDefaultAsync<LeaveSessionParticipantDto>(
"""
SELECT sp.id AS ParticipantRowId,
@@ -66,17 +67,18 @@ public sealed class LeaveSessionHandler(
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND sp.is_gm = false
FOR UPDATE OF sp
""",
new { command.SessionId, command.TelegramUserId },
new { command.SessionId, Platform = platform, command.User.ExternalUserId },
transaction);
if (participant is null)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Вы не записаны на эту сессию.", ct);
await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
return;
}
@@ -170,7 +172,7 @@ public sealed class LeaveSessionHandler(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -187,9 +189,9 @@ public sealed class LeaveSessionHandler(
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(command.ChatId),
command.Group,
view,
TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)),
command.ScheduleMessage),
ct);
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
@@ -198,7 +200,7 @@ public sealed class LeaveSessionHandler(
? "Вы отписались от сессии."
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
await AnswerAsync(command.CallbackQueryId, callbackText, ct);
await AnswerAsync(command.InteractionId, callbackText, ct);
}
catch (Exception ex)
{
@@ -211,10 +213,10 @@ public sealed class LeaveSessionHandler(
var errorText = transactionCommitted
? "Запись снята, но не удалось обновить сообщение расписания."
: "Произошла ошибка при отмене записи.";
await AnswerAsync(command.CallbackQueryId, errorText, ct);
await AnswerAsync(command.InteractionId, errorText, ct);
}
}
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text), ct);
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
}