Compare commits

...

20 Commits

Author SHA1 Message Date
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
52 changed files with 1268 additions and 362 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 3.0.5 VERSION: 3.1.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.1.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.1.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.1.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.1.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
@@ -77,16 +77,15 @@ public sealed class CreateSessionHandler(
{ {
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
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 (@TgId, @Name, @Username, 'Telegram', @TgId::TEXT, @Username) VALUES (@Name, 'Telegram', @ExternalId, @Username)
ON CONFLICT (telegram_id) DO UPDATE 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, SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username, external_username = EXCLUDED.external_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 }, new { ExternalId = gmId.ToString(), Name = gmName, Username = gmUsername },
transaction); transaction);
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>( var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
@@ -97,12 +96,14 @@ public sealed class CreateSessionHandler(
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 = g.id WHERE gm.group_id = g.id
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalGmId
) AS CanManage ) AS CanManage
FROM game_groups g FROM game_groups g
WHERE COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) = @ChatId::TEXT WHERE g.platform = 'Telegram'
AND g.external_group_id = @ExternalChatId
""", """,
new { ChatId = chatId, GmId = gmId }, new { ExternalChatId = chatId.ToString(), ExternalGmId = gmId.ToString() },
transaction); transaction);
Guid groupId; Guid groupId;
@@ -110,11 +111,11 @@ public sealed class CreateSessionHandler(
{ {
groupId = await connection.ExecuteScalarAsync<Guid>( groupId = await connection.ExecuteScalarAsync<Guid>(
""" """
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id) INSERT INTO game_groups (name, platform, external_group_id)
VALUES (@ChatId, @ChatName, @GmId, 'Telegram', @ChatId::TEXT) VALUES (@ChatName, 'Telegram', @ExternalChatId)
RETURNING id; RETURNING id;
""", """,
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId }, new { ExternalChatId = chatId.ToString(), ChatName = chatTitle },
transaction); transaction);
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -122,10 +123,11 @@ public sealed class CreateSessionHandler(
INSERT INTO group_managers (group_id, player_id, role) INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole SELECT @GroupId, p.id, @OwnerRole
FROM players p FROM players p
WHERE COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT WHERE p.platform = 'Telegram'
AND p.external_user_id = @ExternalGmId
ON CONFLICT (group_id, player_id) DO NOTHING ON CONFLICT (group_id, player_id) DO NOTHING
""", """,
new { GroupId = groupId, GmId = gmId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }, new { GroupId = groupId, ExternalGmId = gmId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction); transaction);
} }
else else
@@ -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
@@ -24,11 +24,12 @@ public sealed class ExportCalendarHandler(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt" @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
+ " 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"
+ " WHERE g.telegram_chat_id = @ChatId" + " WHERE g.platform = 'Telegram'"
+ " AND g.external_group_id = @ExternalChatId"
+ " AND s.status = @Planned" + " AND s.status = @Planned"
+ " AND s.scheduled_at > NOW()" + " AND s.scheduled_at > NOW()"
+ " ORDER BY s.scheduled_at ASC", + " ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned }); new { ExternalChatId = message.Chat.Id.ToString(), Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -75,13 +76,13 @@ public sealed class ExportCalendarHandler(
{ {
var token = Guid.NewGuid().ToString("N"); var token = Guid.NewGuid().ToString("N");
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>( var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
@"SELECT id FROM game_groups WHERE telegram_chat_id = @ChatId", @"SELECT id FROM game_groups WHERE platform = 'Telegram' AND external_group_id = @ExternalChatId",
new { ChatId = message.Chat.Id }); new { ExternalChatId = message.Chat.Id.ToString() });
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, 'Telegram', @userExternalId, @groupId, @filterType, now(), NULL)",
new { token, userTelegramId = senderId.Value, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup }); new { token, userExternalId = senderId.Value.ToString(), groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics"; subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
} }
@@ -44,12 +44,13 @@ public sealed class DeleteSessionHandler(
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)
{ {
@@ -109,18 +110,22 @@ public sealed class DeleteSessionHandler(
FROM group_managers gm FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @TelegramUserId AND manager_player.platform = 'Telegram'
AND manager_player.external_user_id = @ExternalUserId
) AS CanManage ) AS CanManage
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.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW() WHERE g.platform = 'Telegram'
AND g.external_group_id = @ExternalChatId
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 GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new new
{ {
ChatId = command.ChatId, ExternalChatId = command.ChatId.ToString(),
command.TelegramUserId, ExternalUserId = command.TelegramUserId.ToString(),
Cancelled = SessionStatus.Cancelled, Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active, Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted Waitlisted = ParticipantRegistrationStatus.Waitlisted
@@ -74,18 +74,22 @@ public sealed class ListSessionsHandler(
FROM group_managers gm FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @TelegramUserId AND manager_player.platform = 'Telegram'
AND manager_player.external_user_id = @ExternalUserId
) AS CanManage ) AS CanManage
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.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW() WHERE g.platform = 'Telegram'
AND g.external_group_id = @ExternalChatId
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 GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new new
{ {
ChatId = message.Chat.Id, ExternalChatId = message.Chat.Id.ToString(),
TelegramUserId = message.From?.Id, ExternalUserId = message.From?.Id.ToString(),
Cancelled = SessionStatus.Cancelled, Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active, Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted Waitlisted = ParticipantRegistrationStatus.Waitlisted
@@ -53,26 +53,28 @@ public sealed class HandleRescheduleTimeInputHandler(
""" """
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt, 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, 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.thread_id AS ThreadId, s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode s.notification_mode AS NotificationMode
FROM reschedule_proposals rp FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE rp.proposed_by = @GmId WHERE rp.proposed_by_external_user_id = @ExternalGmId
AND rp.status = 'AwaitingTime' AND rp.status = 'AwaitingTime'
AND g.telegram_chat_id = @ChatId AND g.platform = 'Telegram'
AND g.external_group_id = @ExternalChatId
AND EXISTS ( AND EXISTS (
SELECT 1 SELECT 1
FROM group_managers gm FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @GmId AND manager_player.platform = 'Telegram'
AND manager_player.external_user_id = @ExternalGmId
) )
ORDER BY rp.created_at DESC ORDER BY rp.created_at DESC
LIMIT 1 LIMIT 1
""", """,
new { GmId = gmTelegramId, ChatId = chatId }); new { ExternalGmId = gmTelegramId.ToString(), ExternalChatId = chatId.ToString() });
if (proposal is null) if (proposal is null)
return false; return false;
@@ -92,8 +94,8 @@ public sealed class HandleRescheduleTimeInputHandler(
""" """
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
@@ -363,7 +365,7 @@ public sealed class HandleRescheduleTimeInputHandler(
""" """
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
@@ -58,11 +58,12 @@ public sealed class HandleRescheduleVoteHandler(
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 p.telegram_id = @TelegramUserId AND p.platform = 'Telegram'
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
""", """,
new { proposal.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active }, new { proposal.SessionId, ExternalUserId = command.TelegramUserId.ToString(), Active = ParticipantRegistrationStatus.Active },
transaction); transaction);
if (playerId is null) if (playerId is null)
@@ -91,8 +92,8 @@ public sealed class HandleRescheduleVoteHandler(
""" """
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
@@ -120,7 +121,7 @@ public sealed class HandleRescheduleVoteHandler(
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
@@ -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
@@ -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;
@@ -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;
}
} }
@@ -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" />
@@ -98,17 +98,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 +152,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 +306,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)
+3
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,6 +56,7 @@ 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<DiscordRescheduleVoteHandler>(); builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
@@ -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
@@ -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);
}
} }
@@ -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);
} }
} }
} }
@@ -73,7 +73,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v3.0.5</div> <div class="nav-version">v3.1.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);
} }
+89 -44
View File
@@ -104,24 +104,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 +151,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 +172,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 +182,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 +201,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 +213,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 +238,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 +257,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 +356,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 +394,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 +453,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 +539,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 +638,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 +665,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 +686,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 +871,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 +955,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 +1177,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);
@@ -1220,7 +1248,7 @@ public sealed class SessionService(
{ {
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 +1265,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 +1318,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 +1355,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 +1363,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 +1589,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.1.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.1.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.1.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.1.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.1.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.1.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains( Assert.Contains(
"v3.0.5", "v3.1.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()
{ {
@@ -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);
} }
@@ -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; } = [];
@@ -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, )"
} }