Compare commits

...

26 Commits

Author SHA1 Message Date
Toutsu 7a2965b43f fix(bot): add missing DI registrations for shared DeleteSessionHandler and ListSessionsHandler
Deploy Telegram Bot / build-and-push (push) Successful in 6m39s
Deploy Telegram Bot / scan-images (push) Successful in 3m26s
Deploy Telegram Bot / deploy (push) Successful in 29s
PR #106 extracted DeleteSessionHandler and ListSessionsHandler to GmRelay.Shared,
but forgot to register the shared implementations in Program.cs. This caused
an InvalidOperationException at startup on Native AOT builds because the Bot
wrappers could not resolve their shared dependencies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:58:51 +03:00
Toutsu a0df94fc91 Merge branch 'main' of ssh://git.codeanddice.ru:222/Toutsu/GmRelayBot
Deploy Telegram Bot / build-and-push (push) Successful in 7m11s
Deploy Telegram Bot / scan-images (push) Successful in 3m27s
Deploy Telegram Bot / deploy (push) Failing after 1m3s
2026-05-27 15:19:32 +03:00
Toutsu 79694f7de8 Merge pull request #106: refactor: extract remaining Telegram handlers to platform-neutral contracts 2026-05-27 15:19:23 +03:00
Toutsu 542f15f2d6 refactor: extract remaining Telegram handlers to platform-neutral contracts
PR Checks / test-and-build (pull_request) Successful in 13m48s
- Extract CreateSessionHandler, ListSessionsHandler, DeleteSessionHandler,
  ExportCalendarHandler, HandleRescheduleTimeInputHandler,
  HandleRescheduleVoteHandler to GmRelay.Shared
- Add IPlatformMessenger methods: SendScheduleAsync, UpdateScheduleAsync,
  SendGroupMessageAsync with actions, CreateThreadAsync, DeleteThreadAsync
- Rewrite Telegram Bot wrappers as thin adapters delegating to shared handlers
- Rewrite DiscordRescheduleVoteHandler to use shared HandleRescheduleVoteHandler
- Update UpdateRouter with explicit type aliases for ambiguous handler names
- Add contract and source-inspection tests for extracted handlers
- Bump version 3.1.1 → 3.2.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:52:09 +03:00
Toutsu 64216f5a26 Merge pull request #105: fix template batch topics
Deploy Telegram Bot / build-and-push (push) Successful in 6m3s
Deploy Telegram Bot / scan-images (push) Successful in 3m25s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-27 14:05:38 +03:00
Toutsu 383e2c1d8d fix: create Telegram topics for template batches
PR Checks / test-and-build (pull_request) Successful in 12m56s
Create a Telegram forum topic when Web creates a batch from a campaign template, persist thread ownership on the generated sessions, and send the batch schedule into that topic.

Bump version -> 3.1.1
2026-05-27 13:50:18 +03:00
Toutsu bfa979a224 Merge pull request #104: refactor: завершить platform migration и удалить deprecated telegram_* scaffolding
Deploy Telegram Bot / build-and-push (push) Successful in 6m43s
Deploy Telegram Bot / scan-images (push) Successful in 3m25s
Deploy Telegram Bot / deploy (push) Successful in 30s
- Migrated all core domain SQL from telegram_* columns to platform + external_*
- Added V024/V025 migrations with backfill and deprecation comments
- Removed all COALESCE(external_*, telegram_*) fallbacks
- Replaced gm_telegram_id join with group_managers query in HandleRsvpHandler
- Updated Bot, Web, DiscordBot, and Shared handlers
- Bumped version to 3.1.0

CI run #265 passed (289 tests, 0 warnings, 0 vulnerabilities)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 17:19:47 +03:00
Toutsu c69ebf6c03 test: update DiscordProjectStructureTests version asserts to 3.1.0
PR Checks / test-and-build (pull_request) Successful in 13m24s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:58:18 +03:00
Toutsu 040b0a3cdb refactor: завершить platform migration и удалить deprecated telegram_* scaffolding
PR Checks / test-and-build (pull_request) Failing after 13m15s
- Добавлены миграции V024 (backfill + deprecation comments + calendar_subscriptions platform identity) и V025 (backfill proposed_by_external_user_id)
- Все Bot handlers переведены с telegram_id/chat_id на platform + external_*
- Shared handlers очищены от COALESCE fallback с telegram_* колонками
- DiscordBot очищен от COALESCE fallback
- Web SessionService и CalendarSubscriptionService переведены на external_*
- HandleRsvpHandler: убран legacy UNION с gm_telegram_id, теперь только group_managers
- RescheduleVotingFinalizer: переведен на external_username/external_user_id
- Tests: добавлены asserts для V024/V025
- Версия обновлена до 3.1.0

Bump version → 3.1.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:41:15 +03:00
Toutsu a5aed14dd2 fix(discord): add backoff to scheduler to prevent 403 spam
Deploy Telegram Bot / build-and-push (push) Successful in 6m37s
Deploy Telegram Bot / scan-images (push) Successful in 3m45s
Deploy Telegram Bot / deploy (push) Successful in 33s
- SessionSchedulerService now backs off for 15 minutes after any
  handler failure (confirmation, one-hour reminder, join link),
  preventing infinite retry loops on Discord 403 Missing Access.
- Added per-session ConcurrentDictionary backoff tracking with
  automatic cleanup on success.
- Enhanced DiscordPlatformMessenger logging for SendConfirmation
  and SendJoinLink to aid permission diagnostics.
- Added 3 regression tests for backoff behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 15:51:25 +03:00
Toutsu 9fc434b42b fix(discord): treat /newsession and /reschedule input as Moscow time (UTC+3)
Deploy Telegram Bot / build-and-push (push) Successful in 6m15s
Deploy Telegram Bot / scan-images (push) Successful in 3m20s
Deploy Telegram Bot / deploy (push) Successful in 34s
DiscordNewSessionHandler.ParseTimeInput used DateTimeStyles.AssumeUniversal,
which interpreted user input as UTC. A user entering 15:00 got a session
scheduled at 18:00 MSK after rendering. Align with Telegram behavior by
treating input as Moscow time and converting to UTC before storage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 15:12:00 +03:00
Toutsu c2cc7fd9a8 fix(web): show discord sessions and integration labels
Deploy Telegram Bot / build-and-push (push) Successful in 5m46s
Deploy Telegram Bot / scan-images (push) Successful in 3m29s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-26 14:43:33 +03:00
Toutsu 3447acd8c4 fix(discord): update sessions via interactions
Deploy Telegram Bot / build-and-push (push) Successful in 6m14s
Deploy Telegram Bot / scan-images (push) Successful in 3m12s
Deploy Telegram Bot / deploy (push) Successful in 31s
2026-05-26 14:24:06 +03:00
Toutsu 56aeca5288 fix(discord): sanitize embed join links
Deploy Telegram Bot / build-and-push (push) Successful in 5m53s
Deploy Telegram Bot / scan-images (push) Successful in 3m6s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-26 13:57:11 +03:00
Toutsu 6ed0a120a0 fix(discord): avoid duplicate schedule send after new session
Deploy Telegram Bot / build-and-push (push) Successful in 6m0s
Deploy Telegram Bot / scan-images (push) Successful in 3m22s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-26 13:40:59 +03:00
Toutsu 682dd3fdec Merge pull request #103: fix(db): make legacy telegram_* columns nullable for Discord multi-platform
Deploy Telegram Bot / build-and-push (push) Successful in 13m30s
Deploy Telegram Bot / scan-images (push) Successful in 3m35s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-26 13:10:58 +03:00
Toutsu c955e1572f fix(db): make legacy telegram_* columns nullable for Discord multi-platform
PR Checks / test-and-build (pull_request) Successful in 18m38s
V023 migration drops NOT NULL constraints on:
- game_groups.telegram_chat_id
- game_groups.gm_telegram_id
- players.telegram_id

This allows Discord (and future platforms) to create players and
game_groups without legacy Telegram identifiers.

Bump version → 3.0.10

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:51:45 +03:00
Toutsu a9aa84af0f Merge pull request #102: fix(discord): add missing Dapper.AOT reference to DiscordBot project
Deploy Telegram Bot / build-and-push (push) Successful in 5m58s
Deploy Telegram Bot / scan-images (push) Successful in 3m19s
Deploy Telegram Bot / deploy (push) Successful in 35s
fix(discord): add missing Dapper.AOT reference to DiscordBot project

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:32:26 +03:00
Toutsu dcbd9bab41 fix(discord): add missing Dapper.AOT reference to DiscordBot project
PR Checks / test-and-build (pull_request) Successful in 11m4s
GmRelay.Shared references Dapper.AOT with PrivateAssets=all, which
prevents the runtime DLL from flowing to downstream projects. Telegram
bot works because it explicitly references Dapper.AOT directly, but
Discord bot did not — causing FileNotFoundException for Dapper.AOT
at runtime, breaking the scheduler and slash commands.

- Add Dapper.AOT 1.0.48 to GmRelay.DiscordBot.csproj
- Add regression test: DiscordWorkerProject_ShouldExist asserts
  Dapper.AOT is present in the DiscordBot csproj
- Bump version → 3.0.9

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:20:56 +03:00
Toutsu 92d5d9c2d3 Merge pull request #101: fix(discord): add console logging and deferred responses
Deploy Telegram Bot / build-and-push (push) Successful in 5m56s
Deploy Telegram Bot / scan-images (push) Successful in 3m3s
Deploy Telegram Bot / deploy (push) Successful in 31s
fix(discord): add console logging and deferred responses

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:46:55 +03:00
Toutsu 47d106e288 fix(tests): update DiscordNewSessionHandlerTests for deferred response pattern
PR Checks / test-and-build (pull_request) Successful in 11m55s
The Command_ShouldRenderEmbedOnSuccess test asserted the presence of
WithEmbeds in DiscordNewSessionCommand.cs. After switching to deferred
responses (InteractionCallback.DeferredMessage + ModifyResponseAsync),
embeds are now set via message.Embeds = embeds instead.

Bump version → 3.0.8

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:33:03 +03:00
Toutsu a5624897e9 fix(discord): add console logging and deferred responses
PR Checks / test-and-build (pull_request) Failing after 12m3s
- Add builder.Logging.AddConsole() to DiscordBot Program.cs so logs
  are visible in docker logs.
- Add granular LogInformation/LogError calls to DiscordNewSessionCommand
  and DiscordRescheduleCommand to diagnose failures.
- Use InteractionCallback.DeferredMessage() + ModifyResponseAsync pattern
  for /newsession and /reschedule to avoid Discord 3-second interaction
  timeout.
- Bump version → 3.0.8

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:18:09 +03:00
Toutsu 11e75d036a Merge pull request #100: fix(discord): use GuildInteractionUser.Permissions instead of REST guild lookup
Deploy Telegram Bot / build-and-push (push) Successful in 5m52s
Deploy Telegram Bot / scan-images (push) Successful in 3m11s
Deploy Telegram Bot / deploy (push) Successful in 30s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 10:57:02 +03:00
Toutsu 2942da0c35 fix(discord): use GuildInteractionUser.Permissions instead of REST guild lookup
PR Checks / test-and-build (pull_request) Successful in 11m25s
Replace REST GetGuildAsync/GetGuildUserAsync calls with authoritative
member.Permissions from the slash-command interaction payload. Discord
already resolves channel/guild permissions in the interaction JSON, so
we no longer need to fetch the guild via REST (which returns 404 when
the bot is not a REST member of the guild, e.g. user-installed apps).

Keep a best-effort GetGuildAsync call only to obtain OwnerId for the
permission checker fallback, swallowing 404 silently.

Bump version → 3.0.7

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 10:44:59 +03:00
Toutsu 549c0c96ae Merge pull request #99: fix(discord): cast COUNT to int for slash command list query
Deploy Telegram Bot / build-and-push (push) Successful in 5m27s
Deploy Telegram Bot / scan-images (push) Successful in 2m49s
Deploy Telegram Bot / deploy (push) Successful in 31s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 10:23:05 +03:00
Toutsu dd9337dd20 fix(discord): cast COUNT to int for slash command list query
PR Checks / test-and-build (pull_request) Successful in 9m34s
PostgreSQL COUNT() returns bigint, but DiscordSessionListItemDto expects
int for PlayerCount and WaitlistCount. Dapper 2.1.72 in GmRelay.DiscordBot
(without Dapper.AOT) fails to materialize the record with bigint→int mismatch.
Added ::int casts to both COUNT expressions.

Bump version to 3.0.6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 10:10:13 +03:00
83 changed files with 2896 additions and 1320 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 3.0.5 VERSION: 3.2.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>3.0.5</Version> <Version>3.2.0</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f crond -f
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.0.5 image: git.codeanddice.ru/toutsu/gmrelay-bot:3.2.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:3.0.5 image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.2.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -84,7 +84,7 @@ services:
retries: 3 retries: 3
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.0.5 image: git.codeanddice.ru/toutsu/gmrelay-web:3.2.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -42,12 +42,13 @@ public sealed class CancelSessionHandler(
FROM group_managers gm FROM group_managers gm
JOIN players p ON p.id = gm.player_id JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
) AS CanManage ) AS CanManage
FROM sessions s FROM sessions s
WHERE s.id = @SessionId WHERE s.id = @SessionId
""", """,
new { command.SessionId, command.TelegramUserId }, transaction); new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() }, transaction);
if (session == null) if (session == null)
{ {
@@ -89,7 +90,7 @@ public sealed class CancelSessionHandler(
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>( var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
""" """
SELECT p.telegram_id AS TelegramId, SELECT p.external_user_id::BIGINT AS TelegramId,
p.display_name AS DisplayName p.display_name AS DisplayName
FROM session_participants sp FROM session_participants sp
JOIN players p ON sp.player_id = p.id JOIN players p ON sp.player_id = p.id
@@ -1,292 +1,178 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Platform;
using GmRelay.Bot.Infrastructure.Telegram;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
public sealed class CreateSessionHandler( public sealed class CreateSessionHandler(
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler sharedHandler,
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient botClient, IPlatformMessenger messenger,
ILogger<CreateSessionHandler> logger) ILogger<CreateSessionHandler> logger)
{ {
public async Task HandleAsync(Message message, CancellationToken cancellationToken) public async Task HandleAsync(Message message, CancellationToken ct)
{ {
var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow); var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow);
foreach (var timeInput in parseResult.PastTimeInputs) foreach (var timeInput in parseResult.PastTimeInputs)
{ {
await botClient.SendMessage( await messenger.SendGroupMessageAsync(
message.Chat.Id, TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.", $"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
cancellationToken: cancellationToken); ct);
} }
foreach (var timeInput in parseResult.InvalidTimeInputs) foreach (var timeInput in parseResult.InvalidTimeInputs)
{ {
await botClient.SendMessage( await messenger.SendGroupMessageAsync(
message.Chat.Id, TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.", $"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
cancellationToken: cancellationToken); ct);
} }
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs) foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
{ {
await botClient.SendMessage( await messenger.SendGroupMessageAsync(
message.Chat.Id, TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.", $"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
cancellationToken: cancellationToken); ct);
} }
foreach (var recurringInput in parseResult.InvalidRecurringInputs) foreach (var recurringInput in parseResult.InvalidRecurringInputs)
{ {
await botClient.SendMessage( await messenger.SendGroupMessageAsync(
message.Chat.Id, TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.", $"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
cancellationToken: cancellationToken); ct);
} }
if (!parseResult.IsValid) if (!parseResult.IsValid)
{ {
await botClient.SendMessage( await messenger.SendGroupMessageAsync(
chatId: message.Chat.Id, TelegramPlatformIds.Group(message.Chat.Id, null),
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link\nКартинка: https://cover\n\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7", """
cancellationToken: cancellationToken); Не удалось распознать формат. Пожалуйста, используйте шаблон:
/newsession
Название: My Game
Время: 15.05.2026 19:30
Время: 22.05.2026 19:30
Мест: 4
Ссылка: https://link
Картинка: https://cover
Для повтора можно указать одну дату и строки:
Игр: 4
Интервал: 7
""",
ct);
return; return;
} }
var title = parseResult.Title!;
var link = parseResult.Link!;
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl); var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
var gmId = message.From!.Id; var gmId = message.From!.Id;
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}"); var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
var gmUsername = message.From.Username; var gmUsername = message.From.Username;
var chatId = message.Chat.Id; var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
var chatTitle = message.Chat.Title ?? "Private Chat"; message.Chat.IsForum,
message.MessageThreadId);
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
var messageThreadId = topicDestination.MessageThreadId;
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); if (topicDestination.ShouldCreateForumTopic)
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{ {
await connection.ExecuteAsync(
"""
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
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);
""",
new { TgId = gmId, Name = gmName, Username = gmUsername },
transaction);
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
"""
SELECT g.id AS GroupId,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = g.id
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT
) AS CanManage
FROM game_groups g
WHERE COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) = @ChatId::TEXT
""",
new { ChatId = chatId, GmId = gmId },
transaction);
Guid groupId;
if (existingGroup is null)
{
groupId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
VALUES (@ChatId, @ChatName, @GmId, 'Telegram', @ChatId::TEXT)
RETURNING id;
""",
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
transaction);
await connection.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT
ON CONFLICT (group_id, player_id) DO NOTHING
""",
new { GroupId = groupId, GmId = gmId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
}
else
{
if (!existingGroup.CanManage)
{
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(
chatId,
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
cancellationToken: cancellationToken);
return;
}
groupId = existingGroup.GroupId;
await connection.ExecuteAsync(
"UPDATE game_groups SET name = @ChatName WHERE id = @GroupId",
new { ChatName = chatTitle, GroupId = groupId },
transaction);
}
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
message.Chat.IsForum,
message.MessageThreadId);
var messageThreadId = topicDestination.MessageThreadId;
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
if (topicDestination.ShouldCreateForumTopic)
{
try
{
var topic = await botClient.CreateForumTopic(
chatId: chatId,
name: $"🎲 Игры: {title}",
cancellationToken: cancellationToken);
messageThreadId = topic.MessageThreadId;
}
catch (Telegram.Bot.Exceptions.ApiRequestException ex)
when (TelegramTopicRouting.IsMissingForumTopicRightsError(ex.Message))
{
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(
chatId,
TelegramTopicRouting.MissingForumTopicRightsMessage,
cancellationToken: cancellationToken);
return;
}
}
var batchId = Guid.NewGuid();
var sessions = new List<SessionBatchDto>();
foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value))
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers)
RETURNING id;
""",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = link,
ScheduledAt = scheduledAt,
ThreadId = messageThreadId,
TopicCreatedByBot = topicCreatedByBot,
MaxPlayers = parseResult.MaxPlayers,
Status = SessionStatus.Planned
},
transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers, link));
}
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
var renderResult = TelegramSessionBatchRenderer.Render(view);
Message batchMessage;
if (imageReference is not null && renderResult.Text.Length <= 1024)
{
// Картинка + расписание умещаются в одном Telegram-фото с подписью
try
{
batchMessage = await botClient.SendPhoto(
chatId: chatId,
messageThreadId: messageThreadId,
photo: InputFile.FromString(imageReference),
caption: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}, отправляем текстом", batchId);
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
}
else
{
// Текст слишком длинный для caption — fallback на два сообщения
if (imageReference is not null)
{
try
{
await botClient.SendPhoto(
chatId: chatId,
messageThreadId: messageThreadId,
photo: InputFile.FromString(imageReference),
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}", batchId);
}
}
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessage.MessageId, BatchId = batchId });
try try
{ {
await botClient.DeleteMessage( var topicRef = await messenger.CreateThreadAsync(
chatId: chatId, TelegramPlatformIds.Group(message.Chat.Id, null),
messageId: message.MessageId, $"🎲 Игры: {parseResult.Title}",
cancellationToken: cancellationToken); ct);
messageThreadId = int.Parse(topicRef.ExternalThreadId!, System.Globalization.CultureInfo.InvariantCulture);
} }
catch (Exception ex) catch (Exception ex)
when (ex.Message.Contains("not enough rights") ||
ex.Message.Contains("CHAT_ADMIN_REQUIRED") ||
ex.Message.Contains("not an administrator"))
{ {
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, chatId); await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
TelegramTopicRouting.MissingForumTopicRightsMessage,
ct);
return;
} }
} }
var platformGroup = TelegramPlatformIds.Group(message.Chat.Id, messageThreadId, message.Chat.Title ?? "Private Chat");
var platformUser = new PlatformUser(
PlatformKind.Telegram,
gmId.ToString(System.Globalization.CultureInfo.InvariantCulture),
gmName,
gmUsername);
var command = new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionCommand(
platformUser,
platformGroup,
parseResult.Title!,
parseResult.Link!,
parseResult.ScheduledTimes,
parseResult.MaxPlayers,
imageReference);
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionResult result;
try
{
result = await sharedHandler.HandleAsync(command, ct);
}
catch
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
"💥 Произошла ошибка базы данных при создании сессии.",
ct);
throw;
}
if (!result.Success)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
result.ErrorMessage!,
ct);
return;
}
var scheduleMessage = new PlatformScheduleMessage(
platformGroup,
result.View!,
null,
imageReference);
var sentMessageRef = await messenger.SendScheduleAsync(scheduleMessage, ct);
// Store batch_message_id
if (int.TryParse(sentMessageRef.ExternalMessageId, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var batchMessageId))
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessageId, BatchId = result.BatchId });
}
// Delete original message
try
{
await messenger.DeleteMessageAsync(
TelegramPlatformIds.Message(message.Chat.Id, null, message.MessageId),
ct);
}
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Ошибка при создании сессии"); logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, message.Chat.Id);
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken);
} }
} }
@@ -41,13 +41,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
FROM group_managers gm FROM group_managers gm
JOIN players p ON p.id = gm.player_id JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
) AS CanManage ) AS CanManage
FROM sessions s FROM sessions s
WHERE s.id = @SessionId WHERE s.id = @SessionId
FOR UPDATE FOR UPDATE
""", """,
new { command.SessionId, command.TelegramUserId }, new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() },
transaction); transaction);
if (session is null) if (session is null)
@@ -150,7 +151,7 @@ public sealed class PromoteWaitlistedPlayerHandler(
""" """
SELECT sp.session_id AS SessionId, SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername, p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus sp.registration_status AS RegistrationStatus
FROM session_participants sp FROM session_participants sp
JOIN players p ON sp.player_id = p.id JOIN players p ON sp.player_id = p.id
@@ -1,113 +1,25 @@
using System.Text;
using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration;
using Npgsql;
using Telegram.Bot.Types; using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.ExportCalendar; namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
public sealed class ExportCalendarHandler( public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource, GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler sharedHandler)
IPlatformMessenger messenger,
IConfiguration configuration)
{ {
public async Task HandleAsync(Message message, CancellationToken cancellationToken) public Task HandleAsync(Message message, CancellationToken cancellationToken)
{ {
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); var command = new GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarCommand(
new PlatformGroup(
PlatformKind.Telegram,
message.Chat.Id.ToString(),
message.Chat.Title ?? "Private Chat",
message.MessageThreadId?.ToString()),
new PlatformUser(
PlatformKind.Telegram,
message.From?.Id.ToString() ?? string.Empty,
message.From?.FirstName ?? string.Empty,
message.From?.Username));
var sessions = await connection.QueryAsync<CalendarSessionDto>( return sharedHandler.HandleAsync(command, cancellationToken);
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
+ " FROM sessions s"
+ " JOIN game_groups g ON s.group_id = g.id"
+ " WHERE g.telegram_chat_id = @ChatId"
+ " AND s.status = @Planned"
+ " AND s.scheduled_at > NOW()"
+ " ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
"📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken);
return;
}
var sb = new StringBuilder();
sb.AppendLine("BEGIN:VCALENDAR");
sb.AppendLine("VERSION:2.0");
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
foreach (var s in sessionsList)
{
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
sb.AppendLine("BEGIN:VEVENT");
sb.AppendLine($"UID:{s.Id}@gmrelay");
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{s.Title}");
sb.AppendLine("END:VEVENT");
}
sb.AppendLine("END:VCALENDAR");
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
// Create calendar subscription
string? subscriptionUrl = null;
var baseUrl = configuration["Web:BaseUrl"];
var senderId = message.From?.Id;
if (!string.IsNullOrWhiteSpace(baseUrl) && senderId.HasValue)
{
try
{
var token = Guid.NewGuid().ToString("N");
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
@"SELECT id FROM game_groups WHERE telegram_chat_id = @ChatId",
new { ChatId = message.Chat.Id });
await connection.ExecuteAsync(
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
new { token, userTelegramId = senderId.Value, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
}
catch
{
// Non-critical: if subscription creation fails, still send the file
}
}
var actions = subscriptionUrl is not null
? new[]
{
new PlatformMessageAction(
"calendar-subscription",
"🔗 Подписаться на календарь",
subscriptionUrl)
}
: Array.Empty<PlatformMessageAction>();
await messenger.SendCalendarFileAsync(
new PlatformCalendarFile(
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
"schedule.ics",
bytes,
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
actions),
cancellationToken);
} }
} }
@@ -1,8 +1,5 @@
using Dapper;
using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain; using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Features.Sessions.ListSessions; namespace GmRelay.Bot.Features.Sessions.ListSessions;
@@ -13,138 +10,88 @@ public sealed record DeleteSessionCommand(
long ChatId, long ChatId,
int MessageId); int MessageId);
internal sealed record DeleteSessionInfoDto(
string Title,
Guid BatchId,
Guid GroupId,
bool CanManage,
int? ThreadId,
bool TopicCreatedByBot);
public sealed class DeleteSessionHandler( public sealed class DeleteSessionHandler(
NpgsqlDataSource dataSource, GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler sharedHandler,
ITelegramBotClient bot, GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler listSessionsHandler,
IPlatformMessenger messenger,
ILogger<DeleteSessionHandler> logger) ILogger<DeleteSessionHandler> logger)
{ {
public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct) public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct)
{ {
await using var connection = await dataSource.OpenConnectionAsync(ct); var platformUser = new PlatformUser(
await using var transaction = await connection.BeginTransactionAsync(ct); PlatformKind.Telegram,
command.TelegramUserId.ToString(),
string.Empty,
null);
// 1. Fetch session and verify group manager. var platformGroup = new PlatformGroup(
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>( PlatformKind.Telegram,
""" command.ChatId.ToString(),
SELECT s.title AS Title, string.Empty);
s.batch_id AS BatchId,
s.group_id AS GroupId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
""",
new { command.SessionId, command.TelegramUserId }, transaction);
if (session == null) var scheduleMessage = TelegramPlatformIds.Message(command.ChatId, null, command.MessageId);
var sharedCommand = new GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionCommand(
command.SessionId,
platformUser,
platformGroup,
scheduleMessage);
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
if (!result.Success)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct); await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("owner")),
ct);
return; return;
} }
if (!session.CanManage)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может удалять сессию.", showAlert: true, cancellationToken: ct);
return;
}
// 2. Delete session
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
var remainingInTopic = session.ThreadId.HasValue
? await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM sessions
WHERE group_id = @GroupId
AND thread_id = @ThreadId
""",
new { session.GroupId, ThreadId = session.ThreadId.Value },
transaction)
: 0;
await transaction.CommitAsync(ct);
// 4. If no sessions are left in a bot-owned forum topic, delete the topic. // 4. If no sessions are left in a bot-owned forum topic, delete the topic.
if (session.ThreadId.HasValue && if (result.ThreadId.HasValue &&
TelegramTopicRouting.ShouldDeleteForumTopic(session.TopicCreatedByBot, remainingInTopic)) TelegramTopicRouting.ShouldDeleteForumTopic(result.TopicCreatedByBot, result.RemainingInTopic))
{ {
try try
{ {
await bot.DeleteForumTopic(command.ChatId, session.ThreadId.Value, cancellationToken: ct); await messenger.DeleteThreadAsync(
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", session.ThreadId.Value, session.BatchId); new PlatformGroup(PlatformKind.Telegram, command.ChatId.ToString(), string.Empty, null, result.ThreadId.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)),
ct);
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", result.ThreadId.Value, result.GroupId);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", session.ThreadId.Value); logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", result.ThreadId.Value);
} }
} }
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия удалена!", cancellationToken: ct); await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
ct);
// 5. Update the /listsessions message (we delete the message or edit it to remove the button) // 5. Update the /listsessions message
// A simple way is to re-render the list: var listCommand = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(platformGroup, platformUser);
await using var readConnection = await dataSource.OpenConnectionAsync(ct); var listResult = await listSessionsHandler.HandleAsync(listCommand, ct);
var sessions = await readConnection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC",
new
{
ChatId = command.ChatId,
command.TelegramUserId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionsList = sessions.ToList(); if (listResult.Sessions.Count == 0)
if (sessionsList.Count == 0)
{ {
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch { } try
{
await messenger.UpdateGroupMessageAsync(
scheduleMessage,
"📭 В этой группе нет предстоящих игр.",
[],
ct);
}
catch { }
return; return;
} }
var renderResult = SessionListMessageRenderer.Render(sessionsList); var text = SessionListMessageRenderer.RenderText(listResult.Sessions);
var actions = listResult.CanManage ? SessionListMessageRenderer.RenderActions(listResult.Sessions) : [];
try try
{ {
await bot.EditMessageText( await messenger.UpdateGroupMessageAsync(scheduleMessage, text, actions, ct);
command.ChatId,
command.MessageId,
renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1,114 +1,37 @@
using Dapper; using GmRelay.Shared.Platform;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.ListSessions; namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
internal static class SessionListMessageRenderer
{
public static (string Text, InlineKeyboardMarkup? Markup) Render(IReadOnlyList<SessionListItemDto> sessions)
{
var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var session in sessions)
{
var seats = session.MaxPlayers.HasValue
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
}
var canManage = sessions.Count > 0 && sessions.First().CanManage;
if (!canManage)
{
return (text, null);
}
var buttons = new List<InlineKeyboardButton[]>();
foreach (var session in sessions)
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"❌ {dateTitle}", $"cancel_session:{session.Id}"),
InlineKeyboardButton.WithCallbackData($"⏰ {dateTitle}", $"reschedule_session:{session.Id}")
]);
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
{
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"⬆️ Из ожидания {dateTitle}", $"promote_waitlist:{session.Id}")
]);
}
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"🗑 Удалить {dateTitle}", $"delete_session:{session.Id}")
]);
}
return (text, new InlineKeyboardMarkup(buttons));
}
}
public sealed class ListSessionsHandler( public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource, GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler sharedHandler,
ITelegramBotClient botClient) IPlatformMessenger messenger)
{ {
public async Task HandleAsync(Message message, CancellationToken cancellationToken) public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{ {
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); var command = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(
new PlatformGroup(
PlatformKind.Telegram,
message.Chat.Id.ToString(),
message.Chat.Title ?? "Private Chat",
message.MessageThreadId?.ToString()),
new PlatformUser(
PlatformKind.Telegram,
message.From?.Id.ToString() ?? string.Empty,
message.From?.FirstName ?? string.Empty,
message.From?.Username));
var sessions = await connection.QueryAsync<SessionListItemDto>( var result = await sharedHandler.HandleAsync(command, cancellationToken);
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC",
new
{
ChatId = message.Chat.Id,
TelegramUserId = message.From?.Id,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionsList = sessions.ToList(); if (result.Sessions.Count == 0)
if (sessionsList.Count == 0)
{ {
await botClient.SendMessage( await messenger.SendGroupMessageAsync(command.Group, "📭 В этой группе нет предстоящих игр.", cancellationToken);
chatId: message.Chat.Id,
text: "📭 В этой группе нет предстоящих игр.",
cancellationToken: cancellationToken);
return; return;
} }
var renderResult = SessionListMessageRenderer.Render(sessionsList); var text = SessionListMessageRenderer.RenderText(result.Sessions);
var actions = result.CanManage ? SessionListMessageRenderer.RenderActions(result.Sessions) : [];
await botClient.SendMessage( await messenger.SendGroupMessageAsync(command.Group, text, actions, cancellationToken);
chatId: message.Chat.Id,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
} }
} }
@@ -0,0 +1,63 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Features.Sessions.ListSessions;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal static class SessionListMessageRenderer
{
public static string RenderText(IReadOnlyList<SessionListItemDto> sessions)
{
var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var session in sessions)
{
var seats = session.MaxPlayers.HasValue
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
}
return text;
}
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
{
if (sessions.Count == 0 || !sessions.First().CanManage)
{
return [];
}
var actions = new List<PlatformMessageAction>();
foreach (var session in sessions)
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
actions.Add(new PlatformMessageAction(
$"cancel_session:{session.Id}",
$"❌ {dateTitle}",
$"cancel_session:{session.Id}"));
actions.Add(new PlatformMessageAction(
$"reschedule_session:{session.Id}",
$"⏰ {dateTitle}",
$"reschedule_session:{session.Id}"));
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
{
actions.Add(new PlatformMessageAction(
$"promote_waitlist:{session.Id}",
$"⬆️ Из ожидания {dateTitle}",
$"promote_waitlist:{session.Id}"));
}
actions.Add(new PlatformMessageAction(
$"delete_session:{session.Id}",
$"🗑 Удалить {dateTitle}",
$"delete_session:{session.Id}"));
}
return actions;
}
}
@@ -12,241 +12,156 @@ using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record AwaitingProposalDto(
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// Handles text input from the GM who has an AwaitingTime proposal. /// Telegram adapter for reschedule time input.
/// Parses reschedule options with a voting deadline, creates a voting message, /// Delegates core logic to the shared handler, then performs Telegram-specific
/// and tags all participants. /// message sending, DM notifications, vote_message_id storage, and cleanup.
/// If no participants are registered, reschedules immediately.
/// </summary> /// </summary>
public sealed class HandleRescheduleTimeInputHandler( public sealed class HandleRescheduleTimeInputHandler(
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler sharedHandler,
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
IPlatformMessenger messenger, IPlatformMessenger messenger,
DirectSessionNotificationSender directSender, DirectSessionNotificationSender directSender,
ILogger<HandleRescheduleTimeInputHandler> logger) ILogger<HandleRescheduleTimeInputHandler> logger)
{ {
/// <summary>
/// Attempts to handle a text message as reschedule time input.
/// Returns true if it was handled (i.e. user had an AwaitingTime proposal).
/// </summary>
public async Task<bool> TryHandleAsync(Message message, CancellationToken ct) public async Task<bool> TryHandleAsync(Message message, CancellationToken ct)
{ {
if (message.From is null || string.IsNullOrWhiteSpace(message.Text)) if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
return false; return false;
var gmTelegramId = message.From.Id; var command = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputCommand(
var chatId = message.Chat.Id; new PlatformUser(
var text = message.Text.Trim(); PlatformKind.Telegram,
message.From.Id.ToString(),
message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"),
message.From.Username),
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title),
message.Text.Trim());
await using var connection = await dataSource.OpenConnectionAsync(ct); var result = await sharedHandler.HandleAsync(command, ct);
if (!result.Handled)
// 1. Check if this GM has an AwaitingTime proposal in this chat
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.proposed_by = @GmId
AND rp.status = 'AwaitingTime'
AND g.telegram_chat_id = @ChatId
AND EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @GmId
)
ORDER BY rp.created_at DESC
LIMIT 1
""",
new { GmId = gmTelegramId, ChatId = chatId });
if (proposal is null)
return false; return false;
// 2. Parse voting input if (!string.IsNullOrEmpty(result.ReplyText) && !result.IsRescheduledImmediately)
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
{ {
await messenger.SendGroupMessageAsync( await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(chatId, proposal.ThreadId), command.Group,
$"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>", $"""⚠️ {result.ReplyText}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>""",
ct); ct);
return true; return true;
} }
// 3. Load participants (non-GM) signed up for this session if (result.IsRescheduledImmediately)
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
// 4. If no participants — reschedule immediately
if (participants.Count == 0)
{ {
await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct); if (result.UpdatedView is not null && result.BatchMessageId.HasValue)
await TryDeleteMessage(chatId, message.MessageId, ct); {
await TryUpdateBatchMessage(
command.Group,
result.UpdatedView,
TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, result.BatchMessageId.Value),
ct);
}
await messenger.SendGroupMessageAsync(command.Group, result.ReplyText!, ct);
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
return true; return true;
} }
// 5. Create voting message // Voting mode
await using var transaction = await connection.BeginTransactionAsync(ct);
var options = votingInput.Options
.Select((proposedAt, index) => new RescheduleOptionDto(
Guid.NewGuid(),
index + 1,
proposedAt))
.ToList();
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @ChatId
WHERE id = @Id
""",
new { votingInput.Deadline, ChatId = chatId, Id = proposal.Id },
transaction);
foreach (var option in options)
{
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
""",
new
{
option.OptionId,
ProposalId = proposal.Id,
option.ProposedAt,
option.DisplayOrder
},
transaction);
}
await transaction.CommitAsync(ct);
var voteText = BuildVotingMessage( var voteText = BuildVotingMessage(
proposal.Title, result.Title!,
proposal.CurrentScheduledAt, result.CurrentScheduledAt,
votingInput.Deadline, result.VotingDeadlineAt!.Value,
options, result.Options,
participants, result.Participants,
[]); []);
var keyboard = BuildVotingKeyboard(options);
var keyboard = BuildVotingKeyboard(result.Options);
var voteMsg = await bot.SendMessage( var voteMsg = await bot.SendMessage(
chatId: chatId, chatId: message.Chat.Id,
messageThreadId: proposal.ThreadId, messageThreadId: message.MessageThreadId,
text: voteText, text: voteText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard, replyMarkup: keyboard,
cancellationToken: ct); cancellationToken: ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode); var mode = await GetNotificationModeAsync(result.ProposalId!.Value, ct);
if (mode.ShouldSendDirectMessages()) if (mode.ShouldSendDirectMessages())
{ {
var optionsText = string.Join( var optionsText = string.Join(
"\n", "\n",
options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)")); result.Options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
var directText = $""" var directText = $"""
🔄 <b>Голосование за перенос сессии</b> 🔄 <b>Голосование за перенос сессии</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b> 📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК) 📅 Текущее время: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
🗳 Варианты: 🗳 Варианты:
{optionsText} {optionsText}
⏳ Дедлайн: <b>{votingInput.Deadline.FormatMoscow()}</b> (МСК) ⏳ Дедлайн: <b>{result.VotingDeadlineAt.Value.FormatMoscow()}</b> (МСК)
Проголосуйте кнопкой в групповом сообщении. Проголосуйте кнопкой в групповом сообщении.
"""; """;
await directSender.SendAsync( await directSender.SendAsync(
participants.Select(p => new DirectNotificationRecipient( result.Participants.Select(p => new DirectNotificationRecipient(
p.TelegramId, p.TelegramId,
p.DisplayName)), p.DisplayName)),
directText, directText,
"reschedule-vote", "reschedule-vote",
proposal.SessionId, result.ProposalId.Value,
ct); ct);
} }
// Store vote message ID await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync( await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id", "UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
new { MsgId = voteMsg.MessageId, Id = proposal.Id }); new { MsgId = voteMsg.MessageId, Id = result.ProposalId.Value });
logger.LogInformation( logger.LogInformation(
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}", "Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
proposal.SessionId, result.ProposalId.Value,
proposal.Id, result.ProposalId.Value,
options.Count, result.Options.Count,
votingInput.Deadline); result.VotingDeadlineAt.Value);
// Delete GM's time input message
await TryDeleteMessage(chatId, message.MessageId, ct);
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
return true; return true;
} }
private async Task RescheduleImmediately( private async Task<SessionNotificationMode> GetNotificationModeAsync(Guid proposalId, CancellationToken ct)
NpgsqlConnection connection, AwaitingProposalDto proposal,
DateTimeOffset newTime, long chatId, CancellationToken ct)
{ {
await using var transaction = await connection.BeginTransactionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
var raw = await connection.QuerySingleOrDefaultAsync<string?>(
await connection.ExecuteAsync(
""" """
UPDATE sessions SELECT s.notification_mode
SET scheduled_at = @NewTime, FROM sessions s
status = @Status, JOIN reschedule_proposals rp ON rp.session_id = s.id
confirmation_message_id = NULL, WHERE rp.id = @Id
confirmation_sent_at = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""", """,
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned }, new { Id = proposalId });
transaction); return SessionNotificationModeExtensions.FromDatabaseValue(raw ?? string.Empty);
}
await connection.ExecuteAsync( private async Task TryUpdateBatchMessage(
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id", PlatformGroup group,
new { NewTime = newTime, Id = proposal.Id }, SessionBatchViewModel view,
transaction); PlatformMessageRef scheduleMessage,
CancellationToken ct)
await transaction.CommitAsync(ct); {
try
await messenger.SendGroupMessageAsync( {
TelegramPlatformIds.Group(chatId, proposal.ThreadId), await messenger.UpdateScheduleAsync(
$"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>", new PlatformScheduleMessage(group, view, scheduleMessage),
ct); ct);
}
// Re-render batch message with updated time catch (Exception ex)
await TryUpdateBatchMessage(proposal, ct); {
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule");
logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId); }
} }
internal static string BuildVotingMessage( internal static string BuildVotingMessage(
@@ -268,7 +183,7 @@ public sealed class HandleRescheduleTimeInputHandler(
var lines = new List<string> var lines = new List<string>
{ {
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>", $"""🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>""",
"", "",
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)", $"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)", $"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
@@ -349,52 +264,6 @@ public sealed class HandleRescheduleTimeInputHandler(
"dd.MM HH:mm", "dd.MM HH:mm",
System.Globalization.CultureInfo.InvariantCulture); System.Globalization.CultureInfo.InvariantCulture);
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
{
try
{
await using var conn = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await conn.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
""",
new { proposal.BatchId })).ToList();
if (proposal.BatchMessageId.HasValue)
{
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
view,
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
ct);
}
else
{
logger.LogWarning("No batch_message_id stored for session {SessionId}, cannot edit batch message in-place", proposal.SessionId);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule for session {SessionId}", proposal.SessionId);
}
}
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct) private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
{ {
try try
@@ -1,8 +1,7 @@
using Dapper; using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot; using Telegram.Bot;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -15,130 +14,49 @@ public sealed record HandleRescheduleVoteCommand(
int MessageId); int MessageId);
public sealed class HandleRescheduleVoteHandler( public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource, GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
ITelegramBotClient bot, ITelegramBotClient bot,
IPlatformMessenger messenger, IPlatformMessenger messenger,
ILogger<HandleRescheduleVoteHandler> logger) ILogger<HandleRescheduleVoteHandler> logger)
{ {
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct) public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
{ {
await using var connection = await dataSource.OpenConnectionAsync(ct); var platformUser = new PlatformUser(
await using var transaction = await connection.BeginTransactionAsync(ct); PlatformKind.Telegram,
command.TelegramUserId.ToString(),
string.Empty,
null);
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>( var platformGroup = new PlatformGroup(
""" PlatformKind.Telegram,
SELECT rp.id AS Id, command.ChatId.ToString(),
rp.session_id AS SessionId, string.Empty);
rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { command.OptionId },
transaction);
if (proposal is null) var sharedCommand = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteCommand(
command.OptionId,
platformUser,
platformGroup,
command.CallbackQueryId,
TelegramPlatformIds.Message(command.ChatId, null, command.MessageId));
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
if (!result.Success)
{ {
await AnswerAsync(command.CallbackQueryId, "Голосование уже завершено или не найдено.", ct); await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("дедлайн")),
ct);
return; return;
} }
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
{
await AnswerAsync(command.CallbackQueryId, "Дедлайн уже прошёл. Результаты скоро будут применены.", ct, showAlert: true);
return;
}
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
transaction);
if (playerId is null)
{
await AnswerAsync(command.CallbackQueryId, "Вы не являетесь участником этой сессии.", ct);
return;
}
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id,
voted_at = now()
""",
new
{
ProposalId = proposal.Id,
PlayerId = playerId.Value,
command.OptionId
},
transaction);
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage( var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title, result.Title!,
proposal.CurrentScheduledAt, result.CurrentScheduledAt,
proposal.VotingDeadlineAt, result.VotingDeadlineAt,
options, result.Options,
participants, result.Participants,
votes); result.Votes);
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options); var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(result.Options);
try try
{ {
@@ -152,12 +70,11 @@ public sealed class HandleRescheduleVoteHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id); logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", result.ProposalId);
} }
await AnswerAsync(command.CallbackQueryId, "Ваш голос учтён. До дедлайна его можно изменить.", ct); await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
ct);
} }
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
} }
@@ -45,12 +45,13 @@ public sealed class InitiateRescheduleHandler(
FROM group_managers gm FROM group_managers gm
JOIN players p ON p.id = gm.player_id JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
) AS CanManage ) AS CanManage
FROM sessions s FROM sessions s
WHERE s.id = @SessionId AND s.status != @Cancelled WHERE s.id = @SessionId AND s.status != @Cancelled
""", """,
new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled }); new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString(), Cancelled = SessionStatus.Cancelled });
if (session is null) if (session is null)
{ {
@@ -83,10 +84,10 @@ public sealed class InitiateRescheduleHandler(
// 3. Create proposal in AwaitingTime status // 3. Create proposal in AwaitingTime status
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
INSERT INTO reschedule_proposals (session_id, proposed_by, source_platform, status) INSERT INTO reschedule_proposals (session_id, proposed_by_external_user_id, source_platform, status)
VALUES (@SessionId, @GmId, 'Telegram', 'AwaitingTime') VALUES (@SessionId, @ProposedBy, 'Telegram', 'AwaitingTime')
""", """,
new { command.SessionId, GmId = command.TelegramUserId }); new { command.SessionId, ProposedBy = command.TelegramUserId.ToString() });
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId); logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
@@ -79,7 +79,7 @@ public sealed class RescheduleVotingDeadlineService(
""" """
SELECT rp.vote_message_id AS VoteMessageId, SELECT rp.vote_message_id AS VoteMessageId,
s.batch_message_id AS BatchMessageId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId, g.external_group_id::BIGINT AS TelegramChatId,
s.thread_id AS ThreadId s.thread_id AS ThreadId
FROM reschedule_proposals rp FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id JOIN sessions s ON s.id = rp.session_id
@@ -169,7 +169,7 @@ public sealed class RescheduleVotingDeadlineService(
""" """
SELECT sp.session_id AS SessionId, SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername, p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus sp.registration_status AS RegistrationStatus
FROM session_participants sp FROM session_participants sp
JOIN players p ON sp.player_id = p.id JOIN players p ON sp.player_id = p.id
@@ -95,6 +95,68 @@ public sealed class TelegramPlatformMessenger(
cancellationToken: ct); cancellationToken: ct);
} }
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
EnsureTelegram(group.Platform);
await bot.SendMessage(
chatId: ParseLong(group.ExternalGroupId),
messageThreadId: ParseNullableInt(group.ExternalThreadId),
text: htmlText,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(actions),
cancellationToken: ct);
}
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
EnsureTelegram(messageRef.Platform);
await bot.EditMessageText(
chatId: ParseLong(messageRef.ExternalGroupId),
messageId: ParseInt(messageRef.ExternalMessageId),
text: htmlText,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(actions),
cancellationToken: ct);
}
public async Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
{
EnsureTelegram(group.Platform);
var topic = await bot.CreateForumTopic(
chatId: ParseLong(group.ExternalGroupId),
name: title,
cancellationToken: ct);
return new PlatformMessageRef(
PlatformKind.Telegram,
group.ExternalGroupId,
topic.MessageThreadId.ToString(CultureInfo.InvariantCulture),
string.Empty);
}
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct)
{
EnsureTelegram(group.Platform);
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
{
return Task.CompletedTask;
}
return bot.DeleteForumTopic(
ParseLong(group.ExternalGroupId),
ParseInt(group.ExternalThreadId),
cancellationToken: ct);
}
public Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
{
EnsureTelegram(messageRef.Platform);
return bot.DeleteMessage(
ParseLong(messageRef.ExternalGroupId),
ParseInt(messageRef.ExternalMessageId),
cancellationToken: ct);
}
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{ {
EnsureTelegram(message.Recipient.Platform); EnsureTelegram(message.Recipient.Platform);
@@ -5,6 +5,9 @@ using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.ListSessions; using GmRelay.Bot.Features.Sessions.ListSessions;
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using BotRescheduleVoteHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler;
using GmRelay.Bot.Features.Sessions.ExportCalendar; using GmRelay.Bot.Features.Sessions.ExportCalendar;
using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Bot.Features.Sessions.RescheduleSession;
using Telegram.Bot; using Telegram.Bot;
@@ -20,7 +23,7 @@ namespace GmRelay.Bot.Infrastructure.Telegram;
/// </summary> /// </summary>
public sealed class UpdateRouter( public sealed class UpdateRouter(
HandleRsvpHandler rsvpHandler, HandleRsvpHandler rsvpHandler,
CreateSessionHandler createSessionHandler, BotCreateSessionHandler createSessionHandler,
JoinSessionHandler joinSessionHandler, JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler, LeaveSessionHandler leaveSessionHandler,
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler, PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
@@ -29,8 +32,8 @@ public sealed class UpdateRouter(
ListSessionsHandler listSessionsHandler, ListSessionsHandler listSessionsHandler,
ExportCalendarHandler exportCalendarHandler, ExportCalendarHandler exportCalendarHandler,
InitiateRescheduleHandler initiateRescheduleHandler, InitiateRescheduleHandler initiateRescheduleHandler,
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler, BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
HandleRescheduleVoteHandler rescheduleVoteHandler, BotRescheduleVoteHandler rescheduleVoteHandler,
ITelegramBotClient bot, ITelegramBotClient bot,
IConfiguration configuration, IConfiguration configuration,
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
@@ -0,0 +1,14 @@
-- =============================================================
-- V023: Make legacy Telegram columns nullable for multi-platform
-- =============================================================
-- Scope: Allow Discord (and future platforms) to create players
-- and game_groups without legacy telegram_* values.
-- Existing Telegram data was backfilled in V016.
-- =============================================================
ALTER TABLE game_groups
ALTER COLUMN telegram_chat_id DROP NOT NULL,
ALTER COLUMN gm_telegram_id DROP NOT NULL;
ALTER TABLE players
ALTER COLUMN telegram_id DROP NOT NULL;
@@ -0,0 +1,41 @@
-- =============================================================
-- V024: Deprecate legacy Telegram-specific columns
-- =============================================================
-- Scope: Complete platform migration by backfilling any remaining
-- external_* gaps and officially deprecating telegram_* columns.
-- No columns are dropped — rollback-safe.
-- =============================================================
-- 1. Backfill players platform identity (safeguard for any rows missed in V016)
UPDATE players
SET platform = 'Telegram',
external_user_id = telegram_id::TEXT,
external_username = telegram_username
WHERE platform IS NULL;
-- 2. Backfill game_groups platform identity (safeguard for any rows missed in V016)
UPDATE game_groups
SET platform = 'Telegram',
external_group_id = telegram_chat_id::TEXT
WHERE platform IS NULL;
-- 3. Add platform identity to calendar_subscriptions
ALTER TABLE calendar_subscriptions
ADD COLUMN user_platform VARCHAR(50),
ADD COLUMN user_external_id VARCHAR(255);
UPDATE calendar_subscriptions
SET user_external_id = user_telegram_id::TEXT,
user_platform = 'Telegram'
WHERE user_platform IS NULL;
-- 4. Migrate calendar subscription index
DROP INDEX IF EXISTS ix_calendar_subscriptions_user_telegram_id;
CREATE INDEX ix_calendar_subscriptions_user_external_id ON calendar_subscriptions (user_external_id);
-- 5. Deprecation comments on legacy columns
COMMENT ON COLUMN players.telegram_id IS 'DEPRECATED: use platform + external_user_id';
COMMENT ON COLUMN players.telegram_username IS 'DEPRECATED: use external_username';
COMMENT ON COLUMN game_groups.telegram_chat_id IS 'DEPRECATED: use platform + external_group_id';
COMMENT ON COLUMN game_groups.gm_telegram_id IS 'DEPRECATED: group ownership is tracked in group_managers';
COMMENT ON COLUMN calendar_subscriptions.user_telegram_id IS 'DEPRECATED: use user_platform + user_external_id';
@@ -0,0 +1,11 @@
-- =============================================================
-- V025: Backfill proposed_by_external_user_id for Telegram proposals
-- =============================================================
-- Scope: Ensure all reschedule_proposals have proposed_by_external_user_id
-- populated so that InitiateRescheduleHandler can stop writing proposed_by.
-- =============================================================
UPDATE reschedule_proposals
SET proposed_by_external_user_id = proposed_by::TEXT
WHERE proposed_by_external_user_id IS NULL
AND proposed_by IS NOT NULL;
+9 -3
View File
@@ -66,18 +66,24 @@ builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>()); builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
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<GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>(); 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>();
builder.Services.AddSingleton<CancelSessionHandler>(); builder.Services.AddSingleton<CancelSessionHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>(); builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>(); builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.ExportCalendarHandler>(); builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
builder.Services.AddSingleton<InitiateRescheduleHandler>(); builder.Services.AddSingleton<InitiateRescheduleHandler>();
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>(); builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<HandleRescheduleVoteHandler>(); builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>(); builder.Services.AddSingleton<RescheduleVotingFinalizer>();
builder.Services.AddSingleton<DirectSessionNotificationSender>(); builder.Services.AddSingleton<DirectSessionNotificationSender>();
@@ -0,0 +1,84 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record DiscordDeleteSessionResult(
string ReplyText,
SessionBatchViewModel? UpdatedView,
string? EmptyMessage = null);
public sealed class DiscordDeleteSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
DiscordListSessionsHandler listSessionsHandler,
ILogger<DiscordDeleteSessionHandler> logger)
{
public async Task<DiscordDeleteSessionResult> HandleAsync(
string guildId,
string channelId,
ulong userId,
ulong resolvedPermissions,
ulong guildOwnerId,
Guid sessionId,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
{
return new DiscordDeleteSessionResult(
"Только owner, администратор или manager могут удалять сессии.",
UpdatedView: null);
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var deletedRows = await connection.ExecuteAsync(
"""
DELETE FROM sessions s
USING game_groups g
WHERE s.group_id = g.id
AND s.id = @SessionId
AND g.platform = 'Discord'
AND g.external_group_id = @GuildId
""",
new { SessionId = sessionId, GuildId = guildId },
transaction);
await transaction.CommitAsync(cancellationToken);
if (deletedRows == 0)
{
return new DiscordDeleteSessionResult(
"Сессия не найдена или уже удалена.",
UpdatedView: null);
}
logger.LogInformation("Deleted Discord session {SessionId} in guild {GuildId}", sessionId, guildId);
var updatedView = await listSessionsHandler.BuildScheduleAsync(
guildId,
channelId,
userId,
resolvedPermissions,
guildOwnerId,
cancellationToken);
return updatedView is null
? new DiscordDeleteSessionResult(
"Сессия удалена.",
UpdatedView: null,
EmptyMessage: "В этом сервере нет предстоящих игр.")
: new DiscordDeleteSessionResult("Сессия удалена.", updatedView);
}
}
@@ -1,3 +1,4 @@
using NetCord;
using NetCord.Rest; using NetCord.Rest;
using NetCord.Services.ApplicationCommands; using NetCord.Services.ApplicationCommands;
@@ -18,8 +19,17 @@ public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandC
var guildId = Context.Interaction.GuildId?.ToString() var guildId = Context.Interaction.GuildId?.ToString()
?? throw new InvalidOperationException("This command can only be used in a guild."); ?? throw new InvalidOperationException("This command can only be used in a guild.");
var channelId = Context.Channel.Id.ToString(); var channelId = Context.Channel.Id.ToString();
var member = Context.User as GuildInteractionUser;
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
var guildOwnerId = 0UL;
var view = await _handler.BuildScheduleAsync(guildId, channelId, CancellationToken.None); var view = await _handler.BuildScheduleAsync(
guildId,
channelId,
Context.User.Id,
resolvedPermissions,
guildOwnerId,
CancellationToken.None);
if (view is null) if (view is null)
{ {
@@ -1,4 +1,5 @@
using Dapper; using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
@@ -9,11 +10,22 @@ internal sealed record DiscordSessionListItemDto(
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
int PlayerCount, int WaitlistCount); int PlayerCount, int WaitlistCount);
public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource) public sealed class DiscordListSessionsHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker)
{ {
public Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
CancellationToken cancellationToken) =>
BuildScheduleAsync(guildId, channelId, 0, 0, 0, cancellationToken);
public async Task<SessionBatchViewModel?> BuildScheduleAsync( public async Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId, string guildId,
string channelId, string channelId,
ulong userId,
ulong resolvedPermissions,
ulong guildOwnerId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
@@ -21,15 +33,15 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
var sessions = await connection.QueryAsync<DiscordSessionListItemDto>( var sessions = await connection.QueryAsync<DiscordSessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
s.max_players as MaxPlayers, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount, COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active)::int as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted)::int as WaitlistCount
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.platform = 'Discord' WHERE g.platform = 'Discord'
AND g.external_group_id = @GuildId AND g.external_group_id = @GuildId
AND s.status != @Cancelled AND s.status != @Cancelled
AND s.scheduled_at > NOW() AND s.scheduled_at > now() - interval '4 hours'
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new new
@@ -44,11 +56,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
if (sessionList.Count == 0) if (sessionList.Count == 0)
return null; return null;
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
new { GuildId = guildId });
var canManage = permissionChecker.CanManageSchedule(
guildOwnerId,
userId,
dbManagerUserIds,
resolvedPermissions);
var sessionIds = sessionList.Select(s => s.Id).ToList(); var sessionIds = sessionList.Select(s => s.Id).ToList();
var participants = await connection.QueryAsync<ParticipantBatchDto>( var participants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId, @"SELECT sp.session_id as SessionId,
p.display_name as DisplayName, p.display_name as DisplayName,
COALESCE(p.external_username, p.telegram_username) as TelegramUsername, p.external_username as TelegramUsername,
sp.registration_status as RegistrationStatus sp.registration_status as RegistrationStatus
FROM session_participants sp FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
@@ -60,6 +86,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
var batchDtos = sessionList.Select(s => new SessionBatchDto( var batchDtos = sessionList.Select(s => new SessionBatchDto(
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList(); s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList()); var view = SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
return canManage ? AddManagerActions(view) : view;
} }
internal static SessionBatchViewModel AddManagerActions(SessionBatchViewModel view) =>
view with
{
Sessions = view.Sessions
.Select(session =>
{
if (SessionStatus.IsCancelled(session.Status))
return session;
var actions = session.AvailableActions
.Concat([new AvailableAction("delete_session", $"Удалить {session.ScheduledAt.FormatMoscowShort()}", session.SessionId)])
.ToList();
return session with { AvailableActions = actions };
})
.ToList()
};
} }
@@ -1,4 +1,5 @@
using GmRelay.DiscordBot.Rendering; using GmRelay.DiscordBot.Rendering;
using NetCord;
using NetCord.Rest; using NetCord.Rest;
using NetCord.Services.ApplicationCommands; using NetCord.Services.ApplicationCommands;
@@ -22,10 +23,46 @@ public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandCon
[SlashCommandParameter(Name = "seats", Description = "Maximum number of players")] long? seats = null, [SlashCommandParameter(Name = "seats", Description = "Maximum number of players")] long? seats = null,
[SlashCommandParameter(Name = "link", Description = "Join link")] string? link = null) [SlashCommandParameter(Name = "link", Description = "Join link")] string? link = null)
{ {
_logger.LogInformation(
"newsession called by user {UserId} ({UserType}) in guild {GuildId}, channel {ChannelId}",
Context.User.Id,
Context.User.GetType().Name,
Context.Interaction.GuildId,
Context.Channel?.Id);
var guildId = Context.Interaction.GuildId var guildId = Context.Interaction.GuildId
?? throw new InvalidOperationException("This command can only be used in a guild."); ?? throw new InvalidOperationException("This command can only be used in a guild.");
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
var member = await Context.Client.Rest.GetGuildUserAsync(guildId, Context.User.Id); var member = Context.User as GuildInteractionUser;
if (member is null)
{
_logger.LogError("Context.User is not GuildInteractionUser. Actual type: {ActualType}", Context.User.GetType().Name);
throw new InvalidOperationException("Guild member data not available in interaction.");
}
var resolvedPermissions = (ulong)member.Permissions;
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
ulong guildOwnerId = 0;
var guildName = guildId.ToString();
try
{
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
guildOwnerId = guild.OwnerId;
guildName = guild.Name;
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
}
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogWarning(
ex,
"Bot is not a REST member of guild {GuildId}; using resolved permissions from interaction payload",
guildId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error fetching guild {GuildId}", guildId);
}
var timeResult = DiscordNewSessionHandler.ParseTimeInput(time); var timeResult = DiscordNewSessionHandler.ParseTimeInput(time);
if (!timeResult.IsSuccess) if (!timeResult.IsSuccess)
@@ -35,54 +72,57 @@ public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandCon
return; return;
} }
var resolvedPermissions = GetResolvedPermissions(guild, member); // Defer the response to avoid Discord 3-second interaction timeout
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
try try
{ {
_logger.LogInformation("Creating session for guild {GuildId}, user {UserId}", guildId, Context.User.Id);
var view = await _handler.HandleAsync( var view = await _handler.HandleAsync(
guildId: guild.Id.ToString(), guildId: guildId.ToString(),
channelId: Context.Channel.Id.ToString(), channelId: Context.Channel!.Id.ToString(),
groupName: guildName,
userId: Context.User.Id, userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username, userDisplayName: Context.User.GlobalName ?? Context.User.Username,
resolvedPermissions: resolvedPermissions, resolvedPermissions: resolvedPermissions,
guildOwnerId: guild.OwnerId, guildOwnerId: guildOwnerId,
title: title, title: title,
scheduledAt: timeResult.Value, scheduledAt: timeResult.Value,
maxPlayers: seats is null ? null : (int)seats.Value, maxPlayers: seats is null ? null : (int)seats.Value,
joinLink: link, joinLink: link,
CancellationToken.None); CancellationToken.None);
_logger.LogInformation("Session created successfully. Building render.");
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view); var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(new InteractionMessageProperties() _logger.LogInformation("Sending success response.");
.WithContent(":white_check_mark: **Session created successfully!**")
.WithEmbeds(embeds) await Context.Interaction.ModifyResponseAsync(message =>
.WithComponents(actionRows))); {
message.Content = ":white_check_mark: **Session created successfully!**";
message.Embeds = embeds;
message.Components = actionRows;
});
_logger.LogInformation("Success response sent.");
} }
catch (UnauthorizedAccessException ex) catch (UnauthorizedAccessException ex)
{ {
await Context.Interaction.SendResponseAsync( _logger.LogWarning(ex, "Unauthorized session creation attempt by user {UserId}", Context.User.Id);
InteractionCallback.Message($":no_entry: {ex.Message}")); await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = $":no_entry: {ex.Message}";
});
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to create session for user {UserId} in guild {GuildId}", Context.User.Id, guild.Id); _logger.LogError(ex, "Failed to create session for user {UserId} in guild {GuildId}", Context.User.Id, guildId);
await Context.Interaction.SendResponseAsync( await Context.Interaction.ModifyResponseAsync(message =>
InteractionCallback.Message(":boom: An error occurred while creating the session.")); {
message.Content = ":boom: An error occurred while creating the session.";
});
} }
} }
private static ulong GetResolvedPermissions(NetCord.Rest.RestGuild guild, NetCord.GuildUser member)
{
if (member is null)
return 0;
ulong resolved = 0;
foreach (var roleId in member.RoleIds)
{
if (guild.Roles.TryGetValue(roleId, out var role))
resolved |= (ulong)role.Permissions;
}
return resolved;
}
} }
@@ -1,9 +1,9 @@
using Dapper; using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord; using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using System.Globalization;
namespace GmRelay.DiscordBot.Features.Sessions; namespace GmRelay.DiscordBot.Features.Sessions;
@@ -12,35 +12,40 @@ public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, strin
public sealed class DiscordNewSessionHandler( public sealed class DiscordNewSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker, DiscordPermissionChecker permissionChecker,
IPlatformMessenger messenger,
ILogger<DiscordNewSessionHandler> logger) ILogger<DiscordNewSessionHandler> logger)
{ {
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
public static TimeParseResult ParseTimeInput(string input) public static TimeParseResult ParseTimeInput(string input)
{ {
if (DateTimeOffset.TryParseExact( var trimmed = input.Trim();
input.Trim(),
if (DateTime.TryParseExact(
trimmed,
"yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm",
System.Globalization.CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal, DateTimeStyles.None,
out var result)) out var dt1))
{ {
if (result < DateTimeOffset.UtcNow) var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом."); return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, result.ToUniversalTime(), null); return new TimeParseResult(true, offset, null);
} }
if (DateTimeOffset.TryParseExact( if (DateTime.TryParseExact(
input.Trim(), trimmed,
"dd.MM.yyyy HH:mm", "dd.MM.yyyy HH:mm",
System.Globalization.CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal, DateTimeStyles.None,
out var altResult)) out var dt2))
{ {
if (altResult < DateTimeOffset.UtcNow) var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом."); return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, altResult.ToUniversalTime(), null); return new TimeParseResult(true, offset, null);
} }
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm"); return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
@@ -49,6 +54,7 @@ public sealed class DiscordNewSessionHandler(
public async Task<SessionBatchViewModel> HandleAsync( public async Task<SessionBatchViewModel> HandleAsync(
string guildId, string guildId,
string channelId, string channelId,
string groupName,
ulong userId, ulong userId,
string userDisplayName, string userDisplayName,
ulong resolvedPermissions, ulong resolvedPermissions,
@@ -60,6 +66,9 @@ public sealed class DiscordNewSessionHandler(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var displayGroupName = string.IsNullOrWhiteSpace(groupName) || string.Equals(groupName, guildId, StringComparison.Ordinal)
? title
: groupName.Trim();
var dbManagerUserIds = await connection.QueryAsync<ulong>( var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT) @"SELECT CAST(p.external_user_id AS BIGINT)
@@ -75,6 +84,7 @@ public sealed class DiscordNewSessionHandler(
} }
await using var transaction = await connection.BeginTransactionAsync(cancellationToken); await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var transactionCommitted = false;
try try
{ {
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -89,13 +99,13 @@ public sealed class DiscordNewSessionHandler(
var groupId = await connection.ExecuteScalarAsync<Guid>( var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id) @"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
VALUES (@GuildId, 'Discord', @GuildId, @ChannelId) VALUES (@GroupName, 'Discord', @GuildId, @ChannelId)
ON CONFLICT (platform, external_group_id) ON CONFLICT (platform, external_group_id)
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
DO UPDATE SET name = EXCLUDED.name, DO UPDATE SET name = EXCLUDED.name,
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id) external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
RETURNING id", RETURNING id",
new { GuildId = guildId, ChannelId = channelId }, new { GroupName = displayGroupName, GuildId = guildId, ChannelId = channelId },
transaction); transaction);
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -125,23 +135,19 @@ public sealed class DiscordNewSessionHandler(
transaction); transaction);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
transactionCommitted = true;
logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId); logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId);
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) }; var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>()); return SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
await messenger.SendScheduleAsync(
new PlatformScheduleMessage(
new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId),
view,
null),
cancellationToken);
return view;
} }
catch catch
{ {
await transaction.RollbackAsync(cancellationToken); if (!transactionCommitted)
{
await transaction.RollbackAsync(cancellationToken);
}
throw; throw;
} }
} }
@@ -1,5 +1,6 @@
namespace GmRelay.DiscordBot.Features.Sessions; namespace GmRelay.DiscordBot.Features.Sessions;
using NetCord;
using NetCord.Rest; using NetCord.Rest;
using NetCord.Services.ApplicationCommands; using NetCord.Services.ApplicationCommands;
@@ -22,10 +23,43 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
[SlashCommandParameter(Name = "option3", Description = "Third time option (optional)")] string? option3 = null, [SlashCommandParameter(Name = "option3", Description = "Third time option (optional)")] string? option3 = null,
[SlashCommandParameter(Name = "deadline", Description = "Voting deadline (YYYY-MM-DD HH:mm)")] string deadline = "") [SlashCommandParameter(Name = "deadline", Description = "Voting deadline (YYYY-MM-DD HH:mm)")] string deadline = "")
{ {
_logger.LogInformation(
"reschedule called by user {UserId} ({UserType}) in guild {GuildId}",
Context.User.Id,
Context.User.GetType().Name,
Context.Interaction.GuildId);
var guildId = Context.Interaction.GuildId var guildId = Context.Interaction.GuildId
?? throw new InvalidOperationException("This command can only be used in a guild."); ?? throw new InvalidOperationException("This command can only be used in a guild.");
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
var member = await Context.Client.Rest.GetGuildUserAsync(guildId, Context.User.Id); var member = Context.User as GuildInteractionUser;
if (member is null)
{
_logger.LogError("Context.User is not GuildInteractionUser. Actual type: {ActualType}", Context.User.GetType().Name);
throw new InvalidOperationException("Guild member data not available in interaction.");
}
var resolvedPermissions = (ulong)member.Permissions;
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
ulong guildOwnerId = 0;
try
{
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
guildOwnerId = guild.OwnerId;
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
}
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogWarning(
ex,
"Bot is not a REST member of guild {GuildId}; using resolved permissions from interaction payload",
guildId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error fetching guild {GuildId}", guildId);
}
if (!Guid.TryParse(sessionIdText, out var sessionId)) if (!Guid.TryParse(sessionIdText, out var sessionId))
{ {
@@ -66,55 +100,55 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
return; return;
} }
var resolvedPermissions = GetResolvedPermissions(guild, member); // Defer the response to avoid Discord 3-second interaction timeout
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
try try
{ {
_logger.LogInformation("Initiating reschedule for session {SessionId} in guild {GuildId}", sessionId, guildId);
var result = await _handler.HandleAsync( var result = await _handler.HandleAsync(
guildId: guild.Id.ToString(), guildId: guildId.ToString(),
channelId: Context.Channel.Id.ToString(), channelId: Context.Channel!.Id.ToString(),
userId: Context.User.Id, userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username, userDisplayName: Context.User.GlobalName ?? Context.User.Username,
resolvedPermissions: resolvedPermissions, resolvedPermissions: resolvedPermissions,
guildOwnerId: guild.OwnerId, guildOwnerId: guildOwnerId,
sessionId: sessionId, sessionId: sessionId,
options: parsedOptions, options: parsedOptions,
deadline: deadlineResult.Value, deadline: deadlineResult.Value,
CancellationToken.None); CancellationToken.None);
await Context.Interaction.SendResponseAsync( _logger.LogInformation("Reschedule voting started for session {SessionId}, proposal {ProposalId}", sessionId, result.ProposalId);
InteractionCallback.Message(
$"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC.")); await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = $"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC.";
});
} }
catch (UnauthorizedAccessException ex) catch (UnauthorizedAccessException ex)
{ {
await Context.Interaction.SendResponseAsync( _logger.LogWarning(ex, "Unauthorized reschedule attempt by user {UserId}", Context.User.Id);
InteractionCallback.Message($":no_entry: {ex.Message}")); await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = $":no_entry: {ex.Message}";
});
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
await Context.Interaction.SendResponseAsync( _logger.LogWarning(ex, "Invalid reschedule request by user {UserId}", Context.User.Id);
InteractionCallback.Message($":warning: {ex.Message}")); await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = $":warning: {ex.Message}";
});
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to initiate reschedule for session {SessionId}", sessionId); _logger.LogError(ex, "Failed to initiate reschedule for session {SessionId}", sessionId);
await Context.Interaction.SendResponseAsync( await Context.Interaction.ModifyResponseAsync(message =>
InteractionCallback.Message(":boom: Ошибка при запуске голосования.")); {
message.Content = ":boom: Ошибка при запуске голосования.";
});
} }
} }
private static ulong GetResolvedPermissions(NetCord.Rest.RestGuild guild, NetCord.GuildUser member)
{
if (member is null)
return 0;
ulong resolved = 0;
foreach (var roleId in member.RoleIds)
{
if (guild.Roles.TryGetValue(roleId, out var role))
resolved |= (ulong)role.Permissions;
}
return resolved;
}
} }
@@ -1,114 +1,46 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using Dapper;
using GmRelay.DiscordBot.Rendering; using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using Npgsql;
using NetCord.Rest; using NetCord.Rest;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record DiscordRescheduleVoteInput( public sealed record DiscordRescheduleVoteInput(
Guid OptionId, ulong UserId, string InteractionId, Guid OptionId,
string GuildId, string ChannelId, string MessageId); ulong UserId,
string InteractionId,
string GuildId,
string ChannelId,
string MessageId);
public sealed class DiscordRescheduleVoteHandler( public sealed class DiscordRescheduleVoteHandler(
NpgsqlDataSource dataSource, GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
RestClient restClient, RestClient restClient,
ILogger<DiscordRescheduleVoteHandler> logger) ILogger<DiscordRescheduleVoteHandler> logger)
{ {
public async Task<string> HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct) public async Task<string> HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct)
{ {
await using var connection = await dataSource.OpenConnectionAsync(ct); var command = new HandleRescheduleVoteCommand(
await using var transaction = await connection.BeginTransactionAsync(ct); input.OptionId,
new PlatformUser(PlatformKind.Discord, input.UserId.ToString(), string.Empty, null),
new PlatformGroup(PlatformKind.Discord, input.GuildId, string.Empty, input.ChannelId),
input.InteractionId,
new PlatformMessageRef(PlatformKind.Discord, input.ChannelId, null, input.MessageId));
// 1. Load proposal + option var result = await sharedHandler.HandleAsync(command, ct);
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title, s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { input.OptionId },
transaction);
if (proposal is null) if (!result.Success)
return "Голосование уже завершено или не найдено."; {
return result.ReplyText!;
}
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
return "Дедлайн уже прошёл. Результаты скоро будут применены.";
// 2. Verify participant (Discord platform)
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.platform = 'Discord'
AND p.external_user_id = @UserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, UserId = input.UserId.ToString(), Active = ParticipantRegistrationStatus.Active },
transaction);
if (playerId is null)
return "Вы не являетесь участником этой сессии.";
// 3. Upsert vote
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id, voted_at = now()
""",
new { ProposalId = proposal.Id, PlayerId = playerId.Value, input.OptionId },
transaction);
// 4. Reload participants, options, votes for re-rendering
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId, display_order AS DisplayOrder, proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
// 5. Re-render and update Discord vote message
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt, result.Title!,
options, participants, votes); result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Options,
result.Participants,
result.Votes);
var channelIdUlong = ulong.Parse(input.ChannelId); var channelIdUlong = ulong.Parse(input.ChannelId);
var messageIdUlong = ulong.Parse(input.MessageId); var messageIdUlong = ulong.Parse(input.MessageId);
@@ -123,9 +55,9 @@ public sealed class DiscordRescheduleVoteHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id); logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", result.ProposalId);
} }
return "Ваш голос учтён. До дедлайна его можно изменить."; return result.ReplyText!;
} }
} }
@@ -152,7 +152,7 @@ public sealed class DiscordRescheduleVotingDeadlineService(
""" """
SELECT sp.session_id AS SessionId, SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername, p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus sp.registration_status AS RegistrationStatus
FROM session_participants sp FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
@@ -1,8 +1,10 @@
using GmRelay.DiscordBot.Infrastructure.Discord; using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Confirmation.HandleRsvp; using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Sessions.CreateSession; using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using System.Collections;
using System.Globalization; using System.Globalization;
using NetCord; using NetCord;
using NetCord.Rest; using NetCord.Rest;
@@ -14,6 +16,7 @@ public sealed class DiscordSessionInteractionModule(
JoinSessionHandler joinSessionHandler, JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler, LeaveSessionHandler leaveSessionHandler,
HandleRsvpHandler rsvpHandler, HandleRsvpHandler rsvpHandler,
DiscordDeleteSessionHandler deleteSessionHandler,
DiscordRescheduleVoteHandler voteHandler, DiscordRescheduleVoteHandler voteHandler,
DiscordInteractionReplyCache interactionReplies, DiscordInteractionReplyCache interactionReplies,
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext> ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
@@ -28,21 +31,22 @@ public sealed class DiscordSessionInteractionModule(
} }
var input = CreateInput(parsedSessionId); var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)); await RespondAsync(InteractionCallback.DeferredModifyMessage);
SessionInteractionResult result;
try try
{ {
await joinSessionHandler.HandleAsync( result = await joinSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateJoinCommand(input), DiscordSessionInteractionMapper.CreateJoinCommand(input) with { DeferScheduleUpdate = true },
CancellationToken.None); CancellationToken.None);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId); logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку."); await FollowupEphemeralAsync("Не удалось обработать кнопку.");
return; return;
} }
await CompleteWithStoredReplyAsync(input.InteractionId); await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
} }
[ComponentInteraction("leave_session")] [ComponentInteraction("leave_session")]
@@ -55,21 +59,56 @@ public sealed class DiscordSessionInteractionModule(
} }
var input = CreateInput(parsedSessionId); var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)); await RespondAsync(InteractionCallback.DeferredModifyMessage);
SessionInteractionResult result;
try try
{ {
await leaveSessionHandler.HandleAsync( result = await leaveSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateLeaveCommand(input), DiscordSessionInteractionMapper.CreateLeaveCommand(input) with { DeferScheduleUpdate = true },
CancellationToken.None); CancellationToken.None);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId); logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку."); await FollowupEphemeralAsync("Не удалось обработать кнопку.");
return; return;
} }
await CompleteWithStoredReplyAsync(input.InteractionId); await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
}
[ComponentInteraction("delete_session")]
public async Task DeleteAsync(string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
var member = Context.User as GuildInteractionUser;
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
await RespondAsync(InteractionCallback.DeferredModifyMessage);
try
{
var result = await deleteSessionHandler.HandleAsync(
guildId: input.GuildId,
channelId: input.ChannelId,
userId: input.UserId,
resolvedPermissions: resolvedPermissions,
guildOwnerId: 0,
sessionId: parsedSessionId,
CancellationToken.None);
await CompleteDeleteResponseAsync(result);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord delete interaction for session {SessionId}", parsedSessionId);
await FollowupEphemeralAsync("Не удалось удалить сессию.");
}
} }
[ComponentInteraction("rsvp")] [ComponentInteraction("rsvp")]
@@ -124,7 +163,7 @@ public sealed class DiscordSessionInteractionModule(
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId); logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("РќРµ удалось обработать РєРЅРѕРїРєСѓ."); await CompleteResponseAsync("Не удалось обработать кнопку.");
return; return;
} }
@@ -190,9 +229,85 @@ public sealed class DiscordSessionInteractionModule(
await CompleteResponseAsync(reply?.Text ?? "Session updated."); await CompleteResponseAsync(reply?.Text ?? "Session updated.");
} }
private async Task CompleteScheduleUpdateResponseAsync(string interactionId, SessionInteractionResult result)
{
var updatedView = result.UpdatedView;
if (updatedView is not null && SourceMessageHasDeleteAction())
{
updatedView = DiscordListSessionsHandler.AddManagerActions(updatedView);
}
if (updatedView is not null)
{
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(updatedView);
await ModifyResponseAsync(options =>
{
options.Embeds = embeds;
options.Components = actionRows;
});
}
var reply = interactionReplies.Take(interactionId);
await FollowupEphemeralAsync(reply?.Text ?? result.ReplyText);
}
private async Task CompleteDeleteResponseAsync(DiscordDeleteSessionResult result)
{
if (result.UpdatedView is not null)
{
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(result.UpdatedView);
await ModifyResponseAsync(options =>
{
options.Embeds = embeds;
options.Components = actionRows;
});
}
else if (result.EmptyMessage is not null)
{
await ModifyResponseAsync(options =>
{
options.Content = result.EmptyMessage;
options.Embeds = [];
options.Components = [];
});
}
await FollowupEphemeralAsync(result.ReplyText);
}
private Task CompleteResponseAsync(string text) => private Task CompleteResponseAsync(string text) =>
ModifyResponseAsync(options => options.Content = text); ModifyResponseAsync(options => options.Content = text);
private Task FollowupEphemeralAsync(string text) =>
FollowupAsync(new InteractionMessageProperties()
.WithContent(text)
.WithFlags(MessageFlags.Ephemeral));
private bool SourceMessageHasDeleteAction() =>
Context.Interaction.Message?.Components.Any(ComponentContainsDeleteAction) == true;
private static bool ComponentContainsDeleteAction(object? component)
{
if (component is null)
return false;
if (component is IInteractiveComponent interactive
&& interactive.CustomId.StartsWith("delete_session:", StringComparison.Ordinal))
return true;
var nestedComponents = component.GetType().GetProperty("Components")?.GetValue(component) as IEnumerable;
if (nestedComponents is null)
return false;
foreach (var nestedComponent in nestedComponents)
{
if (ComponentContainsDeleteAction(nestedComponent))
return true;
}
return false;
}
private static InteractionCallbackProperties CreateEphemeralReply(string text) => private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
InteractionCallback.Message( InteractionCallback.Message(
new InteractionMessageProperties() new InteractionMessageProperties()
@@ -6,11 +6,14 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId> <UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
<NoWarn>$(NoWarn);DAP005</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" /> <PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
<PackageReference Include="Dapper" Version="2.1.72" /> <PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Dapper.AOT" Version="1.0.48" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" /> <PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
<PackageReference Include="NetCord.Hosting.Services" Version="1.0.0-alpha.489" /> <PackageReference Include="NetCord.Hosting.Services" Version="1.0.0-alpha.489" />
@@ -77,6 +77,38 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
await restClient.SendMessageAsync(GetChannelId(group), htmlText); await restClient.SendMessageAsync(GetChannelId(group), htmlText);
} }
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
var rows = BuildActionRows(actions);
await restClient.SendMessageAsync(GetChannelId(group), new MessageProperties().WithContent(htmlText).WithComponents(rows));
}
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
var messageId = ParseSnowflake(messageRef.ExternalMessageId);
var rows = BuildActionRows(actions);
await restClient.ModifyMessageAsync(channelId, messageId, options =>
{
options.Content = htmlText;
options.Components = rows;
});
}
public Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
{
// Discord thread creation is not implemented in this adapter
return Task.FromResult(new PlatformMessageRef(PlatformKind.Discord, group.ExternalGroupId, group.ExternalThreadId, string.Empty));
}
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) => Task.CompletedTask;
public async Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
{
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
await restClient.DeleteMessageAsync(channelId, ParseSnowflake(messageRef.ExternalMessageId));
}
public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{ {
await SendDirectContentAsync(message.Recipient, message.HtmlText, ct); await SendDirectContentAsync(message.Recipient, message.HtmlText, ct);
@@ -98,17 +130,34 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
CancellationToken ct) CancellationToken ct)
{ {
var channelId = GetChannelId(request.Group); var channelId = GetChannelId(request.Group);
var message = await restClient.SendMessageAsync( try
channelId, {
new MessageProperties() var message = await restClient.SendMessageAsync(
.WithEmbeds([BuildConfirmationEmbed(request)]) channelId,
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false))); new MessageProperties()
.WithEmbeds([BuildConfirmationEmbed(request)])
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
return new PlatformMessageRef( logger?.LogInformation(
PlatformKind.Discord, "Confirmation request sent to Discord channel {ChannelId}, message id {MessageId}",
request.Group.ExternalGroupId, channelId,
null, message.Id);
message.Id.ToString(CultureInfo.InvariantCulture));
return new PlatformMessageRef(
PlatformKind.Discord,
request.Group.ExternalGroupId,
null,
message.Id.ToString(CultureInfo.InvariantCulture));
}
catch (Exception ex)
{
logger?.LogError(
ex,
"Failed to send confirmation request to Discord channel {ChannelId} for session {SessionId}",
channelId,
request.SessionId);
throw;
}
} }
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct) public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
@@ -135,15 +184,32 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
CancellationToken ct) CancellationToken ct)
{ {
var channelId = GetChannelId(notification.Group); var channelId = GetChannelId(notification.Group);
var message = await restClient.SendMessageAsync( try
channelId, {
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)])); var message = await restClient.SendMessageAsync(
channelId,
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
return new PlatformMessageRef( logger?.LogInformation(
PlatformKind.Discord, "Join link sent to Discord channel {ChannelId}, message id {MessageId}",
notification.Group.ExternalGroupId, channelId,
null, message.Id);
message.Id.ToString(CultureInfo.InvariantCulture));
return new PlatformMessageRef(
PlatformKind.Discord,
notification.Group.ExternalGroupId,
null,
message.Id.ToString(CultureInfo.InvariantCulture));
}
catch (Exception ex)
{
logger?.LogError(
ex,
"Failed to send join link to Discord channel {ChannelId} for session {SessionId}",
channelId,
notification.SessionId);
throw;
}
} }
public async Task SendDirectSessionNotificationAsync( public async Task SendDirectSessionNotificationAsync(
@@ -272,14 +338,16 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
? "—" ? "—"
: string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User))); : string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User)));
return new EmbedProperties() var embed = new EmbedProperties()
.WithTitle($"Ссылка на игру: {notification.Title}") .WithTitle($"Ссылка на игру: {notification.Title}")
.WithDescription( .WithDescription(
$"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" + $"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" +
$"Ссылка: {notification.JoinLink}\n\n" + $"Ссылка: {notification.JoinLink}\n\n" +
$"Участники: {mentions}") $"Участники: {mentions}")
.WithUrl(notification.JoinLink)
.WithColor(new Color(0x57F287)); .WithColor(new Color(0x57F287));
var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(notification.JoinLink);
return embedUrl is null ? embed : embed.WithUrl(embedUrl);
} }
private static IReadOnlyList<ActionRowProperties> BuildRsvpRows(Guid sessionId, bool disabled) private static IReadOnlyList<ActionRowProperties> BuildRsvpRows(Guid sessionId, bool disabled)
@@ -367,6 +435,30 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
return ParseSnowflake(channelId); return ParseSnowflake(channelId);
} }
private static IReadOnlyList<ActionRowProperties> BuildActionRows(IReadOnlyList<PlatformMessageAction> actions)
{
if (actions.Count == 0)
{
return [];
}
var rows = new List<ActionRowProperties>();
foreach (var chunk in actions.Chunk(5))
{
var row = new ActionRowProperties();
foreach (var action in chunk)
{
row.Add(new ButtonProperties(action.Key, action.Label, ButtonStyle.Secondary)
{
CustomId = action.Payload
});
}
rows.Add(row);
}
return rows;
}
private static ulong ParseSnowflake(string value) => private static ulong ParseSnowflake(string value) =>
ulong.Parse(value, CultureInfo.InvariantCulture); ulong.Parse(value, CultureInfo.InvariantCulture);
} }
+4
View File
@@ -36,6 +36,8 @@ discordOptions.Validate();
builder.Services.AddSingleton(discordOptions); builder.Services.AddSingleton(discordOptions);
builder.Logging.AddConsole();
builder.Services.AddSingleton<NpgsqlDataSource>(sp => builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
{ {
var config = sp.GetRequiredService<IConfiguration>(); var config = sp.GetRequiredService<IConfiguration>();
@@ -54,8 +56,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<DiscordDeleteSessionHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>(); builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<DiscordRescheduleHandler>(); builder.Services.AddSingleton<DiscordRescheduleHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>(); builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>(); builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>(); builder.Services.AddSingleton<JoinSessionHandler>();
@@ -0,0 +1,43 @@
namespace GmRelay.DiscordBot.Rendering;
public static class DiscordEmbedUrls
{
public static string? NormalizeHttpUrl(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var candidate = value.Trim();
if (IsSupportedHttpUrl(candidate, out var normalized))
return normalized;
if (candidate.Contains("://", StringComparison.Ordinal))
return null;
return IsSupportedHttpUrl($"https://{candidate}", out normalized)
&& HasPublicHost(normalized)
? normalized
: null;
}
private static bool IsSupportedHttpUrl(string value, out string normalized)
{
normalized = string.Empty;
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
return false;
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
return false;
}
normalized = uri.ToString();
return true;
}
private static bool HasPublicHost(string value) =>
Uri.TryCreate(value, UriKind.Absolute, out var uri)
&& uri.Host.Contains('.', StringComparison.Ordinal);
}
@@ -70,9 +70,10 @@ public static class DiscordSessionBatchRenderer
.WithInline() .WithInline()
}; };
if (!string.IsNullOrEmpty(session.JoinLink)) var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(session.JoinLink);
if (embedUrl is not null)
{ {
embed = embed.WithUrl(session.JoinLink); embed = embed.WithUrl(embedUrl);
} }
embed = embed.WithColor(GetColor(session)); embed = embed.WithColor(GetColor(session));
@@ -28,6 +28,12 @@
"resolved": "2.1.72", "resolved": "2.1.72",
"contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" "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.Hosting": { "Microsoft.Extensions.Hosting": {
"type": "Direct", "type": "Direct",
"requested": "[10.0.5, )", "requested": "[10.0.5, )",
@@ -56,8 +56,8 @@ public sealed class HandleRsvpHandler(
FROM session_participants sp FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId WHERE sp.session_id = @SessionId
AND COALESCE(p.platform, 'Telegram') = @Platform AND p.platform = @Platform
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @ExternalUserId AND p.external_user_id = @ExternalUserId
AND sp.is_gm = false AND sp.is_gm = false
AND sp.registration_status = @Active AND sp.registration_status = @Active
) )
@@ -90,8 +90,8 @@ public sealed class HandleRsvpHandler(
AND player_id = ( AND player_id = (
SELECT id SELECT id
FROM players FROM players
WHERE COALESCE(platform, 'Telegram') = @Platform WHERE platform = @Platform
AND COALESCE(external_user_id, telegram_id::TEXT) = @ExternalUserId AND external_user_id = @ExternalUserId
LIMIT 1 LIMIT 1
) )
AND registration_status = @Active AND registration_status = @Active
@@ -265,10 +265,10 @@ public sealed class HandleRsvpHandler(
var participants = (await connection.QueryAsync<ParticipantRsvpRow>( var participants = (await connection.QueryAsync<ParticipantRsvpRow>(
""" """
SELECT COALESCE(p.platform, 'Telegram') AS Platform, SELECT p.platform AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, p.external_username AS ExternalUsername,
sp.rsvp_status AS RsvpStatus, sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus, sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm sp.is_gm AS IsGm
@@ -312,23 +312,13 @@ public sealed class HandleRsvpHandler(
var rows = await connection.QueryAsync<RsvpRecipientRow>( var rows = await connection.QueryAsync<RsvpRecipientRow>(
""" """
SELECT DISTINCT SELECT DISTINCT
COALESCE(p.platform, 'Telegram') AS Platform, p.platform AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername p.external_username AS ExternalUsername
FROM group_managers gm FROM group_managers gm
JOIN players p ON p.id = gm.player_id JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId WHERE gm.group_id = @GroupId
UNION
SELECT DISTINCT
COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
FROM game_groups g
JOIN players p ON p.telegram_id = g.gm_telegram_id
WHERE g.id = @GroupId
AND g.gm_telegram_id IS NOT NULL
""", """,
new { GroupId = groupId }, new { GroupId = groupId },
transaction); transaction);
@@ -45,10 +45,10 @@ public sealed class SendConfirmationHandler(
s.title, s.title,
s.scheduled_at AS ScheduledAt, s.scheduled_at AS ScheduledAt,
s.group_id AS GroupId, s.group_id AS GroupId,
COALESCE(g.platform, 'Telegram') AS Platform, g.platform AS Platform,
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId, g.external_group_id AS ExternalGroupId,
g.name AS DisplayName, g.name AS DisplayName,
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId, g.external_channel_id AS ExternalChannelId,
s.thread_id AS ThreadId, s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode s.notification_mode AS NotificationMode
FROM sessions s FROM sessions s
@@ -65,10 +65,10 @@ public sealed class SendConfirmationHandler(
var participants = (await connection.QueryAsync<ConfirmationParticipantRow>( var participants = (await connection.QueryAsync<ConfirmationParticipantRow>(
""" """
SELECT COALESCE(p.platform, 'Telegram') AS Platform, SELECT p.platform AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, p.external_username AS ExternalUsername,
sp.rsvp_status AS RsvpStatus, sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus, sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm sp.is_gm AS IsGm
@@ -47,10 +47,10 @@ public sealed class SendJoinLinkHandler(
s.title, s.title,
s.join_link AS JoinLink, s.join_link AS JoinLink,
s.scheduled_at AS ScheduledAt, s.scheduled_at AS ScheduledAt,
COALESCE(g.platform, 'Telegram') AS Platform, g.platform AS Platform,
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId, g.external_group_id AS ExternalGroupId,
g.name AS DisplayName, g.name AS DisplayName,
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId, g.external_channel_id AS ExternalChannelId,
s.thread_id AS ThreadId, s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode s.notification_mode AS NotificationMode
FROM sessions s FROM sessions s
@@ -58,14 +58,14 @@ public sealed class SendJoinLinkHandler(
WHERE s.id = @SessionId WHERE s.id = @SessionId
AND s.status = @Confirmed AND s.status = @Confirmed
AND ( AND (
(COALESCE(g.platform, 'Telegram') = 'Telegram' AND s.link_message_id IS NULL) (g.platform = 'Telegram' AND s.link_message_id IS NULL)
OR ( OR (
COALESCE(g.platform, 'Telegram') <> 'Telegram' g.platform <> 'Telegram'
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 SELECT 1
FROM platform_messages pm FROM platform_messages pm
WHERE pm.session_id = s.id WHERE pm.session_id = s.id
AND pm.platform = COALESCE(g.platform, 'Telegram') AND pm.platform = g.platform
AND pm.purpose = 'join_link' AND pm.purpose = 'join_link'
) )
) )
@@ -81,10 +81,10 @@ public sealed class SendJoinLinkHandler(
var players = (await connection.QueryAsync<JoinLinkPlayerRow>( var players = (await connection.QueryAsync<JoinLinkPlayerRow>(
""" """
SELECT COALESCE(p.platform, 'Telegram') AS Platform, SELECT p.platform AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, p.external_username AS ExternalUsername,
sp.rsvp_status AS RsvpStatus, sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus, sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm sp.is_gm AS IsGm
@@ -56,10 +56,10 @@ public sealed class SendOneHourReminderHandler(
var recipients = (await connection.QueryAsync<OneHourReminderRecipientRow>( var recipients = (await connection.QueryAsync<OneHourReminderRecipientRow>(
""" """
SELECT COALESCE(p.platform, 'Telegram') AS Platform, SELECT p.platform AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername p.external_username AS ExternalUsername
FROM session_participants sp FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId WHERE sp.session_id = @SessionId
@@ -0,0 +1,12 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record CreateSessionCommand(
PlatformUser User,
PlatformGroup Group,
string Title,
string Link,
IReadOnlyList<DateTimeOffset> ScheduledTimes,
int? MaxPlayers,
string? ImageReference);
@@ -0,0 +1,157 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
public sealed class CreateSessionHandler(
NpgsqlDataSource dataSource)
{
public async Task<CreateSessionResult> HandleAsync(CreateSessionCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
try
{
var platform = command.User.Platform.ToString();
var externalUserId = command.User.ExternalUserId;
var displayName = command.User.DisplayName;
var externalUsername = command.User.ExternalUsername;
await connection.ExecuteAsync(
"""
INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, @Platform, @ExternalId, @Username)
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,
external_username = EXCLUDED.external_username;
""",
new { ExternalId = externalUserId, Name = displayName, Username = externalUsername },
transaction);
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
"""
SELECT g.id AS GroupId,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = g.id
AND p.platform = @Platform
AND p.external_user_id = @ExternalGmId
) AS CanManage
FROM game_groups g
WHERE g.platform = @Platform
AND g.external_group_id = @ExternalGroupId
""",
new { Platform = platform, ExternalGroupId = command.Group.ExternalGroupId, ExternalGmId = externalUserId },
transaction);
Guid groupId;
if (existingGroup is null)
{
groupId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
VALUES (@ChatName, @Platform, @ExternalGroupId, @ExternalChannelId)
RETURNING id;
""",
new
{
Platform = platform,
ExternalGroupId = command.Group.ExternalGroupId,
ExternalChannelId = command.Group.ExternalChannelId,
ChatName = command.Group.DisplayName
},
transaction);
await connection.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE p.platform = @Platform
AND p.external_user_id = @ExternalGmId
ON CONFLICT (group_id, player_id) DO NOTHING
""",
new { GroupId = groupId, ExternalGmId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
}
else
{
if (!existingGroup.CanManage)
{
await transaction.RollbackAsync(ct);
return new CreateSessionResult(
false,
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
null,
null,
null,
Array.Empty<string>());
}
groupId = existingGroup.GroupId;
await connection.ExecuteAsync(
"""
UPDATE game_groups
SET name = @ChatName
WHERE id = @GroupId
""",
new { ChatName = command.Group.DisplayName, GroupId = groupId },
transaction);
}
var batchId = Guid.NewGuid();
var sessions = new List<SessionBatchDto>();
var orderedTimes = command.ScheduledTimes.OrderBy(v => v).ToList();
foreach (var scheduledAt in orderedTimes)
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
RETURNING id;
""",
new
{
BatchId = batchId,
GroupId = groupId,
command.Title,
Link = command.Link,
ScheduledAt = scheduledAt,
Status = SessionStatus.Planned,
MaxPlayers = command.MaxPlayers
},
transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, command.MaxPlayers, command.Link));
}
await transaction.CommitAsync(ct);
var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty<ParticipantBatchDto>());
return new CreateSessionResult(
true,
null,
view,
batchId,
groupId,
Array.Empty<string>());
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
}
}
@@ -0,0 +1,11 @@
using GmRelay.Shared.Rendering;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record CreateSessionResult(
bool Success,
string? ErrorMessage,
SessionBatchViewModel? View,
Guid? BatchId,
Guid? GroupId,
IReadOnlyList<string> Warnings);
@@ -13,7 +13,12 @@ public sealed record JoinSessionCommand(
PlatformUser User, PlatformUser User,
string InteractionId, string InteractionId,
PlatformGroup Group, PlatformGroup Group,
PlatformMessageRef ScheduleMessage); PlatformMessageRef ScheduleMessage,
bool DeferScheduleUpdate = false);
public sealed record SessionInteractionResult(
string ReplyText,
SessionBatchViewModel? UpdatedView = null);
// DTOs for AOT compilation // DTOs for AOT compilation
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers); internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
@@ -24,7 +29,7 @@ public sealed class JoinSessionHandler(
IScheduleMessageUpdateLock scheduleUpdateLock, IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<JoinSessionHandler> logger) ILogger<JoinSessionHandler> logger)
{ {
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct) public async Task<SessionInteractionResult> HandleAsync(JoinSessionCommand command, CancellationToken ct)
{ {
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, 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);
@@ -35,30 +40,19 @@ public sealed class JoinSessionHandler(
{ {
// 1. Убеждаемся, что игрок есть в базе // 1. Убеждаемся, что игрок есть в базе
var platform = command.User.Platform.ToString(); 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>( var playerId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username) @"INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername) VALUES (@Name, @Platform, @ExternalUserId, @ExternalUsername)
ON CONFLICT (platform, external_user_id) ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE DO UPDATE
SET display_name = EXCLUDED.display_name, SET display_name = EXCLUDED.display_name,
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
platform = EXCLUDED.platform,
external_user_id = EXCLUDED.external_user_id,
external_username = EXCLUDED.external_username external_username = EXCLUDED.external_username
RETURNING id;", RETURNING id;",
new new
{ {
LegacyTelegramId = legacyTelegramId,
Name = command.User.DisplayName, Name = command.User.DisplayName,
LegacyTelegramUsername = legacyTelegramUsername,
Platform = platform, Platform = platform,
command.User.ExternalUserId, command.User.ExternalUserId,
command.User.ExternalUsername command.User.ExternalUsername
@@ -77,15 +71,13 @@ public sealed class JoinSessionHandler(
if (batchInfo is null) if (batchInfo is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct); return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
return;
} }
if (SessionStatus.IsCancelled(batchInfo.Status)) if (SessionStatus.IsCancelled(batchInfo.Status))
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct); return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
return;
} }
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>( var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
@@ -105,8 +97,7 @@ public sealed class JoinSessionHandler(
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы уже в листе ожидания!" ? "Вы уже в листе ожидания!"
: "Вы уже записаны!"; : "Вы уже записаны!";
await AnswerAsync(command.InteractionId, alreadyText, ct); return await AnswerAsync(command.InteractionId, alreadyText, ct);
return;
} }
var activeParticipants = await connection.ExecuteScalarAsync<int>( var activeParticipants = await connection.ExecuteScalarAsync<int>(
@@ -139,8 +130,7 @@ public sealed class JoinSessionHandler(
if (inserted == 0) if (inserted == 0)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct); return await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
return;
} }
// Загружаем весь батч для перерисовки // Загружаем весь батч для перерисовки
@@ -154,7 +144,7 @@ public sealed class JoinSessionHandler(
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>( var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId, @"SELECT sp.session_id as SessionId,
p.display_name as DisplayName, p.display_name as DisplayName,
COALESCE(p.external_username, p.telegram_username) as TelegramUsername, p.external_username as TelegramUsername,
sp.registration_status as RegistrationStatus sp.registration_status as RegistrationStatus
FROM session_participants sp FROM session_participants sp
JOIN players p ON sp.player_id = p.id JOIN players p ON sp.player_id = p.id
@@ -168,17 +158,20 @@ public sealed class JoinSessionHandler(
// 4. Перерисовываем сообщение // 4. Перерисовываем сообщение
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList()); var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
await messenger.UpdateScheduleAsync( if (!command.DeferScheduleUpdate)
new PlatformScheduleMessage( {
command.Group, await messenger.UpdateScheduleAsync(
view, new PlatformScheduleMessage(
command.ScheduleMessage), command.Group,
ct); view,
command.ScheduleMessage),
ct);
}
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Основной состав заполнен. Вы добавлены в лист ожидания." ? "Основной состав заполнен. Вы добавлены в лист ожидания."
: "Вы успешно записаны!"; : "Вы успешно записаны!";
await AnswerAsync(command.InteractionId, callbackText, ct); return await AnswerAsync(command.InteractionId, callbackText, ct, view);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -191,10 +184,17 @@ public sealed class JoinSessionHandler(
var errorText = transactionCommitted var errorText = transactionCommitted
? "Регистрация сохранена, но не удалось обновить сообщение расписания." ? "Регистрация сохранена, но не удалось обновить сообщение расписания."
: "Произошла ошибка при регистрации."; : "Произошла ошибка при регистрации.";
await AnswerAsync(command.InteractionId, errorText, ct); return await AnswerAsync(command.InteractionId, errorText, ct);
} }
} }
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) => private async Task<SessionInteractionResult> AnswerAsync(
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct); string interactionId,
string text,
CancellationToken ct,
SessionBatchViewModel? updatedView = null)
{
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
return new SessionInteractionResult(text, updatedView);
}
} }
@@ -12,7 +12,8 @@ public sealed record LeaveSessionCommand(
PlatformUser User, PlatformUser User,
string InteractionId, string InteractionId,
PlatformGroup Group, PlatformGroup Group,
PlatformMessageRef ScheduleMessage); PlatformMessageRef ScheduleMessage,
bool DeferScheduleUpdate = false);
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers); internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus); internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
@@ -24,7 +25,7 @@ public sealed class LeaveSessionHandler(
IScheduleMessageUpdateLock scheduleUpdateLock, IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<LeaveSessionHandler> logger) ILogger<LeaveSessionHandler> logger)
{ {
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct) public async Task<SessionInteractionResult> HandleAsync(LeaveSessionCommand command, CancellationToken ct)
{ {
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, 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);
@@ -49,15 +50,13 @@ public sealed class LeaveSessionHandler(
if (session is null) if (session is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct); return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
return;
} }
if (SessionStatus.IsCancelled(session.Status)) if (SessionStatus.IsCancelled(session.Status))
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct); return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
return;
} }
var platform = command.User.Platform.ToString(); var platform = command.User.Platform.ToString();
@@ -81,8 +80,7 @@ public sealed class LeaveSessionHandler(
if (participant is null) if (participant is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct); return await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
return;
} }
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -175,7 +173,7 @@ public sealed class LeaveSessionHandler(
""" """
SELECT sp.session_id AS SessionId, SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername, p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus sp.registration_status AS RegistrationStatus
FROM session_participants sp FROM session_participants sp
JOIN players p ON sp.player_id = p.id JOIN players p ON sp.player_id = p.id
@@ -190,12 +188,15 @@ public sealed class LeaveSessionHandler(
transactionCommitted = true; transactionCommitted = true;
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants); var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync( if (!command.DeferScheduleUpdate)
new PlatformScheduleMessage( {
command.Group, await messenger.UpdateScheduleAsync(
view, new PlatformScheduleMessage(
command.ScheduleMessage), command.Group,
ct); view,
command.ScheduleMessage),
ct);
}
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы удалены из листа ожидания." ? "Вы удалены из листа ожидания."
@@ -203,7 +204,7 @@ public sealed class LeaveSessionHandler(
? "Вы отписались от сессии." ? "Вы отписались от сессии."
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}."; : $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
await AnswerAsync(command.InteractionId, callbackText, ct); return await AnswerAsync(command.InteractionId, callbackText, ct, view);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -216,10 +217,17 @@ public sealed class LeaveSessionHandler(
var errorText = transactionCommitted var errorText = transactionCommitted
? "Запись снята, но не удалось обновить сообщение расписания." ? "Запись снята, но не удалось обновить сообщение расписания."
: "Произошла ошибка при отмене записи."; : "Произошла ошибка при отмене записи.";
await AnswerAsync(command.InteractionId, errorText, ct); return await AnswerAsync(command.InteractionId, errorText, ct);
} }
} }
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) => private async Task<SessionInteractionResult> AnswerAsync(
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct); string interactionId,
string text,
CancellationToken ct,
SessionBatchViewModel? updatedView = null)
{
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
return new SessionInteractionResult(text, updatedView);
}
} }
@@ -0,0 +1,7 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
public sealed record ExportCalendarCommand(
PlatformGroup Group,
PlatformUser User);
@@ -0,0 +1,111 @@
using System.Text;
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
IConfiguration configuration)
{
public async Task HandleAsync(ExportCalendarCommand command, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<CalendarSessionDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
+ " FROM sessions s"
+ " JOIN game_groups g ON s.group_id = g.id"
+ " WHERE g.platform = @Platform"
+ " AND g.external_group_id = @ExternalGroupId"
+ " AND s.status = @Planned"
+ " AND s.scheduled_at > NOW()"
+ " ORDER BY s.scheduled_at ASC",
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
{
await messenger.SendGroupMessageAsync(
command.Group,
"📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken);
return;
}
var sb = new StringBuilder();
sb.AppendLine("BEGIN:VCALENDAR");
sb.AppendLine("VERSION:2.0");
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
foreach (var s in sessionsList)
{
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
sb.AppendLine("BEGIN:VEVENT");
sb.AppendLine($"UID:{s.Id}@gmrelay");
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{s.Title}");
sb.AppendLine("END:VEVENT");
}
sb.AppendLine("END:VCALENDAR");
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
// Create calendar subscription
string? subscriptionUrl = null;
var baseUrl = configuration["Web:BaseUrl"];
var senderId = command.User.ExternalUserId;
if (!string.IsNullOrWhiteSpace(baseUrl) && !string.IsNullOrWhiteSpace(senderId))
{
try
{
var token = Guid.NewGuid().ToString("N");
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
@"SELECT id FROM game_groups WHERE platform = @Platform AND external_group_id = @ExternalGroupId",
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId });
await connection.ExecuteAsync(
@"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, @userPlatform, @userExternalId, @groupId, @filterType, now(), NULL)",
new { token, userPlatform = command.Group.Platform.ToString(), userExternalId = senderId, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
}
catch
{
// Non-critical: if subscription creation fails, still send the file
}
}
var actions = subscriptionUrl is not null
? new[]
{
new PlatformMessageAction(
"calendar-subscription",
"🔗 Подписаться на календарь",
subscriptionUrl)
}
: Array.Empty<PlatformMessageAction>();
await messenger.SendCalendarFileAsync(
new PlatformCalendarFile(
command.Group,
"schedule.ics",
bytes,
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
actions),
cancellationToken);
}
}
@@ -0,0 +1,91 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.ListSessions;
internal sealed record DeleteSessionInfoDto(
string Title,
Guid BatchId,
Guid GroupId,
bool CanManage,
int? ThreadId,
bool TopicCreatedByBot);
public sealed record DeleteSessionResult(
bool Success,
string? ReplyText,
string? Title,
Guid? GroupId,
int? ThreadId,
bool TopicCreatedByBot,
int RemainingInTopic);
public sealed class DeleteSessionHandler(
NpgsqlDataSource dataSource)
{
public async Task<DeleteSessionResult> HandleAsync(DeleteSessionCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Fetch session and verify group manager.
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.group_id AS GroupId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
""",
new { command.SessionId, Platform = command.User.Platform.ToString(), ExternalUserId = command.User.ExternalUserId }, transaction);
if (session == null)
{
return new DeleteSessionResult(false, "Сессия не найдена.", null, null, null, false, 0);
}
if (!session.CanManage)
{
return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0);
}
// 2. Delete session
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
var remainingInTopic = session.ThreadId.HasValue
? await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM sessions
WHERE group_id = @GroupId
AND thread_id = @ThreadId
""",
new { session.GroupId, ThreadId = session.ThreadId.Value },
transaction)
: 0;
await transaction.CommitAsync(ct);
return new DeleteSessionResult(
true,
"Сессия удалена!",
session.Title,
session.GroupId,
session.ThreadId,
session.TopicCreatedByBot,
remainingInTopic);
}
}
@@ -0,0 +1,13 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.ListSessions;
public sealed record ListSessionsCommand(
PlatformGroup Group,
PlatformUser User);
public sealed record DeleteSessionCommand(
Guid SessionId,
PlatformUser User,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
@@ -0,0 +1,57 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.ListSessions;
public sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
public sealed record SessionListResult(
IReadOnlyList<SessionListItemDto> Sessions,
bool CanManage);
public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource)
{
public async Task<SessionListResult> HandleAsync(ListSessionsCommand command, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.platform = @Platform
AND manager_player.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.platform = @Platform
AND g.external_group_id = @ExternalGroupId
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC",
new
{
Platform = command.Group.Platform.ToString(),
ExternalGroupId = command.Group.ExternalGroupId,
ExternalUserId = command.User.ExternalUserId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionsList = sessions.ToList();
var canManage = sessionsList.Count > 0 && sessionsList.First().CanManage;
return new SessionListResult(sessionsList, canManage);
}
}
@@ -0,0 +1,12 @@
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
internal sealed record AwaitingProposalDto(
Guid Id,
Guid SessionId,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
int? BatchMessageId,
string ExternalGroupId,
int? ThreadId,
string NotificationMode);
@@ -0,0 +1,15 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record HandleRescheduleTimeInputCommand(
PlatformUser User,
PlatformGroup Group,
string Text);
public sealed record HandleRescheduleVoteCommand(
Guid OptionId,
PlatformUser User,
PlatformGroup Group,
string InteractionId,
PlatformMessageRef ScheduleMessage);
@@ -0,0 +1,181 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed class HandleRescheduleTimeInputHandler(
NpgsqlDataSource dataSource)
{
public async Task<HandleRescheduleTimeInputResult> HandleAsync(
HandleRescheduleTimeInputCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var platform = command.User.Platform.ToString();
var externalGmId = command.User.ExternalUserId;
var externalGroupId = command.Group.ExternalGroupId;
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.external_group_id AS ExternalGroupId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.proposed_by_external_user_id = @ExternalGmId
AND rp.status = 'AwaitingTime'
AND g.platform = @Platform
AND g.external_group_id = @ExternalGroupId
AND EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.platform = @Platform
AND manager_player.external_user_id = @ExternalGmId
)
ORDER BY rp.created_at DESC
LIMIT 1
""",
new { ExternalGmId = externalGmId, Platform = platform, ExternalGroupId = externalGroupId });
if (proposal is null)
return new HandleRescheduleTimeInputResult(false, false, null, null, null, null, [], [], [], null, default, null);
if (!RescheduleVotingInput.TryParse(command.Text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
{
return new HandleRescheduleTimeInputResult(
true, false, parseError, null, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, null);
}
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
p.external_user_id::BIGINT AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
if (participants.Count == 0)
{
var newTime = votingInput.Options[0];
var view = await RescheduleImmediatelyAsync(connection, proposal, newTime, ct);
var replyText =
$"""✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>""";
return new HandleRescheduleTimeInputResult(
true, true, replyText, view, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, proposal.BatchMessageId);
}
await using var transaction = await connection.BeginTransactionAsync(ct);
var options = votingInput.Options
.Select((proposedAt, index) => new RescheduleOptionDto(
Guid.NewGuid(),
index + 1,
proposedAt))
.ToList();
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @VoteChatId
WHERE id = @Id
""",
new { votingInput.Deadline, VoteChatId = externalGroupId, Id = proposal.Id },
transaction);
foreach (var option in options)
{
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
""",
new
{
option.OptionId,
ProposalId = proposal.Id,
option.ProposedAt,
option.DisplayOrder
},
transaction);
}
await transaction.CommitAsync(ct);
return new HandleRescheduleTimeInputResult(
true,
false,
null,
null,
proposal.Id,
votingInput.Deadline,
options,
participants,
[],
proposal.Title,
proposal.CurrentScheduledAt,
null);
}
private static async Task<SessionBatchViewModel?> RescheduleImmediatelyAsync(
NpgsqlConnection connection,
AwaitingProposalDto proposal,
DateTimeOffset newTime,
CancellationToken ct)
{
await using var transaction = await connection.BeginTransactionAsync(ct);
await connection.ExecuteAsync(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
confirmation_sent_at = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
new { NewTime = newTime, Id = proposal.Id },
transaction);
await transaction.CommitAsync(ct);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
""",
new { proposal.BatchId })).ToList();
return SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
}
}
@@ -0,0 +1,17 @@
using GmRelay.Shared.Rendering;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record HandleRescheduleTimeInputResult(
bool Handled,
bool IsRescheduledImmediately,
string? ReplyText,
SessionBatchViewModel? UpdatedView,
Guid? ProposalId,
DateTimeOffset? VotingDeadlineAt,
IReadOnlyList<RescheduleOptionDto> Options,
IReadOnlyList<VoteParticipantDto> Participants,
IReadOnlyList<RescheduleOptionVoteDto> Votes,
string? Title,
DateTime CurrentScheduledAt,
int? BatchMessageId);
@@ -0,0 +1,156 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource)
{
public async Task<HandleRescheduleVoteResult> HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { command.OptionId },
transaction);
if (proposal is null)
{
return new HandleRescheduleVoteResult(
false,
"Голосование уже завершено или не найдено.",
null, null, null, default, default,
Array.Empty<VoteParticipantDto>(),
Array.Empty<RescheduleOptionDto>(),
Array.Empty<RescheduleOptionVoteDto>());
}
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
{
return new HandleRescheduleVoteResult(
false,
"Дедлайн уже прошёл. Результаты скоро будут применены.",
null, null, null, default, default,
Array.Empty<VoteParticipantDto>(),
Array.Empty<RescheduleOptionDto>(),
Array.Empty<RescheduleOptionVoteDto>());
}
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new
{
proposal.SessionId,
Platform = command.User.Platform.ToString(),
ExternalUserId = command.User.ExternalUserId,
Active = ParticipantRegistrationStatus.Active
},
transaction);
if (playerId is null)
{
return new HandleRescheduleVoteResult(
false,
"Вы не являетесь участником этой сессии.",
null, null, null, default, default,
Array.Empty<VoteParticipantDto>(),
Array.Empty<RescheduleOptionDto>(),
Array.Empty<RescheduleOptionVoteDto>());
}
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id,
voted_at = now()
""",
new
{
ProposalId = proposal.Id,
PlayerId = playerId.Value,
command.OptionId
},
transaction);
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
p.external_user_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
return new HandleRescheduleVoteResult(
true,
"Ваш голос учтён. До дедлайна его можно изменить.",
proposal.Id,
proposal.SessionId,
proposal.Title,
proposal.CurrentScheduledAt,
proposal.VotingDeadlineAt,
participants,
options,
votes);
}
}
@@ -0,0 +1,15 @@
using GmRelay.Shared.Features.Sessions.RescheduleSession;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record HandleRescheduleVoteResult(
bool Success,
string? ReplyText,
Guid? ProposalId,
Guid? SessionId,
string? Title,
DateTime CurrentScheduledAt,
DateTimeOffset VotingDeadlineAt,
IReadOnlyList<VoteParticipantDto> Participants,
IReadOnlyList<RescheduleOptionDto> Options,
IReadOnlyList<RescheduleOptionVoteDto> Votes);
@@ -78,8 +78,8 @@ public sealed class RescheduleVotingFinalizer(
""" """
SELECT p.id AS PlayerId, SELECT p.id AS PlayerId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername, p.external_username AS TelegramUsername,
p.telegram_id AS TelegramId p.external_user_id::BIGINT AS TelegramId
FROM session_participants sp FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId WHERE sp.session_id = @SessionId
@@ -107,7 +107,7 @@ public sealed class RescheduleVotingFinalizer(
SELECT rov.option_id AS OptionId, SELECT rov.option_id AS OptionId,
p.id AS PlayerId, p.id AS PlayerId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername p.external_username AS TelegramUsername
FROM reschedule_option_votes rov FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId WHERE rov.proposal_id = @ProposalId
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using GmRelay.Shared.Features.Confirmation.SendConfirmation; using GmRelay.Shared.Features.Confirmation.SendConfirmation;
using GmRelay.Shared.Features.Reminders.SendJoinLink; using GmRelay.Shared.Features.Reminders.SendJoinLink;
using GmRelay.Shared.Features.Reminders.SendOneHourReminder; using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
@@ -20,6 +21,11 @@ public sealed class SessionSchedulerService(
ILogger<SessionSchedulerService> logger) : BackgroundService ILogger<SessionSchedulerService> logger) : BackgroundService
{ {
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1); private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private static readonly TimeSpan BackoffDuration = TimeSpan.FromMinutes(15);
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _confirmationBackoff = new();
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _oneHourBackoff = new();
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _joinLinkBackoff = new();
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
@@ -71,14 +77,30 @@ public sealed class SessionSchedulerService(
foreach (var sessionId in sessionIds) foreach (var sessionId in sessionIds)
{ {
if (_confirmationBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
{
logger.LogDebug(
"Skipping confirmation for session {SessionId} until {Backoff}",
sessionId,
backoffUntil);
continue;
}
try try
{ {
await confirmationHandler.HandleAsync(sessionId, ct); await confirmationHandler.HandleAsync(sessionId, ct);
_confirmationBackoff.TryRemove(sessionId, out _);
logger.LogInformation("Confirmation sent for session {SessionId}", sessionId); logger.LogInformation("Confirmation sent for session {SessionId}", sessionId);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Failed to send confirmation for session {SessionId}", sessionId); var nextAttempt = now.Add(BackoffDuration);
_confirmationBackoff[sessionId] = nextAttempt;
logger.LogError(
ex,
"Failed to send confirmation for session {SessionId}, backing off until {Backoff}",
sessionId,
nextAttempt);
} }
} }
} }
@@ -98,14 +120,30 @@ public sealed class SessionSchedulerService(
foreach (var sessionId in sessionIds) foreach (var sessionId in sessionIds)
{ {
if (_oneHourBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
{
logger.LogDebug(
"Skipping one-hour reminder for session {SessionId} until {Backoff}",
sessionId,
backoffUntil);
continue;
}
try try
{ {
await oneHourReminderHandler.HandleAsync(sessionId, ct); await oneHourReminderHandler.HandleAsync(sessionId, ct);
_oneHourBackoff.TryRemove(sessionId, out _);
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId); logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId); var nextAttempt = now.Add(BackoffDuration);
_oneHourBackoff[sessionId] = nextAttempt;
logger.LogError(
ex,
"Failed to process one-hour reminder for session {SessionId}, backing off until {Backoff}",
sessionId,
nextAttempt);
} }
} }
} }
@@ -125,14 +163,30 @@ public sealed class SessionSchedulerService(
foreach (var sessionId in sessionIds) foreach (var sessionId in sessionIds)
{ {
if (_joinLinkBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
{
logger.LogDebug(
"Skipping join link for session {SessionId} until {Backoff}",
sessionId,
backoffUntil);
continue;
}
try try
{ {
await joinLinkHandler.HandleAsync(sessionId, ct); await joinLinkHandler.HandleAsync(sessionId, ct);
_joinLinkBackoff.TryRemove(sessionId, out _);
logger.LogInformation("Join link sent for session {SessionId}", sessionId); logger.LogInformation("Join link sent for session {SessionId}", sessionId);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Failed to send join link for session {SessionId}", sessionId); var nextAttempt = now.Add(BackoffDuration);
_joinLinkBackoff[sessionId] = nextAttempt;
logger.LogError(
ex,
"Failed to send join link for session {SessionId}, backing off until {Backoff}",
sessionId,
nextAttempt);
} }
} }
} }
@@ -2,7 +2,23 @@ namespace GmRelay.Shared.Platform;
public interface IPlatformMessenger public interface IPlatformMessenger
{ {
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct); Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support messages with actions.");
Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support message updates with actions.");
Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support thread creation.");
Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support thread deletion.");
Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support message deletion.");
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support schedule messages.");
Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct); Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
@@ -73,7 +73,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v3.0.5</div> <div class="nav-version">v3.2.0</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
+25 -3
View File
@@ -44,9 +44,14 @@
<div class="group-card-icon">🎮</div> <div class="group-card-icon">🎮</div>
<h3 class="group-card-title">@group.Name</h3> <h3 class="group-card-title">@group.Name</h3>
<p class="group-card-id">ID: @(group.Platform == "Discord" ? group.ExternalGroupId : group.TelegramChatId.ToString())</p> <p class="group-card-id">ID: @(group.Platform == "Discord" ? group.ExternalGroupId : group.TelegramChatId.ToString())</p>
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;"> <div class="group-card-meta">
@FormatRole(group.ManagerRole) <span class="status-badge platform-badge">
</span> @FormatPlatform(group.Platform)
</span>
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
@FormatRole(group.ManagerRole)
</span>
</div>
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;"> <a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
Посмотреть игры → Посмотреть игры →
</a> </a>
@@ -81,6 +86,20 @@
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.group-card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 1rem;
}
.platform-badge {
background: rgba(88, 101, 242, 0.15);
color: #9ea8ff;
border-color: rgba(88, 101, 242, 0.35);
}
</style> </style>
@code { @code {
@@ -104,4 +123,7 @@
private static string FormatRole(string role) => private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName(); GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
private static string FormatPlatform(string? platform) =>
string.Equals(platform, "Discord", StringComparison.OrdinalIgnoreCase) ? "Discord" : "Telegram";
} }
@@ -12,7 +12,8 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
public string GenerateToken() => Guid.NewGuid().ToString("N"); public string GenerateToken() => Guid.NewGuid().ToString("N");
public async Task<string> CreateSubscriptionAsync( public async Task<string> CreateSubscriptionAsync(
long userTelegramId, string userPlatform,
string userExternalId,
Guid? groupId, Guid? groupId,
CalendarSubscriptionFilter filter, CalendarSubscriptionFilter filter,
CancellationToken ct = default) CancellationToken ct = default)
@@ -20,9 +21,9 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
var token = GenerateToken(); var token = GenerateToken();
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync( await connection.ExecuteAsync(
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at) @"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)", VALUES (gen_random_uuid(), @token, @userPlatform, @userExternalId, @groupId, @filterType, now(), NULL)",
new { token, userTelegramId, groupId, filterType = (int)filter }); new { token, userPlatform, userExternalId, groupId, filterType = (int)filter });
return token; return token;
} }
@@ -31,7 +32,7 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
var subscription = await connection.QueryFirstOrDefaultAsync<SubscriptionRecord>( var subscription = await connection.QueryFirstOrDefaultAsync<SubscriptionRecord>(
@"SELECT id, user_telegram_id as UserTelegramId, group_id as GroupId, filter_type as FilterType @"SELECT id, group_id as GroupId, filter_type as FilterType
FROM calendar_subscriptions FROM calendar_subscriptions
WHERE token = @token WHERE token = @token
AND (expires_at IS NULL OR expires_at > now())", AND (expires_at IS NULL OR expires_at > now())",
@@ -88,6 +89,6 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
.Replace("\n", "\\n") .Replace("\n", "\\n")
.Replace("\r", ""); .Replace("\r", "");
private sealed record SubscriptionRecord(Guid Id, long UserTelegramId, Guid? GroupId, int FilterType); private sealed record SubscriptionRecord(Guid Id, Guid? GroupId, int FilterType);
private sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt); private sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
} }
+128 -46
View File
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Exceptions;
using GmRelay.Web.Services; using GmRelay.Web.Services;
namespace GmRelay.Web.Services; namespace GmRelay.Web.Services;
@@ -95,6 +96,7 @@ internal sealed record WebBatchSessionRow(
string NotificationMode, string NotificationMode,
bool TopicCreatedByBot = false); bool TopicCreatedByBot = false);
internal sealed record WebTemplateGroupDto(long TelegramChatId); internal sealed record WebTemplateGroupDto(long TelegramChatId);
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
public sealed class SessionService( public sealed class SessionService(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
@@ -104,24 +106,45 @@ public sealed class SessionService(
public async Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId) public async Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
if (effectiveId is null) if (playerIds.Length == 0)
return []; return [];
return (await conn.QueryAsync<WebGameGroup>( return (await conn.QueryAsync<WebGameGroup>(
""" """
WITH visible_groups AS (
SELECT gm.group_id,
CASE
WHEN bool_or(gm.role = @OwnerRole) THEN @OwnerRole
ELSE @CoGmRole
END AS ManagerRole
FROM group_managers gm
WHERE gm.player_id = ANY(@PlayerIds)
GROUP BY gm.group_id
)
SELECT g.id, SELECT g.id,
g.telegram_chat_id AS TelegramChatId, g.external_group_id::BIGINT AS TelegramChatId,
g.external_group_id AS ExternalGroupId, g.external_group_id AS ExternalGroupId,
g.name, COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
g.platform AS Platform, g.platform AS Platform,
gm.role AS ManagerRole vg.ManagerRole
FROM group_managers gm FROM visible_groups vg
JOIN game_groups g ON g.id = gm.group_id JOIN game_groups g ON g.id = vg.group_id
WHERE gm.player_id = @PlayerId LEFT JOIN LATERAL (
SELECT s.title
FROM sessions s
WHERE s.group_id = g.id
ORDER BY s.scheduled_at DESC
LIMIT 1
) latest_session ON true
ORDER BY g.name ORDER BY g.name
""", """,
new { PlayerId = effectiveId.Value })).ToList(); new
{
PlayerIds = playerIds,
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
})).ToList();
} }
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId) public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
@@ -130,12 +153,19 @@ public sealed class SessionService(
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>( return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
""" """
SELECT g.id, SELECT g.id,
g.telegram_chat_id AS TelegramChatId, g.external_group_id::BIGINT AS TelegramChatId,
g.external_group_id AS ExternalGroupId, g.external_group_id AS ExternalGroupId,
g.name, COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
g.platform AS Platform, g.platform AS Platform,
@OwnerRole AS ManagerRole @OwnerRole AS ManagerRole
FROM game_groups g FROM game_groups g
LEFT JOIN LATERAL (
SELECT s.title
FROM sessions s
WHERE s.group_id = g.id
ORDER BY s.scheduled_at DESC
LIMIT 1
) latest_session ON true
WHERE g.id = @GroupId WHERE g.id = @GroupId
""", """,
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
@@ -144,8 +174,8 @@ public sealed class SessionService(
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
if (effectiveId is null) if (playerIds.Length == 0)
return false; return false;
return await conn.ExecuteScalarAsync<bool>( return await conn.ExecuteScalarAsync<bool>(
@@ -154,17 +184,17 @@ public sealed class SessionService(
SELECT 1 SELECT 1
FROM group_managers FROM group_managers
WHERE group_id = @GroupId WHERE group_id = @GroupId
AND player_id = @PlayerId AND player_id = ANY(@PlayerIds)
) )
""", """,
new { GroupId = groupId, PlayerId = effectiveId.Value }); new { GroupId = groupId, PlayerIds = playerIds });
} }
public async Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) public async Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
if (effectiveId is null) if (playerIds.Length == 0)
return false; return false;
return await conn.ExecuteScalarAsync<bool>( return await conn.ExecuteScalarAsync<bool>(
@@ -173,11 +203,11 @@ public sealed class SessionService(
SELECT 1 SELECT 1
FROM group_managers FROM group_managers
WHERE group_id = @GroupId WHERE group_id = @GroupId
AND player_id = @PlayerId AND player_id = ANY(@PlayerIds)
AND role = @OwnerRole AND role = @OwnerRole
) )
""", """,
new { GroupId = groupId, PlayerId = effectiveId.Value, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); new { GroupId = groupId, PlayerIds = playerIds, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
} }
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId) public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
@@ -185,11 +215,11 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGroupManager>( return (await conn.QueryAsync<WebGroupManager>(
""" """
SELECT COALESCE(p.telegram_id, 0) AS TelegramId, SELECT COALESCE(p.external_user_id::BIGINT, 0) AS TelegramId,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername, p.external_username AS TelegramUsername,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, p.external_username AS ExternalUsername,
gm.role AS Role, gm.role AS Role,
gm.created_at AS AddedAt gm.created_at AS AddedAt
FROM group_managers gm FROM group_managers gm
@@ -210,7 +240,7 @@ public sealed class SessionService(
SELECT SELECT
p.id AS PlayerId, p.id AS PlayerId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, p.external_username AS ExternalUsername,
COUNT(DISTINCT s.id) AS TotalSessions, COUNT(DISTINCT s.id) AS TotalSessions,
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount, COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount,
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount, COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount,
@@ -229,7 +259,7 @@ public sealed class SessionService(
WHERE s.group_id = @GroupId WHERE s.group_id = @GroupId
AND s.scheduled_at <= now() AND s.scheduled_at <= now()
AND sp.is_gm = false AND sp.is_gm = false
GROUP BY p.id, p.display_name, p.external_username, p.telegram_username GROUP BY p.id, p.display_name, p.external_username
ORDER BY AttendanceRate DESC, ConfirmedCount DESC ORDER BY AttendanceRate DESC, ConfirmedCount DESC
""", """,
new { GroupId = groupId })).ToList(); new { GroupId = groupId })).ToList();
@@ -328,7 +358,7 @@ public sealed class SessionService(
return (await conn.QueryAsync<WebSession>( return (await conn.QueryAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId, g.external_group_id::BIGINT AS TelegramChatId,
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
@@ -366,7 +396,7 @@ public sealed class SessionService(
return await conn.QuerySingleOrDefaultAsync<WebSession>( return await conn.QuerySingleOrDefaultAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId, g.external_group_id::BIGINT AS TelegramChatId,
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
@@ -425,7 +455,7 @@ public sealed class SessionService(
var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>( var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId, g.external_group_id::BIGINT AS TelegramChatId,
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
0 AS ActivePlayerCount, 0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount, 0 AS WaitlistedPlayerCount,
@@ -511,7 +541,7 @@ public sealed class SessionService(
var session = await conn.QuerySingleOrDefaultAsync<WebSession>( var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId, g.external_group_id::BIGINT AS TelegramChatId,
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
0 AS ActivePlayerCount, 0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount, 0 AS WaitlistedPlayerCount,
@@ -610,11 +640,11 @@ public sealed class SessionService(
return (await conn.QueryAsync<WebParticipant>( return (await conn.QueryAsync<WebParticipant>(
""" """
SELECT sp.id AS Id, SELECT sp.id AS Id,
COALESCE(p.telegram_id, 0) AS TelegramId, COALESCE(p.external_user_id::BIGINT, 0) AS TelegramId,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername, p.external_username AS TelegramUsername,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, p.external_username AS ExternalUsername,
sp.rsvp_status AS RsvpStatus, sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus, sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm, sp.is_gm AS IsGm,
@@ -637,7 +667,7 @@ public sealed class SessionService(
var session = await conn.QuerySingleOrDefaultAsync<WebSession>( var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId, g.external_group_id::BIGINT AS TelegramChatId,
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
0 AS ActivePlayerCount, 0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount, 0 AS WaitlistedPlayerCount,
@@ -658,9 +688,9 @@ public sealed class SessionService(
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>( var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
""" """
SELECT sp.id AS Id, SELECT sp.id AS Id,
p.telegram_id AS TelegramId, p.external_user_id::BIGINT AS TelegramId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername, p.external_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus, sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus, sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm, sp.is_gm AS IsGm,
@@ -843,7 +873,7 @@ public sealed class SessionService(
s.status AS Status, s.status AS Status,
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
s.batch_message_id AS BatchMessageId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId, g.external_group_id::BIGINT AS TelegramChatId,
s.thread_id AS ThreadId, s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot, s.topic_created_by_bot AS TopicCreatedByBot,
s.notification_mode AS NotificationMode s.notification_mode AS NotificationMode
@@ -927,7 +957,7 @@ public sealed class SessionService(
s.status AS Status, s.status AS Status,
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
s.batch_message_id AS BatchMessageId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId, g.external_group_id::BIGINT AS TelegramChatId,
s.thread_id AS ThreadId, s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot, s.topic_created_by_bot AS TopicCreatedByBot,
s.notification_mode AS NotificationMode s.notification_mode AS NotificationMode
@@ -1149,7 +1179,7 @@ public sealed class SessionService(
} }
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>( var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
"SELECT telegram_chat_id AS TelegramChatId FROM game_groups WHERE id = @GroupId", "SELECT external_group_id::BIGINT AS TelegramChatId FROM game_groups WHERE id = @GroupId",
new { GroupId = groupId }, new { GroupId = groupId },
transaction); transaction);
@@ -1158,6 +1188,10 @@ public sealed class SessionService(
throw new SessionAccessDeniedException(groupId, "0"); throw new SessionAccessDeniedException(groupId, "0");
} }
var topicDestination = await ResolveTemplateBatchTopicAsync(group.TelegramChatId, template.Title);
var messageThreadId = topicDestination.MessageThreadId;
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
var schedule = BatchSchedulePlanner.BuildRecurringSchedule( var schedule = BatchSchedulePlanner.BuildRecurringSchedule(
firstScheduledAt, firstScheduledAt,
template.SessionCount, template.SessionCount,
@@ -1169,8 +1203,8 @@ public sealed class SessionService(
{ {
var sessionId = await conn.ExecuteScalarAsync<Guid>( var sessionId = await conn.ExecuteScalarAsync<Guid>(
""" """
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, notification_mode) INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode)
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @MaxPlayers, @NotificationMode) VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode)
RETURNING id RETURNING id
""", """,
new new
@@ -1181,6 +1215,8 @@ public sealed class SessionService(
template.JoinLink, template.JoinLink,
ScheduledAt = scheduledAt, ScheduledAt = scheduledAt,
Status = SessionStatus.Planned, Status = SessionStatus.Planned,
ThreadId = messageThreadId,
TopicCreatedByBot = topicCreatedByBot,
template.MaxPlayers, template.MaxPlayers,
template.NotificationMode template.NotificationMode
}, },
@@ -1195,6 +1231,7 @@ public sealed class SessionService(
var renderResult = TelegramSessionBatchRenderer.Render(view); var renderResult = TelegramSessionBatchRenderer.Render(view);
var batchMessage = await bot.SendMessage( var batchMessage = await bot.SendMessage(
chatId: group.TelegramChatId, chatId: group.TelegramChatId,
messageThreadId: messageThreadId,
text: renderResult.Text, text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup); replyMarkup: renderResult.Markup);
@@ -1214,13 +1251,41 @@ public sealed class SessionService(
template.NotificationMode); template.NotificationMode);
} }
private async Task<WebTemplateTopicDestination> ResolveTemplateBatchTopicAsync(long telegramChatId, string title)
{
var chat = await bot.GetChat(chatId: telegramChatId);
if (!chat.IsForum)
{
return new WebTemplateTopicDestination(null, TopicCreatedByBot: false);
}
try
{
var topic = await bot.CreateForumTopic(
chatId: telegramChatId,
name: $"🎲 Игры: {title}");
return new WebTemplateTopicDestination(topic.MessageThreadId, TopicCreatedByBot: true);
}
catch (ApiRequestException ex) when (IsMissingForumTopicRightsError(ex.Message))
{
throw new InvalidOperationException(
"Не удалось создать Telegram topic. Сделайте бота admin и включите право Manage Topics, затем повторите действие.",
ex);
}
}
private static bool IsMissingForumTopicRightsError(string apiError) =>
apiError.Contains("not enough rights", StringComparison.OrdinalIgnoreCase) ||
apiError.Contains("CHAT_ADMIN_REQUIRED", StringComparison.OrdinalIgnoreCase) ||
apiError.Contains("not an administrator", StringComparison.OrdinalIgnoreCase);
private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync( private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync(
Npgsql.NpgsqlConnection conn, Npgsql.NpgsqlConnection conn,
Guid sessionId) Guid sessionId)
{ {
return (await conn.QueryAsync<WebDirectNotificationRecipient>( return (await conn.QueryAsync<WebDirectNotificationRecipient>(
""" """
SELECT p.telegram_id AS TelegramId, SELECT p.external_user_id::BIGINT AS TelegramId,
p.display_name AS DisplayName p.display_name AS DisplayName
FROM session_participants sp FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
@@ -1237,7 +1302,7 @@ public sealed class SessionService(
{ {
return (await conn.QueryAsync<WebDirectNotificationRecipient>( return (await conn.QueryAsync<WebDirectNotificationRecipient>(
""" """
SELECT DISTINCT p.telegram_id AS TelegramId, SELECT DISTINCT p.external_user_id::BIGINT AS TelegramId,
p.display_name AS DisplayName p.display_name AS DisplayName
FROM session_participants sp FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
@@ -1290,7 +1355,7 @@ public sealed class SessionService(
var participants = (await conn.QueryAsync<ParticipantBatchDto>( var participants = (await conn.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id AS SessionId, @"SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername, p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus sp.registration_status AS RegistrationStatus
FROM session_participants sp FROM session_participants sp
JOIN players p ON sp.player_id = p.id JOIN players p ON sp.player_id = p.id
@@ -1327,7 +1392,7 @@ public sealed class SessionService(
s.group_id AS GroupId, s.group_id AS GroupId,
(array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title, (array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title,
(array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink, (array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink,
g.telegram_chat_id AS TelegramChatId, g.external_group_id::BIGINT AS TelegramChatId,
(array_agg(s.batch_message_id ORDER BY s.scheduled_at))[1] AS BatchMessageId, (array_agg(s.batch_message_id ORDER BY s.scheduled_at))[1] AS BatchMessageId,
(array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId, (array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId,
(array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode (array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
@@ -1335,7 +1400,7 @@ public sealed class SessionService(
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE s.batch_id = @BatchId WHERE s.batch_id = @BatchId
AND s.group_id = @GroupId AND s.group_id = @GroupId
GROUP BY s.batch_id, s.group_id, g.telegram_chat_id GROUP BY s.batch_id, s.group_id, g.external_group_id
""", """,
new { BatchId = batchId, GroupId = groupId }, new { BatchId = batchId, GroupId = groupId },
transaction); transaction);
@@ -1561,6 +1626,23 @@ public sealed class SessionService(
return primaryId ?? playerId; return primaryId ?? playerId;
} }
private static async Task<Guid[]> _ResolveLinkedPlayerIdsAsync(NpgsqlConnection conn, string platform, string externalUserId)
{
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
if (effectiveId is null)
return [];
return (await conn.QueryAsync<Guid>(
"""
SELECT @EffectiveId
UNION
SELECT secondary_player_id
FROM player_links
WHERE primary_player_id = @EffectiveId
""",
new { EffectiveId = effectiveId.Value })).ToArray();
}
private static async Task<Guid> _UpsertPlayerAndGetIdAsync( private static async Task<Guid> _UpsertPlayerAndGetIdAsync(
NpgsqlConnection conn, string platform, string externalUserId, NpgsqlConnection conn, string platform, string externalUserId,
string displayName, string? avatarUrl, NpgsqlTransaction? transaction) string displayName, string? avatarUrl, NpgsqlTransaction? transaction)
@@ -33,7 +33,17 @@ public sealed class DiscordListSessionsHandlerTests
Assert.Contains("platform = 'Discord'", handler, StringComparison.Ordinal); Assert.Contains("platform = 'Discord'", handler, StringComparison.Ordinal);
Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal); Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal);
Assert.Contains("scheduled_at > NOW()", handler, StringComparison.Ordinal); Assert.Contains("scheduled_at > now() - interval '4 hours'", handler, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldIncludeRecentlyStartedSessionsForCleanup()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs");
var handler = File.ReadAllText(handlerPath);
Assert.Contains("now() - interval '4 hours'", handler, StringComparison.OrdinalIgnoreCase);
} }
[Fact] [Fact]
@@ -47,6 +57,17 @@ public sealed class DiscordListSessionsHandlerTests
Assert.DoesNotContain("telegram_id", handler, StringComparison.Ordinal); Assert.DoesNotContain("telegram_id", handler, StringComparison.Ordinal);
} }
[Fact]
public void Handler_ShouldExposeDeleteActionForManagers()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs");
var handler = File.ReadAllText(handlerPath);
Assert.Contains("delete_session", handler, StringComparison.Ordinal);
Assert.Contains("CanManageSchedule", handler, StringComparison.Ordinal);
}
[Fact] [Fact]
public void Command_ShouldExist() public void Command_ShouldExist()
{ {
@@ -66,4 +87,18 @@ public sealed class DiscordListSessionsHandlerTests
Assert.Contains("SlashCommand", command, StringComparison.Ordinal); Assert.Contains("SlashCommand", command, StringComparison.Ordinal);
Assert.Contains("listsessions", command, StringComparison.Ordinal); Assert.Contains("listsessions", command, StringComparison.Ordinal);
} }
[Fact]
public void DeleteHandler_ShouldDeleteOnlySessionsFromTheInteractionGuild()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordDeleteSessionHandler.cs");
Assert.True(File.Exists(handlerPath), "DiscordDeleteSessionHandler should exist.");
var handler = File.ReadAllText(handlerPath);
Assert.Contains("DELETE FROM sessions", handler, StringComparison.Ordinal);
Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal);
Assert.Contains("CanManageSchedule", handler, StringComparison.Ordinal);
}
} }
@@ -17,6 +17,17 @@ public sealed class DiscordNewSessionHandlerTests
// --- Runtime tests for ParseTimeInput (static, no DB) --- // --- Runtime tests for ParseTimeInput (static, no DB) ---
[Fact]
public void ParseTimeInput_ShouldTreatInputAsMoscowTime()
{
var result = DiscordNewSessionHandler.ParseTimeInput("2026-06-01 15:00");
Assert.True(result.IsSuccess);
// 15:00 MSK = 12:00 UTC
Assert.Equal(12, result.Value.Hour);
Assert.Equal(0, result.Value.Minute);
Assert.Equal(TimeSpan.Zero, result.Value.Offset);
}
[Fact] [Fact]
public void ParseTimeInput_ShouldParseDiscordDateFormat() public void ParseTimeInput_ShouldParseDiscordDateFormat()
{ {
@@ -28,7 +39,8 @@ public sealed class DiscordNewSessionHandlerTests
Assert.Equal(expected.Year, result.Value.Year); Assert.Equal(expected.Year, result.Value.Year);
Assert.Equal(expected.Month, result.Value.Month); Assert.Equal(expected.Month, result.Value.Month);
Assert.Equal(expected.Day, result.Value.Day); Assert.Equal(expected.Day, result.Value.Day);
Assert.Equal(19, result.Value.Hour); // Input is treated as Moscow time; 19:30 MSK = 16:30 UTC
Assert.Equal(16, result.Value.Hour);
Assert.Equal(30, result.Value.Minute); Assert.Equal(30, result.Value.Minute);
} }
@@ -127,6 +139,18 @@ public sealed class DiscordNewSessionHandlerTests
Assert.Contains("RollbackAsync", source, StringComparison.Ordinal); Assert.Contains("RollbackAsync", source, StringComparison.Ordinal);
} }
[Fact]
public void Handler_ShouldNotRollbackCommittedTransactionAfterPostCommitFailure()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("transactionCommitted = false", source, StringComparison.Ordinal);
Assert.Contains("transactionCommitted = true", source, StringComparison.Ordinal);
Assert.Contains("if (!transactionCommitted)", source, StringComparison.Ordinal);
}
[Fact] [Fact]
public void Handler_ShouldRespectCancellationToken() public void Handler_ShouldRespectCancellationToken()
{ {
@@ -145,7 +169,30 @@ public sealed class DiscordNewSessionHandlerTests
var source = File.ReadAllText(commandPath); var source = File.ReadAllText(commandPath);
Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal); Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal);
Assert.Contains("WithEmbeds", source, StringComparison.Ordinal); Assert.Contains("message.Embeds = embeds", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldLeaveScheduleMessageCreationToInteractionResponse()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.DoesNotContain("SendScheduleAsync", source, StringComparison.Ordinal);
Assert.DoesNotContain("PlatformScheduleMessage", source, StringComparison.Ordinal);
}
[Fact]
public void Handler_ShouldStoreReadableDiscordGroupNameForWebCards()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
var source = File.ReadAllText(handlerPath);
Assert.Contains("groupName", source, StringComparison.Ordinal);
Assert.Contains("displayGroupName", source, StringComparison.Ordinal);
Assert.Contains("VALUES (@GroupName, 'Discord'", source, StringComparison.Ordinal);
} }
private static DateTimeOffset FutureDateAt1930() private static DateTimeOffset FutureDateAt1930()
@@ -40,6 +40,7 @@ public sealed class DiscordProjectStructureTests
Assert.Contains("GmRelay.Shared.csproj", project); Assert.Contains("GmRelay.Shared.csproj", project);
Assert.DoesNotContain("Telegram.Bot", project); Assert.DoesNotContain("Telegram.Bot", project);
Assert.DoesNotContain("GmRelay.Bot.csproj", project); Assert.DoesNotContain("GmRelay.Bot.csproj", project);
Assert.Contains("Dapper.AOT", project);
} }
[Fact] [Fact]
@@ -61,7 +62,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:3.0.5", compose); Assert.Contains("gmrelay-discord-bot:3.2.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 +76,13 @@ public sealed class DiscordProjectStructureTests
{ {
var repoRoot = GetRepoRoot(); var repoRoot = GetRepoRoot();
Assert.Contains("<Version>3.0.5</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); Assert.Contains("<Version>3.2.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 3.0.5", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); Assert.Contains("VERSION: 3.2.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:3.0.5", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains("gmrelay-bot:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:3.0.5", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains("gmrelay-web:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:3.0.5", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains("gmrelay-discord-bot:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains( Assert.Contains(
"v3.0.5", "v3.2.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")));
} }
@@ -21,6 +21,26 @@ public sealed class DiscordSessionInteractionModuleSourceTests
Assert.Contains("MessageFlags.Ephemeral", source, StringComparison.Ordinal); Assert.Contains("MessageFlags.Ephemeral", source, StringComparison.Ordinal);
} }
[Fact]
public async Task Module_ShouldUpdateSourceScheduleMessageThroughComponentInteraction()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
Assert.Contains("InteractionCallback.DeferredModifyMessage", source, StringComparison.Ordinal);
Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal);
Assert.Contains("FollowupAsync", source, StringComparison.Ordinal);
Assert.Contains("CompleteScheduleUpdateResponseAsync", source, StringComparison.Ordinal);
}
[Fact]
public async Task Module_ShouldRouteDeleteSessionButtons()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
Assert.Contains("[ComponentInteraction(\"delete_session\")]", source, StringComparison.Ordinal);
Assert.Contains("DiscordDeleteSessionHandler", source, StringComparison.Ordinal);
}
[Fact] [Fact]
public async Task Module_ShouldRouteRsvpButtonsToNeutralHandler() public async Task Module_ShouldRouteRsvpButtonsToNeutralHandler()
{ {
@@ -1,5 +1,5 @@
using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession; using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using GmRelay.DiscordBot.Rendering; using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Features.Sessions.RescheduleSession;
@@ -69,14 +69,14 @@ public sealed class DiscordLandingPromisesSmokeTests
}; };
var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero); var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero);
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId); var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
var voteMessage = HandleRescheduleTimeInputHandler.BuildVotingMessage( var voteMessage = BotRescheduleHandler.BuildVotingMessage(
scenario.Title, scenario.Title,
scenario.Sessions[0].ScheduledAt, scenario.Sessions[0].ScheduledAt,
deadline, deadline,
options, options,
voteParticipants, voteParticipants,
[]); []);
var voteKeyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options); var voteKeyboard = BotRescheduleHandler.BuildVotingKeyboard(options);
Assert.Contains("Landing Promise Smoke", voteMessage); Assert.Contains("Landing Promise Smoke", voteMessage);
Assert.Contains("0/2", voteMessage); Assert.Contains("0/2", voteMessage);
@@ -1,5 +1,5 @@
using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession; using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
@@ -65,14 +65,14 @@ public sealed class TelegramLandingPromisesSmokeTests
}; };
var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero); var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero);
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId); var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
var voteMessage = HandleRescheduleTimeInputHandler.BuildVotingMessage( var voteMessage = BotRescheduleHandler.BuildVotingMessage(
scenario.Title, scenario.Title,
scenario.Sessions[0].ScheduledAt, scenario.Sessions[0].ScheduledAt,
deadline, deadline,
options, options,
voteParticipants, voteParticipants,
[]); []);
var voteKeyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options); var voteKeyboard = BotRescheduleHandler.BuildVotingKeyboard(options);
Assert.Contains("Landing Promise Smoke", voteMessage); Assert.Contains("Landing Promise Smoke", voteMessage);
Assert.Contains("0/2", voteMessage); Assert.Contains("0/2", voteMessage);
@@ -0,0 +1,36 @@
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class CreateSessionCommandContractTests
{
[Fact]
public void CreateSessionCommand_ShouldExposePlatformNeutralContext()
{
AssertProperty<CreateSessionCommand>("User", typeof(PlatformUser));
AssertProperty<CreateSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<CreateSessionCommand>("Title", typeof(string));
AssertProperty<CreateSessionCommand>("Link", typeof(string));
AssertProperty<CreateSessionCommand>("ScheduledTimes", typeof(IReadOnlyList<DateTimeOffset>));
AssertProperty<CreateSessionCommand>("MaxPlayers", typeof(int?));
AssertProperty<CreateSessionCommand>("ImageReference", typeof(string));
AssertNoTelegramSpecificProperties<CreateSessionCommand>();
}
private static void AssertProperty<T>(string name, Type expectedType)
{
var property = Assert.Single(typeof(T).GetProperties(), p => p.Name == name);
Assert.Equal(expectedType, property.PropertyType);
}
private static void AssertNoTelegramSpecificProperties<T>()
{
var names = typeof(T).GetProperties().Select(p => p.Name).ToArray();
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
Assert.DoesNotContain("ChatId", names);
Assert.DoesNotContain("MessageId", names);
Assert.DoesNotContain("TelegramUserId", names);
Assert.DoesNotContain("TelegramUsername", names);
}
}
@@ -0,0 +1,36 @@
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class CreateSessionHandlerTests
{
[Fact]
public async Task SharedHandler_ShouldExist_AndBePlatformNeutral()
{
var handler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs");
Assert.Contains("CreateSessionCommand", handler, StringComparison.Ordinal);
Assert.Contains("CreateSessionResult", handler, StringComparison.Ordinal);
Assert.Contains("command.User", handler, StringComparison.Ordinal);
Assert.Contains("command.Group", handler, StringComparison.Ordinal);
Assert.DoesNotContain("ITelegramBotClient", handler, StringComparison.Ordinal);
Assert.DoesNotContain("Telegram.Bot", handler, StringComparison.Ordinal);
Assert.DoesNotContain("InlineKeyboardMarkup", handler, StringComparison.Ordinal);
Assert.DoesNotContain("MessageThreadId", handler, 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}'.");
}
}
@@ -13,6 +13,7 @@ public sealed class PlatformNeutralSessionInteractionCommandTests
AssertProperty<JoinSessionCommand>("InteractionId", typeof(string)); AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup)); AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef)); AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertProperty<JoinSessionCommand>("DeferScheduleUpdate", typeof(bool));
AssertNoTelegramSpecificProperties<JoinSessionCommand>(); AssertNoTelegramSpecificProperties<JoinSessionCommand>();
} }
@@ -24,12 +25,29 @@ public sealed class PlatformNeutralSessionInteractionCommandTests
AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string)); AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup)); AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef)); AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertProperty<LeaveSessionCommand>("DeferScheduleUpdate", typeof(bool));
AssertNoTelegramSpecificProperties<LeaveSessionCommand>(); AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
} }
[Fact]
public void SessionInteractionResult_ShouldExposeReplyTextAndUpdatedView()
{
var resultType = typeof(JoinSessionCommand).Assembly.GetType(
"GmRelay.Shared.Features.Sessions.CreateSession.SessionInteractionResult");
Assert.NotNull(resultType);
AssertProperty(resultType, "ReplyText", typeof(string));
AssertProperty(resultType, "UpdatedView", typeof(GmRelay.Shared.Rendering.SessionBatchViewModel));
}
private static void AssertProperty<T>(string name, Type expectedType) private static void AssertProperty<T>(string name, Type expectedType)
{ {
var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name); AssertProperty(typeof(T), name, expectedType);
}
private static void AssertProperty(Type type, string name, Type expectedType)
{
var property = Assert.Single(type.GetProperties(), property => property.Name == name);
Assert.Equal(expectedType, property.PropertyType); Assert.Equal(expectedType, property.PropertyType);
} }
@@ -0,0 +1,31 @@
using GmRelay.Shared.Features.Sessions.ExportCalendar;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Features.Sessions.ExportCalendar;
public sealed class ExportCalendarCommandContractTests
{
[Fact]
public void ExportCalendarCommand_ShouldExposePlatformNeutralContext()
{
AssertProperty<ExportCalendarCommand>("Group", typeof(PlatformGroup));
AssertProperty<ExportCalendarCommand>("User", typeof(PlatformUser));
AssertNoTelegramSpecificProperties<ExportCalendarCommand>();
}
private static void AssertProperty<T>(string name, Type expectedType)
{
var property = Assert.Single(typeof(T).GetProperties(), p => p.Name == name);
Assert.Equal(expectedType, property.PropertyType);
}
private static void AssertNoTelegramSpecificProperties<T>()
{
var names = typeof(T).GetProperties().Select(p => p.Name).ToArray();
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
Assert.DoesNotContain("ChatId", names);
Assert.DoesNotContain("MessageId", names);
Assert.DoesNotContain("TelegramUserId", names);
Assert.DoesNotContain("TelegramUsername", names);
}
}
@@ -0,0 +1,41 @@
using GmRelay.Shared.Features.Sessions.ListSessions;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Features.Sessions.ListSessions;
public sealed class ListSessionsCommandContractTests
{
[Fact]
public void ListSessionsCommand_ShouldExposePlatformNeutralContext()
{
AssertProperty<ListSessionsCommand>("Group", typeof(PlatformGroup));
AssertProperty<ListSessionsCommand>("User", typeof(PlatformUser));
AssertNoTelegramSpecificProperties<ListSessionsCommand>();
}
[Fact]
public void DeleteSessionCommand_ShouldExposePlatformNeutralContext()
{
AssertProperty<DeleteSessionCommand>("SessionId", typeof(Guid));
AssertProperty<DeleteSessionCommand>("User", typeof(PlatformUser));
AssertProperty<DeleteSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<DeleteSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertNoTelegramSpecificProperties<DeleteSessionCommand>();
}
private static void AssertProperty<T>(string name, Type expectedType)
{
var property = Assert.Single(typeof(T).GetProperties(), p => p.Name == name);
Assert.Equal(expectedType, property.PropertyType);
}
private static void AssertNoTelegramSpecificProperties<T>()
{
var names = typeof(T).GetProperties().Select(p => p.Name).ToArray();
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
Assert.DoesNotContain("ChatId", names);
Assert.DoesNotContain("MessageId", names);
Assert.DoesNotContain("TelegramUserId", names);
Assert.DoesNotContain("TelegramUsername", names);
}
}
@@ -1,5 +1,6 @@
using GmRelay.Bot.Features.Sessions.ListSessions; using GmRelay.Bot.Features.Sessions.ListSessions;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.ListSessions;
namespace GmRelay.Bot.Tests.Features.Sessions.ListSessions; namespace GmRelay.Bot.Tests.Features.Sessions.ListSessions;
@@ -22,17 +23,15 @@ public sealed class SessionListMessageRendererTests
true) true)
}; };
var result = SessionListMessageRenderer.Render(sessions); var text = SessionListMessageRenderer.RenderText(sessions);
Assert.NotNull(result.Markup); var actions = SessionListMessageRenderer.RenderActions(sessions);
var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList();
Assert.Contains("Ravenloft", result.Text); Assert.Contains("Ravenloft", text);
Assert.Collection( Assert.Equal(4, actions.Count);
buttons.Select(button => button.CallbackData), Assert.Contains(actions, a => a.Payload == $"cancel_session:{sessionId}");
callbackData => Assert.Equal($"cancel_session:{sessionId}", callbackData), Assert.Contains(actions, a => a.Payload == $"reschedule_session:{sessionId}");
callbackData => Assert.Equal($"reschedule_session:{sessionId}", callbackData), Assert.Contains(actions, a => a.Payload == $"promote_waitlist:{sessionId}");
callbackData => Assert.Equal($"promote_waitlist:{sessionId}", callbackData), Assert.Contains(actions, a => a.Payload == $"delete_session:{sessionId}");
callbackData => Assert.Equal($"delete_session:{sessionId}", callbackData));
} }
[Fact] [Fact]
@@ -51,8 +50,7 @@ public sealed class SessionListMessageRendererTests
false) false)
}; };
var result = SessionListMessageRenderer.Render(sessions); var actions = SessionListMessageRenderer.RenderActions(sessions);
Assert.Empty(actions);
Assert.Null(result.Markup);
} }
} }
@@ -1,4 +1,4 @@
using GmRelay.Bot.Features.Sessions.RescheduleSession; using BotHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
@@ -72,7 +72,7 @@ public sealed class HandleRescheduleTimeInputHandlerTests
new(secondOptionId, bobId, "Bob", null) new(secondOptionId, bobId, "Bob", null)
}; };
var text = HandleRescheduleTimeInputHandler.BuildVotingMessage( var text = BotHandler.BuildVotingMessage(
"Shadowrun", "Shadowrun",
currentTime, currentTime,
deadline, deadline,
@@ -101,7 +101,7 @@ public sealed class HandleRescheduleTimeInputHandlerTests
new(secondOptionId, 2, new DateTimeOffset(2026, 4, 27, 17, 0, 0, TimeSpan.Zero)) new(secondOptionId, 2, new DateTimeOffset(2026, 4, 27, 17, 0, 0, TimeSpan.Zero))
}; };
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options); var keyboard = BotHandler.BuildVotingKeyboard(options);
var buttons = keyboard.InlineKeyboard.SelectMany(row => row).ToList(); var buttons = keyboard.InlineKeyboard.SelectMany(row => row).ToList();
Assert.Collection( Assert.Collection(
@@ -0,0 +1,43 @@
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
public sealed class RescheduleCommandContractTests
{
[Fact]
public void HandleRescheduleTimeInputCommand_ShouldExposePlatformNeutralContext()
{
AssertProperty<HandleRescheduleTimeInputCommand>("User", typeof(PlatformUser));
AssertProperty<HandleRescheduleTimeInputCommand>("Group", typeof(PlatformGroup));
AssertProperty<HandleRescheduleTimeInputCommand>("Text", typeof(string));
AssertNoTelegramSpecificProperties<HandleRescheduleTimeInputCommand>();
}
[Fact]
public void HandleRescheduleVoteCommand_ShouldExposePlatformNeutralContext()
{
AssertProperty<HandleRescheduleVoteCommand>("OptionId", typeof(Guid));
AssertProperty<HandleRescheduleVoteCommand>("User", typeof(PlatformUser));
AssertProperty<HandleRescheduleVoteCommand>("Group", typeof(PlatformGroup));
AssertProperty<HandleRescheduleVoteCommand>("InteractionId", typeof(string));
AssertProperty<HandleRescheduleVoteCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertNoTelegramSpecificProperties<HandleRescheduleVoteCommand>();
}
private static void AssertProperty<T>(string name, Type expectedType)
{
var property = Assert.Single(typeof(T).GetProperties(), p => p.Name == name);
Assert.Equal(expectedType, property.PropertyType);
}
private static void AssertNoTelegramSpecificProperties<T>()
{
var names = typeof(T).GetProperties().Select(p => p.Name).ToArray();
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
Assert.DoesNotContain("ChatId", names);
Assert.DoesNotContain("MessageId", names);
Assert.DoesNotContain("TelegramUserId", names);
Assert.DoesNotContain("TelegramUsername", names);
}
}
@@ -58,7 +58,7 @@ public sealed class PlatformIdentityMigrationTests
[Fact] [Fact]
public async Task Code_ShouldQueryPlayersUsingExternalUserIdFallback() public async Task Code_ShouldQueryPlayersUsingExternalUserIdFallback()
{ {
var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs"); var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs");
Assert.Contains("external_user_id", createHandler, StringComparison.Ordinal); Assert.Contains("external_user_id", createHandler, StringComparison.Ordinal);
} }
@@ -66,7 +66,7 @@ public sealed class PlatformIdentityMigrationTests
[Fact] [Fact]
public async Task Code_ShouldQueryGroupsUsingExternalGroupIdFallback() public async Task Code_ShouldQueryGroupsUsingExternalGroupIdFallback()
{ {
var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs"); var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs");
Assert.Contains("external_group_id", createHandler, StringComparison.Ordinal); Assert.Contains("external_group_id", createHandler, StringComparison.Ordinal);
} }
@@ -91,6 +91,15 @@ public sealed class PlatformIdentityMigrationTests
Assert.Contains("platform", service, StringComparison.Ordinal); Assert.Contains("platform", service, StringComparison.Ordinal);
} }
[Fact]
public async Task WebSessionService_ShouldAuthorizeGroupsAcrossLinkedIdentities()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
Assert.Contains("_ResolveLinkedPlayerIdsAsync", service, StringComparison.Ordinal);
Assert.Contains("player_id = ANY(@PlayerIds)", service, StringComparison.Ordinal);
}
[Fact] [Fact]
public async Task AttendanceStatsFunction_ShouldReferenceExternalUsername() public async Task AttendanceStatsFunction_ShouldReferenceExternalUsername()
{ {
@@ -108,6 +117,28 @@ public sealed class PlatformIdentityMigrationTests
Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal); Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal);
} }
[Fact]
public async Task MigrationV024_ShouldDeprecateTelegramColumns()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V024__deprecate_telegram_columns.sql");
Assert.Contains("UPDATE players", migration, StringComparison.Ordinal);
Assert.Contains("UPDATE game_groups", migration, StringComparison.Ordinal);
Assert.Contains("DEPRECATED", migration, StringComparison.Ordinal);
Assert.Contains("calendar_subscriptions", migration, StringComparison.Ordinal);
Assert.Contains("user_platform", migration, StringComparison.Ordinal);
Assert.Contains("user_external_id", migration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV025_ShouldBackfillRescheduleProposals()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V025__reschedule_proposals_telegram_external.sql");
Assert.Contains("reschedule_proposals", migration, StringComparison.Ordinal);
Assert.Contains("proposed_by_external_user_id", migration, 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);
@@ -149,6 +149,68 @@ public sealed class SessionSchedulerServiceTests
Assert.Null(ex); Assert.Null(ex);
} }
[Fact]
public async Task TickAsync_WhenHandlerThrows_BackoffsForDuration()
{
var sessionId = Guid.NewGuid();
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
_clock.UtcNow = now;
_store.SessionsNeedingConfirmation = [sessionId];
_confirmationHandler.ThrowFor.Add(sessionId);
var sut = CreateSut();
await sut.TickAsync(CancellationToken.None);
Assert.Single(_confirmationHandler.Calls);
// Second tick immediately — should be backed off
await sut.TickAsync(CancellationToken.None);
Assert.Single(_confirmationHandler.Calls);
}
[Fact]
public async Task TickAsync_WhenHandlerThrows_AfterBackoffRetriesAgain()
{
var sessionId = Guid.NewGuid();
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
_clock.UtcNow = now;
_store.SessionsNeedingConfirmation = [sessionId];
_confirmationHandler.ThrowFor.Add(sessionId);
var sut = CreateSut();
await sut.TickAsync(CancellationToken.None);
Assert.Single(_confirmationHandler.Calls);
// Advance clock past backoff duration (15 min)
_clock.UtcNow = now.AddMinutes(16);
await sut.TickAsync(CancellationToken.None);
Assert.Equal(2, _confirmationHandler.Calls.Count);
}
[Fact]
public async Task TickAsync_WhenHandlerSucceedsAfterBackoff_ClearsBackoff()
{
var sessionId = Guid.NewGuid();
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
_clock.UtcNow = now;
_store.SessionsNeedingConfirmation = [sessionId];
_confirmationHandler.ThrowFor.Add(sessionId);
var sut = CreateSut();
await sut.TickAsync(CancellationToken.None);
Assert.Single(_confirmationHandler.Calls);
// Remove throw condition, advance past backoff
_confirmationHandler.ThrowFor.Remove(sessionId);
_clock.UtcNow = now.AddMinutes(16);
await sut.TickAsync(CancellationToken.None);
Assert.Equal(2, _confirmationHandler.Calls.Count);
// Next tick should still call because backoff was cleared on success
_clock.UtcNow = now.AddMinutes(17);
await sut.TickAsync(CancellationToken.None);
Assert.Equal(3, _confirmationHandler.Calls.Count);
}
private sealed class FakeSendConfirmationHandler : ISendConfirmationHandler private sealed class FakeSendConfirmationHandler : ISendConfirmationHandler
{ {
public List<Guid> Calls { get; } = []; public List<Guid> Calls { get; } = [];
@@ -20,12 +20,13 @@ public sealed class TelegramPlatformMessengerSourceTests
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs")] [InlineData("src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs", "src/GmRelay.Shared/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs")]
public async Task SessionFlows_ShouldUsePlatformMessengerForOutboundTelegramWork(string relativePath) public async Task SessionFlows_ShouldUsePlatformMessengerForOutboundTelegramWork(string relativePath, string? sharedPath = null)
{ {
var source = await ReadRepositoryFileAsync(relativePath); var source = await ReadRepositoryFileAsync(relativePath);
var sharedSource = sharedPath is not null ? await ReadRepositoryFileAsync(sharedPath) : string.Empty;
Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal); Assert.Contains("IPlatformMessenger", source + sharedSource, StringComparison.Ordinal);
Assert.DoesNotContain("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal); Assert.DoesNotContain("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal);
Assert.DoesNotContain(".AnswerCallbackQuery(", source, StringComparison.Ordinal); Assert.DoesNotContain(".AnswerCallbackQuery(", source, StringComparison.Ordinal);
} }
@@ -12,11 +12,11 @@ public sealed class TelegramTopicIntegrationSmokeTests
Assert.Contains("topic_created_by_bot", migration, StringComparison.Ordinal); Assert.Contains("topic_created_by_bot", migration, StringComparison.Ordinal);
Assert.Contains("ResolveNewScheduleDestination", createHandler, StringComparison.Ordinal); Assert.Contains("ResolveNewScheduleDestination", createHandler, StringComparison.Ordinal);
Assert.Contains("message.MessageThreadId", createHandler, StringComparison.Ordinal); Assert.Contains("message.MessageThreadId", createHandler, StringComparison.Ordinal);
Assert.Contains("topic_created_by_bot", createHandler, StringComparison.Ordinal); Assert.Contains("topicCreatedByBot", createHandler, StringComparison.Ordinal);
Assert.Contains("MissingForumTopicRightsMessage", createHandler, StringComparison.Ordinal); Assert.Contains("MissingForumTopicRightsMessage", createHandler, StringComparison.Ordinal);
Assert.Contains("TopicCreatedByBot", deleteHandler, StringComparison.Ordinal); Assert.Contains("TopicCreatedByBot", deleteHandler, StringComparison.Ordinal);
Assert.Contains("ShouldDeleteForumTopic", deleteHandler, StringComparison.Ordinal); Assert.Contains("ShouldDeleteForumTopic", deleteHandler, StringComparison.Ordinal);
Assert.Contains("remainingInTopic", deleteHandler, StringComparison.Ordinal); Assert.Contains("RemainingInTopic", deleteHandler, StringComparison.Ordinal);
} }
[Fact] [Fact]
@@ -28,6 +28,7 @@ public sealed class TelegramTopicIntegrationSmokeTests
var cancelHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs"); var cancelHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs");
var initiateRescheduleHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs"); var initiateRescheduleHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs");
var rescheduleInputHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs"); var rescheduleInputHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs");
var sharedRescheduleInputHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs");
var rescheduleDeadlineService = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs"); var rescheduleDeadlineService = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs");
var telegramMessenger = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs"); var telegramMessenger = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs");
@@ -48,9 +49,9 @@ public sealed class TelegramTopicIntegrationSmokeTests
Assert.Contains("int? MessageThreadId", initiateRescheduleHandler, StringComparison.Ordinal); Assert.Contains("int? MessageThreadId", initiateRescheduleHandler, StringComparison.Ordinal);
Assert.Contains("TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId)", initiateRescheduleHandler, StringComparison.Ordinal); Assert.Contains("TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId)", initiateRescheduleHandler, StringComparison.Ordinal);
Assert.Contains("int? ThreadId", rescheduleInputHandler, StringComparison.Ordinal); Assert.Contains("message.MessageThreadId", rescheduleInputHandler, StringComparison.Ordinal);
Assert.Contains("s.thread_id AS ThreadId", rescheduleInputHandler, StringComparison.Ordinal); Assert.Contains("s.thread_id AS ThreadId", sharedRescheduleInputHandler, StringComparison.Ordinal);
Assert.Contains("TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId)", rescheduleInputHandler, StringComparison.Ordinal); Assert.Contains("TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId", rescheduleInputHandler, StringComparison.Ordinal);
Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
@@ -60,6 +61,19 @@ public sealed class TelegramTopicIntegrationSmokeTests
Assert.Contains("ExternalThreadId", telegramMessenger, StringComparison.Ordinal); Assert.Contains("ExternalThreadId", telegramMessenger, StringComparison.Ordinal);
} }
[Fact]
public async Task WebTemplateBatches_ShouldCreateAndPersistForumTopic()
{
var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
Assert.Contains("GetChat", sessionService, StringComparison.Ordinal);
Assert.Contains("CreateForumTopic", sessionService, StringComparison.Ordinal);
Assert.Contains("thread_id, topic_created_by_bot", sessionService, StringComparison.Ordinal);
Assert.Contains("ThreadId = messageThreadId", sessionService, StringComparison.Ordinal);
Assert.Contains("TopicCreatedByBot = topicCreatedByBot", sessionService, StringComparison.Ordinal);
Assert.Contains("messageThreadId: messageThreadId", sessionService, 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);
@@ -176,6 +176,32 @@ public sealed class DiscordSessionBatchRendererTests
Assert.Equal("https://example.com/game", embeds[0].Url); Assert.Equal("https://example.com/game", embeds[0].Url);
} }
[Fact]
public void Render_ShouldNormalizeBareDomainJoinLinkForEmbedUrl()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "mobaxterm.mobatek.net/game") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
var (embeds, _) = DiscordSessionBatchRenderer.Render(view);
Assert.Equal("https://mobaxterm.mobatek.net/game", embeds[0].Url);
}
[Fact]
public void Render_ShouldNotSetEmbedUrlWhenJoinLinkIsNotHttpUrl()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "test") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
var (embeds, _) = DiscordSessionBatchRenderer.Render(view);
Assert.Null(embeds[0].Url);
}
[Fact] [Fact]
public void Render_ShouldEmbedCorrectFieldValues() public void Render_ShouldEmbedCorrectFieldValues()
{ {
@@ -0,0 +1,35 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class HomePageSourceTests
{
[Fact]
public async Task HomePage_ShouldShowPlatformBadgeForGroups()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Home.razor");
Assert.Contains("platform-badge", source, StringComparison.Ordinal);
Assert.Contains("FormatPlatform", source, StringComparison.Ordinal);
Assert.Contains("group.Platform", source, StringComparison.Ordinal);
}
[Fact]
public async Task SessionService_ShouldUseSessionTitleWhenDiscordGroupNameIsOnlyId()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
Assert.Contains("latest_session.title", source, StringComparison.Ordinal);
Assert.Contains("NULLIF(g.name, g.external_group_id)", source, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
{
dir = Directory.GetParent(dir)?.FullName;
}
var repoRoot = dir ?? throw new InvalidOperationException("Could not find repo root");
return await File.ReadAllTextAsync(Path.Combine(repoRoot, relativePath));
}
}
+7 -6
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.5.0, )", "GmRelay.ServiceDefaults": "[3.0.9, )",
"GmRelay.Shared": "[2.5.0, )", "GmRelay.Shared": "[3.0.9, )",
"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,9 @@
"dependencies": { "dependencies": {
"Aspire.Npgsql": "[13.2.2, )", "Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )", "Dapper": "[2.1.72, )",
"GmRelay.ServiceDefaults": "[2.5.0, )", "Dapper.AOT": "[1.0.48, )",
"GmRelay.Shared": "[2.5.0, )", "GmRelay.ServiceDefaults": "[3.0.9, )",
"GmRelay.Shared": "[3.0.9, )",
"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, )",
@@ -436,8 +437,8 @@
"dependencies": { "dependencies": {
"Aspire.Npgsql": "[13.2.2, )", "Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )", "Dapper": "[2.1.72, )",
"GmRelay.ServiceDefaults": "[2.5.0, )", "GmRelay.ServiceDefaults": "[3.0.9, )",
"GmRelay.Shared": "[2.5.0, )", "GmRelay.Shared": "[3.0.9, )",
"Npgsql": "[10.0.2, )", "Npgsql": "[10.0.2, )",
"Telegram.Bot": "[22.9.6.1, )" "Telegram.Bot": "[22.9.6.1, )"
} }