Compare commits

...

54 Commits

Author SHA1 Message Date
Toutsu 66dc53f12f fix(web): address PR review critical issues for Discord OAuth
PR Checks / test-and-build (pull_request) Successful in 6m6s
- Add V019 migration: rename session_audit_log.actor_telegram_id → actor_external_user_id
- Add CSRF protection to Discord OAuth flow (state cookie with HttpOnly/Secure/Strict)
- Add Discord OAuth env vars to compose.yaml, deploy.yml, and .env.example
- Fix SQL COALESCE for nullable telegram_id in GetGroupManagersAsync and GetSessionParticipantsAsync

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 12:07:40 +03:00
Toutsu 50f5307aac feat(web): finalize Discord OAuth and platform-agnostic auth
PR Checks / test-and-build (pull_request) Successful in 5m47s
- Bump version to 2.8.0 across all versioned files
- Fix AuthorizedSessionServiceTests for platform-agnostic identity
- Update Razor Pages to use *ForCurrentUserAsync APIs
- Add backward-compatible constructors to WebGameGroup/WebGroupManager
- Make DiscordOAuthOptions properties non-required for config binding

Bump version → 2.8.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:47:54 +03:00
Toutsu 5fa7e26f72 test(web): add Discord auth and platform identity tests
- DiscordAuthServiceTests: authorize URL, token exchange, profile fetch
- PlatformIdentityTests: Telegram fallback, Discord identity, avatar URL

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:13:08 +03:00
Toutsu 976e204102 feat(web): add Discord login button, platform indicator, and CSS styles
- Discord login button on /login with brand colors
- NavMenu shows user avatar (Discord) and platform label
- CSS: login-divider, login-btn-discord, nav-user-info, nav-user-platform
- NavMenu version bumped to v2.8.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:12:16 +03:00
Toutsu 9d4256353d feat(web): refactor SessionStore and AuthorizedSessionService to platform-agnostic identity
- ISessionStore: all methods use (platform, external_user_id)
- SessionService: updated SQL queries and added UpsertDiscordUserAsync
- AuthorizedSessionService: resolves identity from HttpContext, no longer accepts telegram_id params
- SessionAccessDeniedException now accepts string externalUserId
- Added ExternalUserId/ExternalUsername to WebGroupManager and WebParticipant

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:08:10 +03:00
Toutsu 543fc42a6d feat(web): add platform-agnostic identity extraction from ClaimsPrincipal
- TryGetPlatformIdentity returns (platform, external_user_id)
- TryGetDiscordId for Discord-specific flows
- Backward-compatible fallback for legacy Telegram auth without Platform claim
- GetAvatarUrl helper for Discord avatars

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:02:29 +03:00
Toutsu bfed400b4d feat(web): add Discord OAuth service and authorization endpoints
- DiscordOAuthOptions for client_id, secret, redirect_uri
- DiscordAuthService exchanges code for token and fetches user profile
- /auth/discord and /auth/discord/callback endpoints
- CreateDiscordPrincipal for cookie auth claims
- Telegram principal now includes Platform claim for forward compatibility

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:02:13 +03:00
Toutsu d0ddf3fb58 fix(bot): add missing using for DirectSessionNotificationSender
Deploy Telegram Bot / build-and-push (push) Successful in 5m50s
Deploy Telegram Bot / scan-images (push) Successful in 2m29s
Deploy Telegram Bot / deploy (push) Successful in 36s
2026-05-24 07:50:05 +03:00
Toutsu 654db04d44 fix(discord): use dotnet/aspnet:10.0-noble runtime image
Deploy Telegram Bot / build-and-push (push) Failing after 42s
Deploy Telegram Bot / scan-images (push) Has been skipped
Deploy Telegram Bot / deploy (push) Has been skipped
2026-05-24 07:48:05 +03:00
Toutsu 3a94becf05 fix(bot): register DirectSessionNotificationSender in DI
Deploy Telegram Bot / build-and-push (push) Failing after 44s
Deploy Telegram Bot / scan-images (push) Has been skipped
Deploy Telegram Bot / deploy (push) Has been skipped
2026-05-24 07:48:04 +03:00
Toutsu 31d8f59f1e ci(deploy): reduce healthcheck timeout 180s -> 40s
Deploy Telegram Bot / build-and-push (push) Failing after 48s
Deploy Telegram Bot / scan-images (push) Has been skipped
Deploy Telegram Bot / deploy (push) Has been skipped
2026-05-24 07:48:04 +03:00
Toutsu 31e08ba073 ci(deploy): reduce healthcheck timeout 180s → 40s
Deploy Telegram Bot / build-and-push (push) Successful in 34s
Deploy Telegram Bot / scan-images (push) Successful in 1m51s
Deploy Telegram Bot / deploy (push) Failing after 1m22s
2026-05-24 07:38:34 +03:00
Toutsu 7c8e14c44f feat(ci): wait for bot/discord/web healthcheck after deploy
Deploy Telegram Bot / build-and-push (push) Successful in 41s
Deploy Telegram Bot / scan-images (push) Successful in 1m45s
Deploy Telegram Bot / deploy (push) Failing after 3m46s
Add loop that polls docker compose ps for healthy state.
Timeout: 180s, interval: 5s.
Workflow now fails if any service doesn't become healthy.
2026-05-24 07:33:57 +03:00
Toutsu b57332bd5c chore: remove AI working directories (docs/superpowers, docs/plans) from repo
Deploy Telegram Bot / build-and-push (push) Successful in 32s
Deploy Telegram Bot / scan-images (push) Successful in 1m45s
Deploy Telegram Bot / deploy (push) Successful in 15s
Add docs/superpowers/, docs/plans/, *.diff to .gitignore.
These directories contain implementation plans and design specs
used during agentic development; they are not needed in source control.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:58:57 +03:00
Toutsu 73714c9525 docs(adr): add ADR-003 Discord Integration Architecture
Deploy Telegram Bot / build-and-push (push) Successful in 35s
Deploy Telegram Bot / scan-images (push) Successful in 1m57s
Deploy Telegram Bot / deploy (push) Successful in 14s
2026-05-21 18:40:30 +03:00
Toutsu 8319edda38 docs(adr-002): add links to issues #30-33 in related section
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:39:29 +03:00
Toutsu 5e1f0a00ad docs(adr-001): add Discord Gateway + NetCord decision, update Aspire services 2026-05-21 18:38:36 +03:00
Toutsu 987013974c docs(c4): update container view for Discord worker and healthcheck 2026-05-21 18:37:22 +03:00
Toutsu 7249ca079d docs(readme): update for v2.7.2 — Discord features, env vars, structure
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:34:46 +03:00
Toutsu 7fac5926fc docs: add design spec for MVP2 documentation sync
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:19:08 +03:00
Toutsu 9f7b772680 Merge pull request #90: test: добавить регрессионные тесты platform rendering и Discord MVP interactions (issue #33)
Deploy Telegram Bot / build-and-push (push) Successful in 4m43s
Deploy Telegram Bot / scan-images (push) Successful in 1m55s
Deploy Telegram Bot / deploy (push) Successful in 16s
2026-05-21 17:51:51 +03:00
Toutsu 1853a7a9c7 chore(release): bump version to 2.7.2
PR Checks / test-and-build (pull_request) Successful in 8m3s
Issue: #33
2026-05-21 15:36:17 +03:00
Toutsu befb2da6a0 test: add DiscordLandingPromisesSmokeTests
Mirror TelegramLandingPromisesSmokeTests for Discord MVP:
- Join/leave/waitlist promotion via capacity rules
- Reschedule voting flow
- Direct message notifications on reschedule
- Dashboard batch update

Issue: #33
2026-05-21 15:34:03 +03:00
Toutsu d29c6c0725 test: extend DiscordSessionBatchRendererTests with regression cases
- Confirmed status blue color
- Empty player description
- Embed URL from join link
- Inline field values for capacity/waitlist/status

Issue: #33
2026-05-21 15:21:02 +03:00
Toutsu 47b22c7401 test: extend TelegramSessionBatchRendererTests with regression cases
- Empty sessions
- HTML encoding in title
- Confirmed status buttons
- No join link handling

Issue: #33
2026-05-21 15:19:08 +03:00
Toutsu b4a39c027f test: extend SessionBatchViewBuilderTests with edge cases
- Empty sessions
- Confirmed status
- Null MaxPlayers

Issue: #33
2026-05-21 15:11:01 +03:00
Toutsu dd9eab2e4a Merge pull request #89: chore: добавить compose/deploy wiring для Discord bot (issue #32)
Deploy Telegram Bot / build-and-push (push) Successful in 4m50s
Deploy Telegram Bot / scan-images (push) Successful in 1m51s
Deploy Telegram Bot / deploy (push) Successful in 15s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:51:35 +03:00
Toutsu 492d47a863 fix(discord): add wget to Dockerfile for healthcheck
PR Checks / test-and-build (pull_request) Successful in 7m30s
Issue #32
2026-05-21 14:40:45 +03:00
Toutsu fe8d5fe026 test(discord): update version assertions to 2.7.1
PR Checks / test-and-build (pull_request) Successful in 7m7s
Issue #32
2026-05-21 14:26:02 +03:00
Toutsu a2fa9aaa6c chore(release): bump version to 2.7.1
Issue #32
2026-05-21 14:24:17 +03:00
Toutsu 5b65ac4a2f chore(discord): add healthcheck to compose.yaml discord service
Issue #32
2026-05-21 14:21:35 +03:00
Toutsu feb3e08b63 feat(discord): register health check hosted service in Program.cs
Issue #32
2026-05-21 14:20:40 +03:00
Toutsu f1d8f56fec feat(discord): add health check hosted service
Issue #32
2026-05-21 14:19:50 +03:00
Toutsu 08ffc6694e chore(discord): add DISCORD_BOT_TOKEN to .env.example
Issue #32
2026-05-21 14:19:02 +03:00
Toutsu 3199c48fcd Merge pull request #88: feat(platform): route scheduler notifications through platform messenger
Deploy Telegram Bot / build-and-push (push) Successful in 6m18s
Deploy Telegram Bot / scan-images (push) Successful in 1m44s
Deploy Telegram Bot / deploy (push) Successful in 16s
2026-05-21 12:40:22 +03:00
Toutsu 2a707e4825 feat(platform): route scheduler notifications through platform messenger
PR Checks / test-and-build (pull_request) Successful in 7m9s
2026-05-21 12:30:35 +03:00
Toutsu 5dbec1a0a4 docs: add issue 31 implementation plan 2026-05-20 14:53:41 +03:00
Toutsu 7426000937 docs: add issue 31 platform notification design 2026-05-20 14:38:27 +03:00
Toutsu 0c62631ab6 Merge pull request #87: feat(discord): implement reschedule voting via Discord interactions (issue #30)
Deploy Telegram Bot / build-and-push (push) Successful in 4m37s
Deploy Telegram Bot / scan-images (push) Successful in 1m25s
Deploy Telegram Bot / deploy (push) Successful in 14s
Database:
- Add source_platform and proposed_by_external_user_id to reschedule_proposals
- Make proposed_by nullable for Discord proposals

Shared:
- Extract platform-neutral RescheduleVoteRules, RescheduleVotingInput, RescheduleDtos
- Create RescheduleVotingFinalizer for cross-platform deadline handling

Telegram:
- Refactor RescheduleVotingDeadlineService to use RescheduleVotingFinalizer
- Tag Telegram proposals with source_platform = 'Telegram'

Discord:
- /reschedule slash command with time options and deadline
- DiscordRescheduleVoteHandler for button interactions
- DiscordRescheduleVotingRenderer for embeds and buttons
- DiscordRescheduleVotingDeadlineService for automatic finalization
- DiscordSessionInteractionModule routing for vote buttons

Version: 2.5.0 -> 2.6.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 13:12:26 +03:00
Toutsu db9a931ed6 fix(shared): filter due proposals by source_platform to prevent cross-platform race
PR Checks / test-and-build (pull_request) Successful in 6m11s
Both Telegram and Discord deadline services were querying ALL due
proposals without filtering by source_platform. If the Telegram
service reached a Discord proposal first, it finalized the DB state
but skipped message handling. The Discord service then saw status
!= 'Voting' and never updated the Discord vote message.

Fix: GetDueProposalIdsAsync now accepts a sourcePlatform parameter
and filters at the DB level. Each service only processes its own
platform's proposals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:48:25 +03:00
Toutsu 35548a03cb test(discord): update version assertions to 2.6.0
PR Checks / test-and-build (pull_request) Successful in 6m27s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:05 +03:00
Toutsu dda393c372 chore: bump version to 2.6.0
Synchronized across Directory.Build.props, compose.yaml,
deploy.yml, and NavMenu.razor.

Bump version → 2.6.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:33:45 +03:00
Toutsu 1e9bf4ab25 feat(telegram): set source_platform = 'Telegram' on reschedule proposals
Ensures Telegram-initiated reschedule proposals are tagged with
source_platform so the platform-neutral finalizer can distinguish
them from Discord proposals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:33:24 +03:00
Toutsu 690aa0272f feat(discord): add reschedule voting deadline service 2026-05-20 12:29:33 +03:00
Toutsu d871f2c142 feat(discord): implement SendGroupMessageAsync in DiscordPlatformMessenger 2026-05-20 12:26:31 +03:00
Toutsu 9712fe125b feat(discord): add DiscordRescheduleVotingRenderer and replace inline helper 2026-05-20 12:23:25 +03:00
Toutsu fdfc73ae9c feat(discord): add reschedule vote button handler 2026-05-20 12:21:13 +03:00
Toutsu e93e777fb3 feat(discord): add /reschedule slash command and handler 2026-05-20 12:15:03 +03:00
Toutsu a13edf20af feat(shared): add RescheduleVotingFinalizer and ISystemClock 2026-05-20 11:54:53 +03:00
Toutsu fcd7de035f refactor(shared): extract reschedule voting types to Shared 2026-05-20 11:44:57 +03:00
Toutsu fb0c29eefe feat(db): add platform columns to reschedule_proposals 2026-05-20 11:41:25 +03:00
Toutsu 9ff5cc4a67 Merge pull request #86: feat(discord): enable session join leave buttons
Deploy Telegram Bot / build-and-push (push) Successful in 4m54s
Deploy Telegram Bot / scan-images (push) Successful in 1m22s
Deploy Telegram Bot / deploy (push) Successful in 15s
2026-05-20 09:09:51 +03:00
Toutsu 3251846001 fix(shared): enable dapper aot for session handlers
PR Checks / test-and-build (pull_request) Successful in 6m30s
2026-05-20 09:01:34 +03:00
Toutsu 39132be4e8 feat(discord): enable session join leave buttons
PR Checks / test-and-build (pull_request) Successful in 6m6s
Move neutral join/leave handlers into GmRelay.Shared so Telegram and Discord share capacity, waitlist, duplicate-click, and schedule-update behavior.

Add Discord component routing for join_session and leave_session buttons with deferred ephemeral replies and serialized schedule message updates.

Bump version to 2.5.0 and update Discord docs.

Refs #29
2026-05-19 14:13:48 +03:00
121 changed files with 5891 additions and 4775 deletions
+11
View File
@@ -10,6 +10,17 @@ TELEGRAM_BOT_USERNAME=YOUR_BOT_USERNAME_HERE
# Используется ботом для кнопки меню Telegram и кнопки /start.
TELEGRAM_MINI_APP_URL=
# Токен Discord application bot
# Можно получить в Discord Developer Portal (https://discord.com/developers/applications)
DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE
# Discord OAuth (для Web Dashboard)
# Client ID и Secret из OAuth2 раздела Discord Developer Portal
# Redirect URI должен указывать на /auth/discord/callback вашего домена
DISCORD_CLIENT_ID=YOUR_DISCORD_CLIENT_ID_HERE
DISCORD_CLIENT_SECRET=YOUR_DISCORD_CLIENT_SECRET_HERE
DISCORD_REDIRECT_URI=https://your-domain.example/auth/discord/callback
# Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=StrongPasswordForDatabase
+36 -3
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 2.4.0
VERSION: 2.8.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -113,14 +113,47 @@ jobs:
echo "DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}" >> .env
echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env
echo "TELEGRAM_MINI_APP_URL=${{ secrets.TELEGRAM_MINI_APP_URL }}" >> .env
echo "DISCORD_CLIENT_ID=${{ secrets.DISCORD_CLIENT_ID }}" >> .env
echo "DISCORD_CLIENT_SECRET=${{ secrets.DISCORD_CLIENT_SECRET }}" >> .env
echo "DISCORD_REDIRECT_URI=${{ secrets.DISCORD_REDIRECT_URI }}" >> .env
- name: Deploy Containers
run: |
# Авторизуемся локальным докером в нашей Gitea
docker login git.codeanddice.ru/ -u toutsu -p ${{ secrets.GIT_TOKEN }}
# Pull гарантирует, что мы получили нужную версию.
docker compose pull bot discord web
# Запускаем! Флаг -d оставит их работать в фоне.
docker compose up -d
# Ждём, пока сервисы перейдут в healthy или упадут
SERVICES="bot discord web"
MAX_WAIT=40
INTERVAL=5
ELAPSED=0
while [ $ELAPSED -lt $MAX_WAIT ]; do
NOT_HEALTHY=0
for svc in $SERVICES; do
HEALTH=$(docker compose ps $svc --format="{{.Health}}" 2>/dev/null | head -n1)
if [ "$HEALTH" != "healthy" ]; then
STATE=$(docker compose ps $svc --format="{{.State}}" 2>/dev/null | head -n1)
echo "❌ $svc not healthy yet (state: ${STATE:-unknown})"
NOT_HEALTHY=$((NOT_HEALTHY + 1))
fi
done
if [ $NOT_HEALTHY -eq 0 ]; then
echo "✅ All services are healthy!"
exit 0
fi
sleep $INTERVAL
ELAPSED=$((ELAPSED + INTERVAL))
done
echo "⏰ Timed out waiting for services to become healthy"
docker compose ps
exit 1
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>2.4.0</Version>
<Version>2.8.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+23
View File
@@ -0,0 +1,23 @@
# Discord /newsession и /listsessions — Issue #28
## Что реализовано
- Slash-команда /newsession для создания игровых сессий прямо из Discord.
- Slash-команда /listsessions для просмотра предстоящих игр в сервере.
- DiscordPermissionChecker — проверка прав (owner / admin / manager).
- DiscordPlatformMessenger — реализация IPlatformMessenger для Discord (NetCord REST).
- Полная интеграция в DI (Program.cs).
## Архитектура
- Vertical slice: каждая команда — отдельный файл (Command + Handler).
- Platform-agnostic SQL: используются колонки platform, external_group_id, external_user_id.
- Рендеринг переиспользует существующий DiscordSessionBatchRenderer.
## TDD
- 212 тестов, все зелёные.
- Source-level тесты проверяют паттерны: Dapper, Npgsql, транзакции, CancellationToken, платформенную нейтральность.
## Версия
- Minor bump: 2.3.0 → 2.4.0
- Синхронизировано: Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor.
Closes #28
+30 -8
View File
@@ -1,10 +1,10 @@
# 🎲 GM-Relay: TTRPG Session Scheduling Bot & Web Dashboard
**GM-Relay** — это комплексное решение для Мастеров Подземелий (ГМов), состоящее из высокопроизводительного Telegram-бота и удобного веб-интерфейса. Предназначено для автоматизации записи игроков на сессии, управления расписанием и проведения игр.
**GM-Relay** — это комплексное решение для Мастеров Подземелий (ГМов), состоящее из высокопроизводительного Telegram-бота, Discord worker и удобного веб-интерфейса. Предназначено для автоматизации записи игроков на сессии, управления расписанием и проведения игр.
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v2.2.0`.
**Текущая версия:** `v2.8.0`.
---
@@ -22,7 +22,15 @@
- **🔔 Уведомления**: Игрок получают за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
- **🕐 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в подключенных Telegram- и Discord-каналах.
### Discord Bot
- **Slash-команды `/newsession` и `/listsessions`**: GM создаёт сессии и публикует актуальное расписание прямо в Discord-канале.
- **Кнопки Join/Leave с ephemeral-ответами**: игроки нажимают Join/Leave в Discord-сообщении; бот отвечает ephemeral-сообщением и обновляет schedule message.
- **RSVP (подтверждения) за 24ч до сессии**: scheduler публикует запрос подтверждения в Discord-канале, игроки отвечают кнопками, а GM получает итоги RSVP.
- **DM-напоминания за 1ч и ссылки перед игрой**: one-hour reminders и join-link notifications отправляются в Discord DM при включённых личных уведомлениях; сбои DM логируются без публичного fallback.
- **Reschedule voting (голосование за перенос)**: deadline-сервис обновляет Discord vote message и schedule message через `IPlatformMessenger`.
- **Лимиты и waitlist**: при заполненном составе игрок попадает в waitlist, а при выходе участника первый ожидающий автоматически продвигается в основной состав.
### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через Telegram**: Telegram Login Widget с HMAC-SHA256 валидацией.
@@ -37,7 +45,7 @@
- **⬆️ Управление очередью**: Заполненность, лист ожидания и ручное повышение игрока из очереди.
- **📜 История изменений сессий**: Страница `/session/{id}/history` показывает аудит-лог всех значимых изменений (время, ссылка, название, участники, статус) с указанием акторов и дат.
- **📊 Статистика посещаемости**: Страница `/group/{id}/stats` показывает долю присутствия, количество пропусков и среднюю явку по каждому игроку группы.
- **🔄 Автосинхронизация**: Изменения в вебе мгновенно перерисовывают Telegram-сообщения расписания.
- **🔄 Автосинхронизация**: Изменения в вебе мгновенно перерисовывают platform message расписания через `IPlatformMessenger`.
---
@@ -47,7 +55,7 @@
|---|---|
| Язык | C# 14 (.NET 10) |
| Архитектура | Vertical Slice + общая библиотека `GmRelay.Shared` |
| Боты | Telegram.Bot (**Native AOT**), NetCord Gateway (Discord worker) |
| Боты | Telegram.Bot (**Native AOT**), NetCord Gateway (Discord worker внутри `GmRelay.Bot`) |
| Веб | Blazor Server |
| Оркестрация | .NET Aspire (`GmRelay.AppHost`) |
| БД | PostgreSQL |
@@ -77,6 +85,9 @@ TELEGRAM_BOT_TOKEN=ваш_токен_здесь
# Токен Discord application bot
DISCORD_BOT_TOKEN=ваш_discord_токен_здесь
# Client ID Discord application (используется для slash-команд)
DISCORD_BOT_CLIENT_ID=ваш_discord_client_id_здесь
# Имя бота без @ (для Telegram Login Widget)
TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
@@ -101,13 +112,15 @@ docker compose up -d
- создание Docker-сети и volume PostgreSQL;
- подъём PostgreSQL (`db:5432`);
- запуск бота с плавной миграцией (DbUp);
- запуск отдельного Discord Gateway worker на NetCord;
- запуск Discord Gateway worker на NetCord (healthcheck на `:8082`);
- запуск веб-приложения с подключением к БД и Telegram API.
### 3. Первоначальная настройка
1. Напишите боту `/start`.
2. Создайте группу через `/newgroup`.
3. Откройте Mini App или Web Dashboard для расширенного управления.
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` и `DISCORD_BOT_CLIENT_ID` в `.env`.
5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
## 💾 Backup и восстановление
@@ -154,8 +167,7 @@ BACKUP_VOLUME_NAME=game_pgbackups
```
├── src/
│ ├── GmRelay.AppHost/ # .NET Aspire orchestrator
│ ├── GmRelay.Bot/ # Telegram-бот (Native AOT)
│ ├── GmRelay.DiscordBot/ # Discord Gateway worker на NetCord
│ ├── GmRelay.Bot/ # Telegram- и Discord-бот (Native AOT + NetCord Gateway worker)
│ ├── GmRelay.ServiceDefaults/ # Aspire service defaults
│ ├── GmRelay.Shared/ # Общие доменные модели
│ └── GmRelay.Web/ # Blazor Server dashboard
@@ -167,6 +179,16 @@ BACKUP_VOLUME_NAME=game_pgbackups
---
## 👨‍💻 Для разработчиков
- **Архитектура**: проект следует Vertical Slice с явным DI. Подробности — в [ADR-001](docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md) и [ADR-002](docs/adr/002-platform-neutral-batch-rendering.md).
- **Добавление обработчика**: из-за Native AOT все DI-регистрации выполняются вручную в `src/GmRelay.Bot/Program.cs` (assembly scanning не используется).
- **Миграции**: SQL-скрипты добавляются как embedded resources в `src/GmRelay.Bot/Migrations/` и применяются автоматически при старте бота через DbUp.
- **Тесты**: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal`
- **Сборка**: `dotnet build`
---
## 📜 Лицензия
MIT License. См. [LICENSE](./LICENSE).
+23
View File
@@ -0,0 +1,23 @@
## 🛠 Patch 2.4.0 — Discord /newsession и /listsessions
Реализованы slash-команды Discord для создания сессий и просмотра расписания без Web Dashboard.
## 🧩 Что вошло в релиз
- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs — slash-команда /newsession с параметрами (title, time, seats, link)
- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs — handler создания batch + session в БД
- src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs — slash-команда /listsessions
- src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs — handler запроса активных сессий с embed-рендерингом
- src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs — проверка прав через Discord permissions bitflag (Administrator = 0x8)
- src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs — реализация IPlatformMessenger для Discord через NetCord REST
- src/GmRelay.DiscordBot/Program.cs — регистрация DI: handlers, permission checker, messenger
- ests/GmRelay.Bot.Tests/Discord/ — 20+ TDD-тестов на парсинг, права, структуру, DI, рендеринг
- Синхронизированы версии: Directory.Build.props, NavMenu.razor, compose.yaml, deploy.yml → 2.4.0
## 🗺 Что это даёт
- Мастера (GM) могут создавать сессии прямо из Discord, не заходя в Web.
- Участники сервера видят расписание через /listsessions.
- Единая PostgreSQL модель для Telegram и Discord — никакого дублирования данных.
## 📦 Версия и деплой
- версия обновлена до 2.4.0
- Docker-образы используют тег 2.4.0
+11 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.4.0
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.8.0
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.4.0
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.8.0
restart: always
depends_on:
db:
@@ -77,9 +77,14 @@ services:
- "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}"
networks:
- gmrelay
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8082/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:2.4.0
image: git.codeanddice.ru/toutsu/gmrelay-web:2.8.0
restart: always
depends_on:
db:
@@ -89,6 +94,9 @@ services:
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}"
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
- "Discord__ClientId=${DISCORD_CLIENT_ID:-}"
- "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}"
- "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}"
ports:
- "${GMRELAY_WEB_PORT:-8080}:8080"
volumes:
@@ -56,8 +56,18 @@ Aspire обеспечивает:
- Service discovery и передачу connection strings.
- OpenTelemetry (traces, metrics, logs) из коробки.
- Aspire Dashboard для мониторинга.
- **Три сервиса:** Bot (Telegram long polling + Discord Gateway), Web, PostgreSQL.
### 5. Telegram.Bot 22.x + Long Polling
### 5. Discord Gateway + NetCord
Discord-интеграция реализована через NetCord Gateway (не DSharpPlus) из-за:
- Нативной совместимости с .NET 10 и минимального размера зависимостей.
- Gateway events маршрутизируются в те же vertical slice handlers, что и Telegram updates.
- Slash-команды регистрируются через NetCord `ApplicationCommandService`.
Ephemeral-ответы (кнопки Join/Leave/RSVP) используют `InteractionMessageProperties` с `Flags = MessageFlags.Ephemeral`.
### 6. Telegram.Bot 22.x + Long Polling
- Long Polling — единственный вариант для Pi за NAT.
- Telegram.Bot поддерживает `System.Text.Json` source generators для AOT.
@@ -30,7 +30,7 @@ SessionBatchViewModel (platform-neutral)
├──► TelegramSessionBatchRenderer ──► HTML + InlineKeyboardMarkup
└──► DiscordSessionBatchRenderer ──► (issue #26)
└──► DiscordSessionBatchRenderer ──► Discord embeds + buttons
```
### Изменённые компоненты
@@ -41,7 +41,7 @@ SessionBatchViewModel (platform-neutral)
| `SessionBatchViewBuilder` | — | `GmRelay.Shared.Rendering` |
| `SessionBatchViewModel` | — | `GmRelay.Shared.Rendering` |
| `TelegramSessionBatchRenderer` | — | `GmRelay.Bot` + `GmRelay.Web` |
| `DiscordSessionBatchRenderer` | — | `GmRelay.Shared.Rendering` (stub) |
| `DiscordSessionBatchRenderer` | — | `GmRelay.DiscordBot.Rendering` |
| `BatchMessageEditor` | `GmRelay.Shared.Rendering` | `GmRelay.Bot` + `GmRelay.Web` |
## Consequences
@@ -49,7 +49,7 @@ SessionBatchViewModel (platform-neutral)
### Positive
- `GmRelay.Shared` больше не зависит от `Telegram.Bot`. Чистый platform-agnostic проект.
- Можно добавить `DiscordSessionBatchRenderer` без изменений в `Shared`.
- Discord renderer lives in `GmRelay.DiscordBot`, so NetCord stays out of `Shared`.
- Unit-тесты ViewBuilder не создают `InlineKeyboardMarkup`.
- Логика подсчёта игроков, сортировки сессий и генерации действий — в одном месте (ViewBuilder).
@@ -62,4 +62,8 @@ SessionBatchViewModel (platform-neutral)
- Issue #22 — этот рефакторинг.
- Issue #26 — Discord Bot MVP (потребитель новой архитектуры).
- Issue #30 — Discord reschedule voting использует `IPlatformMessenger`.
- Issue #31 — scheduler notifications и reschedule deadline updates через `IPlatformMessenger`.
- Issue #32 — compose wiring для Discord bot (healthcheck :8082).
- Issue #33 — регрессионные тесты platform rendering (Telegram + Discord).
- ADR 001 — vertical slice, native AOT, Aspire (`docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md`).
@@ -0,0 +1,57 @@
# ADR 003: Discord Integration Architecture
## Status
**Accepted** — implemented in v2.6.0 (PR #87, issue #30).
## Context
После Telegram-бота требовалась поддержка Discord для кросс-платформенных групп. Нужно было выбрать:
1. Библиотеку для Discord API (NetCord vs DSharpPlus vs Discord.NET).
2. Модель runtime (отдельный процесс vs тот же Worker).
3. Способ обработки интеракций (Gateway events vs HTTP interactions).
## Decision
### 1. NetCord (не DSharpPlus, не Discord.NET)
- **NetCord** — лёгкий, AOT-compatible, minimal dependencies.
- **DSharpPlus** — слишком тяжёлый, много зависимостей, reflection-heavy.
- **Discord.NET** — несовместим с Native AOT (heavy reflection, dynamic IL).
### 2. Gateway Events внутри GmRelay.Bot
- Discord Gateway worker живёт **внутри** `GmRelay.Bot` (тот же Worker Service), а не как отдельный проект.
- Это упрощает DI, shared DB connection, shared `IPlatformMessenger`.
- Для масштабирования можно вынести в отдельный контейнер позже.
### 3. Slash-команды через NetCord ApplicationCommandService
- Регистрация глобальных slash-команд (`/newsession`, `/listsessions`) через `ApplicationCommandService`.
- Команды мапятся на vertical slice handlers через `DiscordSessionInteractionModule`.
### 4. Ephemeral Replies
- Все кнопки (Join/Leave/RSVP) отвечают ephemeral (`MessageFlags.Ephemeral`).
- Schedule message редактируется через `DiscordPlatformMessenger` (реализация `IPlatformMessenger`).
## Consequences
### Positive
- Один бинарник для Telegram + Discord.
- Shared DI, shared DB pool, shared domain logic.
- Native AOT совместимость.
### Negative
- Gateway connection требует persistent WebSocket — при разрыве происходит reconnect.
- Discord rate limits агрессивнее Telegram — нужен backoff.
## Related
- Issue #30 — reschedule voting (кнопки + дедлайн).
- Issue #31 — scheduler notifications через `IPlatformMessenger`.
- Issue #32 — compose wiring + healthcheck.
- ADR 001 — Vertical Slice, Native AOT, Aspire.
- ADR 002 — Platform-Neutral Rendering.
+84 -39
View File
@@ -1,4 +1,4 @@
# GM-Relay C4 Model
# GM-Relay - C4 Model
## Level 1: System Context
@@ -6,19 +6,24 @@
C4Context
title GM-Relay System Context
Person(gm, "Game Master", "Создаёт сессии, управляет расписанием игр")
Person(player, "Player", "Подтверждает участие через inline-кнопки")
Person(gm, "Game Master", "Creates sessions and manages schedules")
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
System(gmrelay, "GM-Relay Bot", "Telegram Worker Service на Raspberry Pi. Управляет подтверждениями, рассылает напоминания и ссылки.")
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, and shared scheduling logic")
System_Ext(telegram, "Telegram Bot API", "Long Polling. Сообщения, inline keyboards, callback queries.")
SystemDb_Ext(postgres, "PostgreSQL", "Сессии, игроки, RSVP-статусы")
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities")
Rel(gm, telegram, "Команды бота (/newsession)")
Rel(player, telegram, "Нажимает кнопки (✅ Буду / ❌ Не смогу)")
Rel(telegram, gmrelay, "Updates (Long Polling)")
Rel(gm, telegram, "Creates and manages sessions")
Rel(gm, discord, "Uses /newsession and /listsessions")
Rel(player, telegram, "Uses inline buttons")
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
Rel(telegram, gmrelay, "Updates via long polling")
Rel(discord, gmrelay, "Gateway events and component interactions")
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
Rel(gmrelay, postgres, "SQL (Npgsql + Dapper)")
Rel(gmrelay, discord, "Send/edit schedule, RSVP, reminder, and reschedule messages")
Rel(gmrelay, postgres, "SQL via Npgsql and Dapper")
```
## Level 2: Container
@@ -30,49 +35,89 @@ C4Container
Person(gm, "Game Master")
Person(player, "Player")
System_Boundary(pi, "Raspberry Pi 5") {
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Long polling, обработка команд и callback queries, планировщик")
ContainerDb(db, "PostgreSQL 16", "Database", "sessions, players, session_participants, game_groups")
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
Container(discordBot, "Discord Gateway Worker", "Внутри GmRelay.Bot", "NetCord Gateway, slash commands, scheduler notifications, button interactions, healthcheck :8082")
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, editing and stats")
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, platform identities")
}
System_Ext(telegram, "Telegram Bot API")
System_Ext(discord, "Discord Gateway and REST API")
Rel(gm, telegram, "Commands")
Rel(player, telegram, "Callback Queries")
Rel(telegram, bot, "GetUpdates (Long Polling)")
Rel(gm, discord, "Slash commands")
Rel(player, telegram, "Callback queries")
Rel(player, discord, "Button interactions")
Rel(telegram, bot, "GetUpdates")
Rel(discord, discordBot, "Gateway events")
Rel(bot, telegram, "Bot API calls")
Rel(discordBot, discord, "REST send/edit/reply calls")
Rel(bot, shared, "Uses shared renderers and join/leave handlers")
Rel(discordBot, shared, "Uses shared renderers, scheduler, and platform-neutral handlers")
Rel(web, shared, "Uses shared domain and rendering models")
Rel(bot, db, "Npgsql + Dapper.AOT")
Rel(discordBot, db, "Npgsql + Dapper")
Rel(web, db, "Npgsql + Dapper")
```
## Level 3: Component (GmRelay.Bot)
## Level 3: Component - Session Interactions
```mermaid
C4Component
title GmRelay.Bot Components
title Platform-Neutral Session Interactions
Container_Boundary(bot, "GmRelay.Bot") {
Component(polling, "TelegramBotService", "BackgroundService", "Long polling loop, получает Updates")
Component(router, "UpdateRouter", "C#", "Маршрутизирует Update → Handler по типу")
Component(scheduler, "SessionSchedulerService", "BackgroundService", "PeriodicTimer(60s): T-24ч и T-5мин триггеры")
Component(migrator, "DbMigrator", "DbUp", "SQL миграции при старте")
Component(confirm, "SendConfirmationHandler", "Feature", "Отправляет inline keyboard за 24ч")
Component(rsvp, "HandleRsvpHandler", "Feature", "Обрабатывает ✅/❌, проверяет all-confirmed")
Component(link, "SendJoinLinkHandler", "Feature", "Отправляет join link за 5 мин")
Container_Boundary(shared, "GmRelay.Shared") {
Component(join, "JoinSessionHandler", "Feature handler", "Adds players as Active or Waitlisted with session row locking")
Component(leave, "LeaveSessionHandler", "Feature handler", "Removes players and promotes the first waitlisted player when capacity allows")
Component(rsvp, "HandleRsvpHandler", "Feature handler", "Updates RSVP state and emits platform-neutral RSVP outcomes")
Component(scheduler, "SessionSchedulerService", "Background service", "Triggers confirmation, reminder, and join-link notifications per platform")
Component(updateLock, "ScheduleMessageUpdateLock", "In-memory keyed lock", "Serializes DB changes and schedule message edits per platform message")
Component(renderer, "SessionBatchViewBuilder", "Renderer model builder", "Builds platform-neutral schedule views and actions")
}
System_Ext(telegram, "Telegram Bot API")
ContainerDb(db, "PostgreSQL")
Component(healthCheck, "DiscordHealthCheckHostedService", ":8082", "Healthcheck для Docker Compose")
Rel(polling, router, "Update")
Rel(router, rsvp, "CallbackQuery rsvp:*")
Rel(scheduler, confirm, "T-24h trigger")
Rel(scheduler, link, "T-5min trigger")
Rel(confirm, telegram, "SendMessage + InlineKeyboard")
Rel(rsvp, telegram, "EditMessage + AnswerCallback")
Rel(link, telegram, "SendMessage + user mentions")
Rel(confirm, db, "SELECT/UPDATE sessions")
Rel(rsvp, db, "UPDATE participants, SELECT counts")
Rel(link, db, "SELECT confirmed players")
Rel(migrator, db, "DDL migrations")
Container_Boundary(discordBot, "GmRelay.DiscordBot") {
Component(discordModule, "DiscordSessionInteractionModule", "NetCord component module", "Maps join_session/leave_session/rsvp buttons to neutral commands")
Component(discordMessenger, "DiscordPlatformMessenger", "IPlatformMessenger", "Sends and edits Discord schedule, RSVP, reminder, join-link, and reschedule messages")
}
Container_Boundary(bot, "GmRelay.Bot") {
Component(updateRouter, "UpdateRouter", "Telegram adapter", "Maps callback queries to neutral commands")
Component(telegramMessenger, "TelegramPlatformMessenger", "IPlatformMessenger", "Sends and edits Telegram schedule, RSVP, reminder, join-link, and reschedule messages")
}
ContainerDb(db, "PostgreSQL")
System_Ext(telegram, "Telegram Bot API")
System_Ext(discord, "Discord Gateway and REST API")
Rel(discord, discordModule, "Button interaction")
Rel(discordModule, join, "JoinSessionCommand")
Rel(discordModule, leave, "LeaveSessionCommand")
Rel(discordModule, rsvp, "HandleRsvpCommand")
Rel(discordModule, discord, "Deferred ephemeral reply, then modify response")
Rel(updateRouter, join, "JoinSessionCommand")
Rel(updateRouter, leave, "LeaveSessionCommand")
Rel(updateRouter, rsvp, "HandleRsvpCommand")
Rel(join, updateLock, "Acquire by PlatformMessageRef")
Rel(leave, updateLock, "Acquire by PlatformMessageRef")
Rel(join, db, "SELECT FOR UPDATE, INSERT participant")
Rel(leave, db, "SELECT FOR UPDATE, DELETE/promote participant")
Rel(rsvp, db, "Update RSVP and load notification recipients")
Rel(scheduler, db, "Load due session triggers")
Rel(join, renderer, "Build updated schedule view")
Rel(leave, renderer, "Build updated schedule view")
Rel(join, discordMessenger, "Update Discord schedule when command is Discord")
Rel(leave, discordMessenger, "Update Discord schedule when command is Discord")
Rel(join, telegramMessenger, "Update Telegram schedule when command is Telegram")
Rel(leave, telegramMessenger, "Update Telegram schedule when command is Telegram")
Rel(rsvp, discordMessenger, "Update Discord confirmation and outcomes")
Rel(rsvp, telegramMessenger, "Update Telegram confirmation and outcomes")
Rel(scheduler, discordMessenger, "Send Discord scheduler notifications")
Rel(scheduler, telegramMessenger, "Send Telegram scheduler notifications")
Rel(discordMessenger, discord, "REST send/edit/DM + ephemeral text")
Rel(telegramMessenger, telegram, "SendMessage/EditMessage + AnswerCallbackQuery")
Rel(healthCheck, discord, "HTTP /health")
```
@@ -1,438 +0,0 @@
# Player List + Kick + Waitlist Promotion Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Add a player list (with names) to the Web UI session views, allow GM to kick a specific player, and auto-promote the next waitlisted player.
**Architecture:** Extend `ISessionStore` with participant queries and a remove method. Update `GroupDetails.razor` to show expandable participant lists. Reuse existing `PromoteWaitlistedPlayerAsync` logic after removal.
**Tech Stack:** C# 14, Blazor SSR, Dapper, PostgreSQL
---
## Task 1: Add domain model for WebParticipant
**Objective:** Create a DTO to represent a session participant in the web layer.
**Files:**
- Modify: `src/GmRelay.Web/Services/SessionService.cs`
**Step 1: Add record**
```csharp
public sealed record WebParticipant(
Guid Id,
long TelegramId,
string DisplayName,
string? TelegramUsername,
string RsvpStatus,
string RegistrationStatus,
bool IsGm,
DateTime? RespondedAt);
```
**Step 2: Commit**
```bash
git add src/GmRelay.Web/Services/SessionService.cs
git commit -m "feat: add WebParticipant record"
```
---
## Task 2: Add GetSessionParticipantsAsync to ISessionStore
**Objective:** Retrieve all participants for a session with full player info.
**Files:**
- Modify: `src/GmRelay.Web/Services/ISessionStore.cs`
- Modify: `src/GmRelay.Web/Services/SessionService.cs`
- Modify: `src/GmRelay.Web/Services/AuthorizedSessionService.cs`
**Step 1: Add to interface**
In `ISessionStore.cs`, add:
```csharp
Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
```
**Step 2: Implement in SessionService**
In `SessionService.cs`, add:
```csharp
public async Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebParticipant>(
"""
SELECT sp.id AS Id,
p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm,
sp.responded_at AS RespondedAt
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
ORDER BY sp.is_gm DESC,
CASE sp.registration_status WHEN 'Active' THEN 0 ELSE 1 END,
sp.created_at
""",
new { SessionId = sessionId })).ToList();
}
```
**Step 3: Add authorized wrapper**
In `AuthorizedSessionService.cs`, add:
```csharp
public async Task<List<WebParticipant>?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
return null;
}
return await sessionStore.GetSessionParticipantsAsync(sessionId);
}
```
**Step 4: Commit**
```bash
git add src/GmRelay.Web/Services/ISessionStore.cs
git add src/GmRelay.Web/Services/SessionService.cs
git add src/GmRelay.Web/Services/AuthorizedSessionService.cs
git commit -m "feat: add GetSessionParticipantsAsync"
```
---
## Task 3: Add RemovePlayerFromSessionAsync with waitlist promotion
**Objective:** Allow GM to remove a specific player; auto-promote next waitlisted player if conditions met.
**Files:**
- Modify: `src/GmRelay.Web/Services/ISessionStore.cs`
- Modify: `src/GmRelay.Web/Services/SessionService.cs`
- Modify: `src/GmRelay.Web/Services/AuthorizedSessionService.cs`
**Step 1: Add to interface**
In `ISessionStore.cs`, add:
```csharp
Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);
```
**Step 2: Implement in SessionService**
In `SessionService.cs`, add:
```csharp
public async Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
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,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
FOR UPDATE",
new { SessionId = sessionId, GroupId = groupId },
transaction);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, 0);
}
// Verify participant exists in this session
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
"""
SELECT sp.id AS Id,
p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm,
sp.responded_at AS RespondedAt
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.id = @ParticipantId AND sp.session_id = @SessionId
""",
new { ParticipantId = participantId, SessionId = sessionId },
transaction);
if (participant is null)
{
throw new InvalidOperationException("Участник не найден в этой сессии.");
}
bool wasActive = participant.RegistrationStatus == ParticipantRegistrationStatus.Active;
await conn.ExecuteAsync(
"DELETE FROM session_participants WHERE id = @ParticipantId",
new { ParticipantId = participantId },
transaction);
WebPromotedParticipantDto? promoted = null;
if (wasActive)
{
promoted = await conn.QuerySingleOrDefaultAsync<WebPromotedParticipantDto>(
"""
SELECT sp.id AS ParticipantRowId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
ORDER BY sp.created_at ASC, sp.id ASC
LIMIT 1
FOR UPDATE OF sp
""",
new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
transaction);
if (promoted is not null)
{
await conn.ExecuteAsync(
"""
UPDATE session_participants
SET registration_status = @Active,
rsvp_status = @Pending,
responded_at = NULL
WHERE id = @ParticipantRowId
""",
new
{
promoted.ParticipantRowId,
Active = ParticipantRegistrationStatus.Active,
Pending = RsvpStatus.Pending
},
transaction);
}
}
await transaction.CommitAsync();
// Notifications
await bot.SendMessage(
session.TelegramChatId,
$"🚪 <b>{System.Net.WebUtility.HtmlEncode(participant.DisplayName)}</b> удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
if (promoted is not null)
{
await bot.SendMessage(
session.TelegramChatId,
$"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
}
if (session.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
}
}
```
**Step 3: Add authorized wrapper**
In `AuthorizedSessionService.cs`, add:
```csharp
public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, gmId);
}
await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId);
}
```
**Step 4: Commit**
```bash
git add src/GmRelay.Web/Services/ISessionStore.cs
git add src/GmRelay.Web/Services/SessionService.cs
git add src/GmRelay.Web/Services/AuthorizedSessionService.cs
git commit -m "feat: add RemovePlayerFromSessionAsync with waitlist promotion"
```
---
## Task 4: Modify GroupDetails.razor to show participant list
**Objective:** Add expandable player lists to each session row with kick buttons.
**Files:**
- Modify: `src/GmRelay.Web/Components/Pages/GroupDetails.razor`
**Step 1:** Add `participants` dictionary and `kickingParticipantId` state variables.
**Step 2:** Add `LoadParticipants(Guid sessionId)` and `KickParticipant(Guid sessionId, Guid participantId)` methods.
**Step 3:** In desktop table, add a new column or expand row with participant list.
**Step 4:** In mobile cards, add expandable participant section.
**Step 5:** Add styles to `app.css` if needed (badge styles are already present).
**Step 6:** Commit
```bash
git add src/GmRelay.Web/Components/Pages/GroupDetails.razor
git add src/GmRelay.Web/wwwroot/app.css
git commit -m "feat: show player list and kick button in GroupDetails"
```
---
## Task 5: Modify EditSession.razor to show participant list
**Objective:** Show participant list on the edit page with kick capability.
**Files:**
- Modify: `src/GmRelay.Web/Components/Pages/EditSession.razor`
**Step 1:** Load participants in `OnInitializedAsync`.
**Step 2:** Render participant list below the edit form.
**Step 3:** Add kick button for each non-GM participant.
**Step 4:** Commit
```bash
git add src/GmRelay.Web/Components/Pages/EditSession.razor
git commit -m "feat: show player list and kick button in EditSession"
```
---
## Task 6: Add backend tests
**Objective:** Cover new GetSessionParticipants and RemovePlayerFromSession logic.
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs`
**Step 1:** Write tests for `GetSessionParticipantsForGmAsync`.
**Step 2:** Write tests for `RemovePlayerFromSessionForGmAsync` including waitlist promotion.
**Step 3:** Run tests
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj -v n
```
**Step 4:** Commit
```bash
git add tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs
git commit -m "test: add SessionParticipant service tests"
```
---
## Task 7: Update README
**Objective:** Bump version and document new features.
**Files:**
- Modify: `README.md`
**Step 1:** Change version from `v1.9.6` to `v1.9.7`.
**Step 2:** Add bullet under Web Dashboard: player list with kick and auto-promote.
**Step 3:** Commit
```bash
git add README.md
git commit -m "docs: bump README to v1.9.7, document player list kick"
```
---
## Task 8: Update Wiki
**Objective:** Update `Руководство ГМа` page with player management instructions.
**Files:**
- Modify: Wiki page `Руководство ГМа`
**Step 1:** Read current wiki content via MCP.
**Step 2:** Add section about viewing player list and removing players.
**Step 3:** Update via MCP.
---
## Task 9: Push branch and run CI
**Objective:** Push branch, monitor workflow, fix issues.
**Step 1:** Push
```bash
git push -u origin feat/player-list-kick-waitlist
```
**Step 2:** Check workflow run via MCP gitea actions.
**Step 3:** Fix any issues.
---
## Task 10: Merge and create release
**Objective:** Merge PR (or fast-forward), tag, create release.
**Step 1:** Merge to main
```bash
git checkout main
git merge --no-ff feat/player-list-kick-waitlist -m "feat: player list, kick, and waitlist promotion (#X)"
```
**Step 2:** Tag v1.9.7
```bash
git tag v1.9.7
git push origin main --tags
```
**Step 3:** Create release via MCP gitea_create_release.
---
@@ -1,69 +0,0 @@
# Telegram Mini App Dashboard Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a Telegram Mini App mobile dashboard that reuses the existing Web Dashboard and validates Telegram WebApp `initData` on the server.
**Architecture:** Extend `TelegramAuthService` for WebApp init data, add a `/miniapp` Blazor entry page plus `/auth/telegram-webapp` endpoint, and add bot entry points through an inline WebApp button and optional menu button setup. Existing application/domain services remain the only write path.
**Tech Stack:** .NET 10, Blazor Server, Telegram.Bot, xUnit, Dapper/Npgsql-backed existing services.
---
### Task 1: Telegram WebApp Authentication
**Files:**
- Modify: `src/GmRelay.Web/Services/TelegramAuthService.cs`
- Modify: `src/GmRelay.Web/Program.cs`
- Test: `tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs`
- [ ] Write failing tests for valid WebApp `initData`, tampered hash, and expired auth date.
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramAuthServiceTests`.
- [ ] Implement WebApp HMAC verification using the Telegram `WebAppData` secret derivation.
- [ ] Add `/auth/telegram-webapp` endpoint that signs in using the same claims as `/auth/telegram`.
- [ ] Re-run the filtered tests.
### Task 2: Mini App Entry Page
**Files:**
- Create: `src/GmRelay.Web/Components/Pages/MiniApp.razor`
- Modify: `src/GmRelay.Web/Components/App.razor`
- Modify: `src/GmRelay.Web/wwwroot/app.css`
- Test: `tests/GmRelay.Bot.Tests/Web/MiniAppDashboardTests.cs`
- [ ] Write failing tests that assert `/miniapp`, `telegram-web-app.js`, `authenticateTelegramMiniApp`, and Mini App CSS hooks exist.
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter MiniAppDashboardTests`.
- [ ] Implement `/miniapp` to post `Telegram.WebApp.initData` to `/auth/telegram-webapp`, expand/ready the Mini App, and show fallback login when opened outside Telegram.
- [ ] Add CSS for a mobile-first Mini App shell and compact dashboard spacing.
- [ ] Re-run the filtered tests.
### Task 3: Bot Entry Points
**Files:**
- Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramMiniAppMenuButtonService.cs`
- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs`
- Modify: `src/GmRelay.Bot/Program.cs`
- Test: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramMiniAppEntryPointTests.cs`
- [ ] Write failing tests that assert `/start` exposes a WebApp button and startup registers the menu button service.
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramMiniAppEntryPointTests`.
- [ ] Add a configurable `Telegram:MiniAppUrl` entry point; when missing, keep existing command behavior.
- [ ] Add hosted service that calls `SetChatMenuButton` with `MenuButtonWebApp` only when the URL is configured.
- [ ] Re-run the filtered tests.
### Task 4: Docs, Versions, and Release Prep
**Files:**
- Modify: `Directory.Build.props`
- Modify: `compose.yaml`
- Modify: `.gitea/workflows/deploy.yml`
- Modify: `src/GmRelay.Web/wwwroot/app.css`
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
- Modify: `README.md`
- Wiki: `Home`, `Быстрый старт`, `Руководство ГМа`, `Развёртывание`, `Архитектура`, `Разработка`
- [ ] Update project/container/workflow/UI versions to `1.9.0`.
- [ ] Document `TELEGRAM_MINI_APP_URL`, BotFather `/setmenubutton`, `/miniapp`, and WebApp auth.
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --collect:"XPlat Code Coverage"`.
- [ ] Run `dotnet build GM-Relay.slnx -c Release`.
- [ ] Commit, push, close issue #17, update wiki, create tag/release `v1.9.0`.
@@ -1,560 +0,0 @@
# Platform Messenger Contracts Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement issue #24 by adding platform-neutral platform identity and messaging contracts, then routing the Telegram session flows through a Telegram adapter without changing Telegram behavior.
**Architecture:** Keep update routing and Telegram update parsing at the `GmRelay.Bot.Infrastructure.Telegram` boundary, but move outbound messaging decisions behind `GmRelay.Shared.Platform.IPlatformMessenger`. `GmRelay.Shared` owns platform-neutral DTOs and contracts; `GmRelay.Bot` owns `TelegramPlatformMessenger`, which translates neutral requests into `Telegram.Bot` calls and reuses the existing Telegram renderers/editing rules.
**Tech Stack:** .NET 10, C# preview, xUnit, Dapper.AOT constraints, Telegram.Bot in `GmRelay.Bot` only, platform-neutral shared contracts in `GmRelay.Shared`.
---
## Issue Context
- Gitea issue: #24, `refactor: ввести PlatformKind, PlatformUser, PlatformGroup и IPlatformMessenger`
- Labels: `area:bot`, `area:platform`, `area:shared`, `platform:multi`, `type:refactor`, `pending-approval`
- Acceptance criteria:
- New contracts live in a platform-neutral layer.
- Telegram flow goes through the adapter without behavior changes.
- A future DiscordBot can reference the contract without depending on Telegram assemblies.
## Proposed Version Bump
Current version is `2.0.0` in:
- `Directory.Build.props`
- `compose.yaml`
- `.gitea/workflows/deploy.yml`
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
Issue label is `type:refactor`; per workflow rules this is not a major bump and has no user-facing feature label. Proposed bump: `2.0.0` -> `2.0.1`.
## Files
- Create: `src/GmRelay.Shared/Platform/PlatformKind.cs`
- Create: `src/GmRelay.Shared/Platform/PlatformUser.cs`
- Create: `src/GmRelay.Shared/Platform/PlatformGroup.cs`
- Create: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs`
- Create: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs`
- Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs`
- Create: `tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs`
- Create: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs`
- Modify: `src/GmRelay.Bot/Program.cs`
- Modify: `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs`
- Modify: `src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs`
- Modify: version files listed above
## Design
### Shared Contracts
`PlatformKind` is a sentinel enum where `Max` is not a sendable platform:
```csharp
namespace GmRelay.Shared.Platform;
public enum PlatformKind
{
Telegram = 0,
Discord = 1,
Max = 2
}
```
`PlatformUser` and `PlatformGroup` carry external platform identity while keeping current Telegram IDs representable as strings:
```csharp
namespace GmRelay.Shared.Platform;
public sealed record PlatformUser(
PlatformKind Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername);
public sealed record PlatformGroup(
PlatformKind Platform,
string ExternalGroupId,
string DisplayName,
string? ExternalChannelId = null,
string? ExternalThreadId = null);
```
Outbound message contracts stay independent of Telegram/Discord SDK types:
```csharp
using GmRelay.Shared.Rendering;
namespace GmRelay.Shared.Platform;
public sealed record PlatformMessageRef(
PlatformKind Platform,
string ExternalGroupId,
string? ExternalThreadId,
string ExternalMessageId);
public sealed record PlatformMessageAction(
string Key,
string Label,
string Payload);
public sealed record PlatformScheduleMessage(
PlatformGroup Group,
SessionBatchViewModel View,
PlatformMessageRef? ExistingMessage,
string? ImageReference = null);
public sealed record PlatformPrivateMessage(
PlatformUser Recipient,
string HtmlText);
public sealed record PlatformInteractionReply(
string InteractionId,
string Text,
bool ShowAlert = false);
public sealed record PlatformCalendarFile(
PlatformGroup Group,
string FileName,
byte[] Content,
string CaptionHtml,
IReadOnlyList<PlatformMessageAction> Actions);
```
`IPlatformMessenger` exposes the required outward operations:
```csharp
namespace GmRelay.Shared.Platform;
public interface IPlatformMessenger
{
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct);
Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct);
Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct);
Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct);
}
```
### Telegram Adapter
`TelegramPlatformMessenger` lives in `GmRelay.Bot.Infrastructure.Telegram`, depends on `ITelegramBotClient`, and translates neutral DTOs to existing Telegram calls:
- `SendScheduleAsync` renders `SessionBatchViewModel` with `TelegramSessionBatchRenderer.Render`.
- `UpdateScheduleAsync` calls `BatchMessageEditor.EditBatchMessageAsync`.
- `SendGroupMessageAsync` calls `SendMessage` with `ParseMode.Html` and optional `messageThreadId`.
- `SendPrivateMessageAsync` calls `SendMessage` to `PlatformUser.ExternalUserId`.
- `AnswerInteractionAsync` calls `AnswerCallbackQuery`.
- `SendCalendarFileAsync` calls `SendDocument` and maps URL actions to inline keyboard buttons.
### Handler Scope
Refactor outbound Telegram calls in these flows to `IPlatformMessenger`:
- Join/leave/promote waitlist schedule updates and callback replies.
- Cancel schedule update, group cancellation message, direct notification and callback reply.
- Reschedule initiation, voting message updates, immediate reschedule schedule update, direct notifications and callback replies.
- Export calendar file sending.
Keep Telegram inbound DTOs at the boundary for now:
- `UpdateRouter` still receives `Telegram.Bot.Types.Update`.
- Text message parsing in reschedule input still receives `Telegram.Bot.Types.Message`.
- `CreateSessionHandler` can keep photo/topic creation via `ITelegramBotClient` because issue #24 targets outbound schedule/interaction/private/calendar contract, not replacing all Telegram update primitives in one PR.
## Tasks
### Task 1: RED - Shared Contract Tests
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Platform/PlatformContractsTests.cs`
- [ ] **Step 1: Write failing tests for neutral contracts**
```csharp
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Platform;
public sealed class PlatformContractsTests
{
[Fact]
public void PlatformKind_ShouldDefineTelegramDiscordAndMaxSentinel()
{
Assert.Equal(0, (int)PlatformKind.Telegram);
Assert.Equal(1, (int)PlatformKind.Discord);
Assert.Equal(2, (int)PlatformKind.Max);
}
[Fact]
public void PlatformContracts_ShouldBeTelegramAssemblyFree()
{
var contractTypes = new[]
{
typeof(PlatformUser),
typeof(PlatformGroup),
typeof(PlatformMessageRef),
typeof(PlatformMessageAction),
typeof(PlatformScheduleMessage),
typeof(PlatformPrivateMessage),
typeof(PlatformInteractionReply),
typeof(PlatformCalendarFile),
typeof(IPlatformMessenger)
};
Assert.All(contractTypes, type =>
Assert.DoesNotContain(
"Telegram",
string.Join(" ", type.Assembly.GetReferencedAssemblies().Select(value => value.Name)),
StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void PlatformScheduleMessage_ShouldCarrySharedViewModelWithoutPlatformTypes()
{
var sessionId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
var view = SessionBatchViewBuilder.Build(
"Campaign",
[new SessionBatchDto(sessionId, new DateTime(2026, 5, 15, 16, 0, 0, DateTimeKind.Utc), "Planned", 4, "https://example.test/game")],
[]);
var group = new PlatformGroup(PlatformKind.Discord, "guild-1", "Guild", "channel-1", "thread-1");
var message = new PlatformScheduleMessage(group, view, ExistingMessage: null);
Assert.Equal(PlatformKind.Discord, message.Group.Platform);
Assert.Same(view, message.View);
}
}
```
- [ ] **Step 2: Run tests and verify RED**
Run:
```powershell
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests
```
Expected: compile failure because `GmRelay.Shared.Platform` types do not exist.
### Task 2: GREEN - Add Shared Contracts
**Files:**
- Create: `src/GmRelay.Shared/Platform/PlatformKind.cs`
- Create: `src/GmRelay.Shared/Platform/PlatformUser.cs`
- Create: `src/GmRelay.Shared/Platform/PlatformGroup.cs`
- Create: `src/GmRelay.Shared/Platform/PlatformMessageContracts.cs`
- Create: `src/GmRelay.Shared/Platform/IPlatformMessenger.cs`
- [ ] **Step 1: Add the contract files exactly as described in the Design section**
- [ ] **Step 2: Run PlatformContractsTests and verify GREEN**
Run:
```powershell
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter PlatformContractsTests
```
Expected: `Passed`.
### Task 3: RED - Adapter and Flow Source Tests
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramPlatformMessengerSourceTests.cs`
- [ ] **Step 1: Write source tests for adapter wiring and target flows**
```csharp
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
public sealed class TelegramPlatformMessengerSourceTests
{
[Fact]
public async Task Program_ShouldRegisterTelegramPlatformMessenger()
{
var program = await ReadRepositoryFileAsync("src/GmRelay.Bot/Program.cs");
Assert.Contains("IPlatformMessenger", program, StringComparison.Ordinal);
Assert.Contains("TelegramPlatformMessenger", program, StringComparison.Ordinal);
}
[Theory]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs")]
public async Task SessionFlows_ShouldUsePlatformMessengerForOutboundTelegramWork(string relativePath)
{
var source = await ReadRepositoryFileAsync(relativePath);
Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal);
Assert.DoesNotContain("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal);
Assert.DoesNotContain(".AnswerCallbackQuery(", source, StringComparison.Ordinal);
}
[Fact]
public async Task TelegramPlatformMessenger_ShouldOwnTelegramBotClientCalls()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs");
Assert.Contains("ITelegramBotClient", source, StringComparison.Ordinal);
Assert.Contains("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal);
Assert.Contains("AnswerCallbackQuery", source, StringComparison.Ordinal);
Assert.Contains("SendDocument", source, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
```
- [ ] **Step 2: Run tests and verify RED**
Run:
```powershell
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests
```
Expected: failures because `TelegramPlatformMessenger` is missing and handlers still call Telegram APIs directly.
### Task 4: GREEN - Implement TelegramPlatformMessenger and Registration
**Files:**
- Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs`
- Modify: `src/GmRelay.Bot/Program.cs`
- [ ] **Step 1: Implement adapter**
Implementation notes:
- Parse Telegram chat/thread/message IDs from neutral string IDs with `long.Parse` and `int.Parse`.
- Use `ParseMode.Html` for HTML text.
- Map `PlatformMessageAction` URLs to `InlineKeyboardButton.WithUrl`.
- Return a `PlatformMessageRef` with message IDs converted to strings.
- [ ] **Step 2: Register adapter**
Add `using GmRelay.Shared.Platform;` and register:
```csharp
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
```
- [ ] **Step 3: Run adapter source tests**
Run:
```powershell
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests
```
Expected: some handler source tests still fail until Task 5.
### Task 5: GREEN - Refactor Session Flows Through Adapter
**Files:**
- Modify target handler files listed in Task 3
- Modify: `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs`
- [ ] **Step 1: Replace constructor dependencies**
Use `IPlatformMessenger messenger` in target handlers for outbound operations. Keep `ITelegramBotClient` only where the handler still performs inbound Telegram-specific work that is out of scope, such as message deletion or forum topic creation.
- [ ] **Step 2: Convert Telegram IDs to neutral platform objects**
Use helper code equivalent to:
```csharp
private static PlatformGroup TelegramGroup(long chatId, string? title, int? threadId = null)
=> new(
PlatformKind.Telegram,
chatId.ToString(System.Globalization.CultureInfo.InvariantCulture),
title ?? "Telegram chat",
ExternalChannelId: chatId.ToString(System.Globalization.CultureInfo.InvariantCulture),
ExternalThreadId: threadId?.ToString(System.Globalization.CultureInfo.InvariantCulture));
private static PlatformUser TelegramUser(long telegramId, string displayName, string? username = null)
=> new(
PlatformKind.Telegram,
telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture),
displayName,
username);
```
- [ ] **Step 3: Replace schedule updates**
Build `SessionBatchViewModel` as before, then call:
```csharp
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
group,
view,
new PlatformMessageRef(PlatformKind.Telegram, group.ExternalGroupId, group.ExternalThreadId, messageId.ToString(System.Globalization.CultureInfo.InvariantCulture))),
ct);
```
- [ ] **Step 4: Replace interaction replies**
Use:
```csharp
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, text, showAlert: false),
ct);
```
- [ ] **Step 5: Replace direct notifications**
`DirectSessionNotificationSender` should become a small compatibility service over `IPlatformMessenger`:
```csharp
await messenger.SendPrivateMessageAsync(
new PlatformPrivateMessage(
new PlatformUser(PlatformKind.Telegram, recipient.TelegramId.ToString(CultureInfo.InvariantCulture), recipient.DisplayName, null),
htmlText),
ct);
```
- [ ] **Step 6: Replace calendar file sending**
`ExportCalendarHandler` builds the same ICS bytes and calls `SendCalendarFileAsync`, preserving the subscription URL button as a `PlatformMessageAction`.
- [ ] **Step 7: Run target source tests**
Run:
```powershell
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramPlatformMessengerSourceTests
```
Expected: `Passed`.
### Task 6: Regression Tests
**Files:**
- Existing tests only unless a compiler failure exposes a missing using or changed behavior.
- [ ] **Step 1: Run rendering and routing tests**
Run:
```powershell
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Rendering|FullyQualifiedName~Telegram|FullyQualifiedName~RescheduleSession"
```
Expected: `Passed`.
- [ ] **Step 2: Run all tests**
Run:
```powershell
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj
```
Expected: `Passed`.
- [ ] **Step 3: Build solution**
Run:
```powershell
dotnet build GM-Relay.slnx
```
Expected: `Build succeeded` with warnings treated as errors.
### Task 7: Version Bump
**Files:**
- Modify: `Directory.Build.props`
- Modify: `compose.yaml`
- Modify: `.gitea/workflows/deploy.yml`
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
- [ ] **Step 1: Update all four version locations to `2.0.1`**
- [ ] **Step 2: Verify sync**
Run:
```powershell
rg -n "2\\.0\\.0|2\\.0\\.1" Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor
```
Expected: no `2.0.0` matches in these files and `2.0.1` appears in all required locations.
### Task 8: Documentation Review
**Files:**
- Review: `README.md`
- Review: `docs/adr/002-platform-neutral-batch-rendering.md`
- [ ] **Step 1: Check README and ADR for platform contract accuracy**
- [ ] **Step 2: Update docs if they now misrepresent platform-neutral responsibilities**
Expected likely doc change: README currently lists current version as `v1.15.0`, which is already inconsistent with repo version `2.0.0`. If this PR bumps to `2.0.1`, update that line to `v2.0.1`.
### Task 9: Commit, PR, CI, Review, Merge, Deploy, Release
**Files:**
- Stage only files intentionally changed for issue #24.
- [ ] **Step 1: Create branch**
```powershell
git checkout -b codex/refactor/issue-24-platform-messenger
```
- [ ] **Step 2: Commit**
```powershell
git add src/GmRelay.Shared/Platform src/GmRelay.Bot tests/GmRelay.Bot.Tests Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor README.md docs/adr/002-platform-neutral-batch-rendering.md
git commit -m "refactor: add platform messenger contracts"
```
- [ ] **Step 3: Push and create PR via Gitea**
- [ ] **Step 4: Wait for PR CI and fix failures if any**
- [ ] **Step 5: Run code review subagent and address findings**
- [ ] **Step 6: Merge PR after CI and review**
- [ ] **Step 7: Monitor deploy workflow**
- [ ] **Step 8: Create release `v2.0.1` with Russian release notes**
- [ ] **Step 9: Close issue #24 with PR and release links**
## Self-Review
- Spec coverage: all issue acceptance criteria map to Shared contracts, Telegram adapter, handler source tests, and build/test verification.
- Placeholder scan: no `TBD`, `TODO`, or "fill later" placeholders are left in this plan.
- Type consistency: all snippets use `GmRelay.Shared.Platform`, `PlatformKind.Telegram`, `PlatformMessageRef`, and `IPlatformMessenger` consistently.
- Scope control: inbound Telegram update parsing remains out of scope; outbound schedule/private/interaction/calendar operations are in scope.
@@ -1,731 +0,0 @@
# Discord NetCord Gateway Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a separate `src/GmRelay.DiscordBot` worker that uses NetCord Gateway for Discord slash commands and component interactions while keeping Telegram dependencies isolated in `src/GmRelay.Bot`.
**Architecture:** Create a new .NET worker project that references `GmRelay.ServiceDefaults` and `GmRelay.Shared`, validates `Discord:Token` during startup, registers NetCord gateway/application command/component services, and logs gateway lifecycle events through NetCord gateway handlers. Keep database connectivity aligned with the existing worker by registering the same `ConnectionStrings:gmrelaydb` `NpgsqlDataSource` pattern, but do not move Telegram code or dependencies.
**Tech Stack:** .NET 10 worker, Aspire service defaults, NetCord.Hosting `1.0.0-alpha.489`, Npgsql `10.0.2`, xUnit, Docker Compose, Gitea Actions.
---
## Issue
- Gitea issue: `#26`, `feat: добавить src/GmRelay.DiscordBot на NetCord Gateway`
- Labels: `type:feature`, `area:discord`, `area:infra`, `platform:discord`, `priority:p1`, `pending-approval`
- Version bump: minor, `2.1.1` -> `2.2.0`
- Branch: `feature/issue-26-discord-netcord-gateway`
## Sources Checked
- NetCord application commands guide: `https://netcord.dev/guides/services/application-commands/introduction.html`
- NetCord intents guide: `https://netcord.dev/guides/events/intents.html`
- NetCord gateway handler docs: `https://netcord.dev/docs/NetCord.Hosting.Gateway.html`
- NuGet flat container for `NetCord.Hosting`: latest observed version `1.0.0-alpha.489`
## File Structure
- Create: `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` - Discord worker project and package references.
- Create: `src/GmRelay.DiscordBot/Program.cs` - host composition, token validation, database registration, NetCord service registration.
- Create: `src/GmRelay.DiscordBot/DiscordOptions.cs` - strongly typed Discord token/options validation.
- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs` - Discord-local startup redaction without referencing the Telegram worker project.
- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` - NetCord gateway lifecycle handler for ready/connect/resume/disconnect/close/rate-limit events where available.
- Create: `src/GmRelay.DiscordBot/Dockerfile` - publish and runtime image for the Discord worker.
- Modify: `GM-Relay.slnx` - include the new project.
- Modify: `src/GmRelay.AppHost/GmRelay.AppHost.csproj` - reference the Discord worker for Aspire orchestration.
- Modify: `src/GmRelay.AppHost/Program.cs` - add `discord` project with PostgreSQL reference.
- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` - reference the Discord worker project.
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` - source-level tests for solution inclusion, Docker/Compose/CI wiring, and Telegram isolation.
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs` - unit tests for token validation.
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` - source-level startup tests for NetCord registration, service defaults, and PostgreSQL connection requirements.
- Modify: `compose.yaml` - add `discord` service and versioned image tag.
- Modify: `.gitea/workflows/deploy.yml` - build/push/scan/pull Discord image and include `DISCORD_BOT_TOKEN` in `.env`.
- Modify: `.gitea/workflows/pr-checks.yml` - build the Discord project in PR checks.
- Modify: `Directory.Build.props` - version `2.2.0`.
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` - visible version `v2.2.0`.
- Generated by restore: `src/GmRelay.DiscordBot/packages.lock.json`.
- Generated by restore: updates to `tests/GmRelay.Bot.Tests/packages.lock.json` and `src/GmRelay.AppHost/packages.lock.json`.
## TDD Plan
### Task 1: Project Presence And Telegram Isolation
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs`
- Modify: `GM-Relay.slnx`
- Create: `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj`
- Create: `src/GmRelay.DiscordBot/Program.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using System;
using System.IO;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordProjectStructureTests
{
private static string GetRepoRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
{
dir = Directory.GetParent(dir)?.FullName;
}
return dir ?? throw new InvalidOperationException("Could not find repo root");
}
[Fact]
public void Solution_ShouldIncludeDiscordWorkerProject()
{
var repoRoot = GetRepoRoot();
var solution = File.ReadAllText(Path.Combine(repoRoot, "GM-Relay.slnx"));
Assert.Contains("src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj", solution);
}
[Fact]
public void DiscordWorkerProject_ShouldExistWithoutTelegramDependency()
{
var repoRoot = GetRepoRoot();
var projectPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "GmRelay.DiscordBot.csproj");
Assert.True(File.Exists(projectPath), "Discord worker project should exist.");
var project = File.ReadAllText(projectPath);
Assert.Contains("Microsoft.NET.Sdk.Worker", project);
Assert.Contains("NetCord.Hosting", project);
Assert.Contains("GmRelay.ServiceDefaults.csproj", project);
Assert.Contains("GmRelay.Shared.csproj", project);
Assert.DoesNotContain("Telegram.Bot", project);
Assert.DoesNotContain("GmRelay.Bot.csproj", project);
}
[Fact]
public void TelegramWorkerProject_ShouldNotReferenceNetCord()
{
var repoRoot = GetRepoRoot();
var project = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Bot", "GmRelay.Bot.csproj"));
Assert.DoesNotContain("NetCord", project, StringComparison.OrdinalIgnoreCase);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordProjectStructureTests`
Expected: FAIL because `GM-Relay.slnx` does not include `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` and the project file does not exist.
- [ ] **Step 3: Write minimal implementation**
Create `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj`:
```xml
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
<PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GmRelay.ServiceDefaults\GmRelay.ServiceDefaults.csproj" />
<ProjectReference Include="..\GmRelay.Shared\GmRelay.Shared.csproj" />
</ItemGroup>
</Project>
```
Add this project to `GM-Relay.slnx` inside `/src/`:
```xml
<Project Path="src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj" />
```
Create temporary minimal `src/GmRelay.DiscordBot/Program.cs`:
```csharp
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
await builder.Build().RunAsync();
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordProjectStructureTests`
Expected: PASS.
### Task 2: Token Validation
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs`
- Create: `src/GmRelay.DiscordBot/DiscordOptions.cs`
- [ ] **Step 1: Write the failing test**
Add the project reference to `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`:
```xml
<ProjectReference Include="..\..\src\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
```
Create `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs`:
```csharp
using GmRelay.DiscordBot;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordOptionsTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_ShouldRejectMissingToken(string? token)
{
var options = new DiscordOptions { Token = token };
var exception = Assert.Throws<InvalidOperationException>(options.Validate);
Assert.Contains("Discord:Token is required", exception.Message);
Assert.Contains("Discord__Token", exception.Message);
}
[Fact]
public void Validate_ShouldAcceptConfiguredToken()
{
var options = new DiscordOptions { Token = "configured-token" };
options.Validate();
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordOptionsTests`
Expected: FAIL at compile time because `GmRelay.DiscordBot.DiscordOptions` is not defined.
- [ ] **Step 3: Write minimal implementation**
Create `src/GmRelay.DiscordBot/DiscordOptions.cs`:
```csharp
namespace GmRelay.DiscordBot;
public sealed class DiscordOptions
{
public string? Token { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Token))
{
throw new InvalidOperationException(
"Discord:Token is required. Set via environment variable Discord__Token or user secrets.");
}
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordOptionsTests`
Expected: PASS.
### Task 3: Startup Wiring For Service Defaults, PostgreSQL, NetCord, And Slash Commands
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`
- Modify: `src/GmRelay.DiscordBot/Program.cs`
- [ ] **Step 1: Write the failing test**
Create `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`:
```csharp
using System;
using System.IO;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordStartupTests
{
private static string GetRepoRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
{
dir = Directory.GetParent(dir)?.FullName;
}
return dir ?? throw new InvalidOperationException("Could not find repo root");
}
[Fact]
public void Program_ShouldValidateDiscordTokenBeforeRunning()
{
var program = ReadProgram();
Assert.Contains("GetRequiredSection(\"Discord\")", program);
Assert.Contains("DiscordOptions", program);
Assert.Contains(".Validate()", program);
}
[Fact]
public void Program_ShouldRegisterServiceDefaultsAndPostgresDataSource()
{
var program = ReadProgram();
Assert.Contains("builder.AddServiceDefaults()", program);
Assert.Contains("ConnectionStrings:gmrelaydb is required", program);
Assert.Contains("NpgsqlDataSource", program);
Assert.Contains("SecretRedactor.RedactConnectionString", program);
}
[Fact]
public void Program_ShouldRegisterNetCordGatewayApplicationCommandsAndComponents()
{
var program = ReadProgram();
Assert.Contains(".AddDiscordGateway", program);
Assert.Contains(".AddApplicationCommands", program);
Assert.Contains(".AddComponentInteractions", program);
Assert.Contains(".AddGatewayHandlers", program);
Assert.Contains("AddSlashCommand", program);
}
private static string ReadProgram()
{
var repoRoot = GetRepoRoot();
return File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Program.cs"));
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordStartupTests`
Expected: FAIL because `Program.cs` does not validate `Discord:Token`, register `NpgsqlDataSource`, or register NetCord services yet.
- [ ] **Step 3: Write minimal implementation**
Replace `src/GmRelay.DiscordBot/Program.cs` with host composition that:
```csharp
using GmRelay.DiscordBot;
using GmRelay.DiscordBot.Infrastructure.Logging;
using NetCord.Gateway;
using NetCord.Hosting.Gateway;
using NetCord.Hosting.Services.ApplicationCommands;
using NetCord.Hosting.Services.ComponentInteractions;
using Npgsql;
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
var discordOptions = builder.Configuration
.GetRequiredSection("Discord")
.Get<DiscordOptions>() ?? new DiscordOptions();
discordOptions.Validate();
builder.Services.AddSingleton(discordOptions);
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var connectionString = config.GetConnectionString("gmrelaydb")
?? throw new InvalidOperationException(
"ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb.");
var logger = loggerFactory.CreateLogger("GmRelay.DiscordBot.Startup");
logger.LogInformation(
"Configured PostgreSQL data source with connection string {ConnectionString}",
SecretRedactor.RedactConnectionString(connectionString));
return NpgsqlDataSource.Create(connectionString);
});
builder.Services
.AddDiscordGateway(options =>
{
options.Token = discordOptions.Token;
options.Intents = GatewayIntents.Guilds;
})
.AddApplicationCommands()
.AddComponentInteractions()
.AddGatewayHandlers(typeof(Program).Assembly);
var host = builder.Build();
host.AddSlashCommand("ping", "Checks whether GM-Relay Discord is online.", () => "Pong!");
await host.RunAsync();
```
Use the Discord-local `SecretRedactor` namespace instead of `GmRelay.Bot.Infrastructure.Logging` so the new project does not reference the Telegram worker.
Create `src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs`:
```csharp
using System.Text.RegularExpressions;
namespace GmRelay.DiscordBot.Infrastructure.Logging;
internal static partial class SecretRedactor
{
public static string RedactConnectionString(string connectionString)
{
return PasswordPattern().Replace(connectionString, "$1***");
}
[GeneratedRegex(@"(?i)(Password\s*=\s*)[^;]+")]
private static partial Regex PasswordPattern();
}
```
If `GatewayClientOptions.Token` does not accept `string`, adjust to NetCord's required token type after compile feedback while preserving the tests' intent.
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordStartupTests`
Expected: PASS.
### Task 4: Gateway Lifecycle Logging
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`
- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs`
- [ ] **Step 1: Write the failing test**
Add to `DiscordStartupTests.cs`:
```csharp
[Fact]
public void LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues()
{
var repoRoot = GetRepoRoot();
var loggerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Infrastructure", "Logging", "DiscordGatewayLifecycleLogger.cs");
Assert.True(File.Exists(loggerPath), "Discord gateway lifecycle logger should exist.");
var logger = File.ReadAllText(loggerPath);
Assert.Contains("IReadyGatewayHandler", logger);
Assert.Contains("IDisconnectGatewayHandler", logger);
Assert.Contains("IResumeGatewayHandler", logger);
Assert.Contains("LogInformation", logger);
Assert.DoesNotContain("Token", logger);
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues`
Expected: FAIL because `DiscordGatewayLifecycleLogger.cs` does not exist.
- [ ] **Step 3: Write minimal implementation**
Create `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` using the concrete NetCord handler signatures from the installed `NetCord.Hosting` package. Minimum behavior:
```csharp
using Microsoft.Extensions.Logging;
using NetCord.Gateway;
using NetCord.Hosting.Gateway;
namespace GmRelay.DiscordBot.Infrastructure.Logging;
public sealed class DiscordGatewayLifecycleLogger(
ILogger<DiscordGatewayLifecycleLogger> logger)
: IReadyGatewayHandler,
IDisconnectGatewayHandler,
IResumeGatewayHandler
{
public ValueTask HandleAsync(ReadyEventArgs arg)
{
logger.LogInformation("Discord gateway ready as application {ApplicationId}", arg.Application.Id);
return ValueTask.CompletedTask;
}
public ValueTask HandleAsync(DisconnectEventArgs arg)
{
logger.LogWarning("Discord gateway disconnected with close status {CloseStatus}", arg.CloseStatus);
return ValueTask.CompletedTask;
}
public ValueTask HandleAsync()
{
logger.LogInformation("Discord gateway session resumed");
return ValueTask.CompletedTask;
}
}
```
If interface signatures differ in `1.0.0-alpha.489`, inspect the package XML/docs and adjust the handlers to compile while keeping ready/disconnect/resume logging and never logging token values.
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues`
Expected: PASS.
### Task 5: Runtime Container, Compose, AppHost, And CI Wiring
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs`
- Create: `src/GmRelay.DiscordBot/Dockerfile`
- Modify: `compose.yaml`
- Modify: `src/GmRelay.AppHost/GmRelay.AppHost.csproj`
- Modify: `src/GmRelay.AppHost/Program.cs`
- Modify: `.gitea/workflows/pr-checks.yml`
- Modify: `.gitea/workflows/deploy.yml`
- [ ] **Step 1: Write the failing test**
Add to `DiscordProjectStructureTests.cs`:
```csharp
[Fact]
public void RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram()
{
var repoRoot = GetRepoRoot();
var compose = File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"));
var appHostProject = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "GmRelay.AppHost.csproj"));
var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs"));
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
Assert.Contains("gmrelay-discord-bot:2.2.0", compose);
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
Assert.Contains("dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore", prChecks);
Assert.Contains("GmRelay.DiscordBot.csproj", appHostProject);
Assert.Contains("Projects.GmRelay_DiscordBot", appHostProgram);
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram`
Expected: FAIL because runtime wiring is not present.
- [ ] **Step 3: Write minimal implementation**
Create `src/GmRelay.DiscordBot/Dockerfile` modeled after `src/GmRelay.Bot/Dockerfile`, with project copy/restore for `GmRelay.DiscordBot`, `GmRelay.ServiceDefaults`, and `GmRelay.Shared`, and entrypoint `./GmRelay.DiscordBot`.
Add `discord` service to `compose.yaml`:
```yaml
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.2.0
restart: always
depends_on:
db:
condition: service_healthy
environment:
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}"
networks:
- gmrelay
```
Add Discord project reference to `src/GmRelay.AppHost/GmRelay.AppHost.csproj`:
```xml
<ProjectReference Include="..\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
```
Add Discord service to `src/GmRelay.AppHost/Program.cs`:
```csharp
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
.WithReference(postgres)
.WaitFor(postgres);
```
Update `.gitea/workflows/pr-checks.yml` with:
```yaml
- name: Build Discord Bot (compile check, includes SAST)
run: dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore
```
Update `.gitea/workflows/deploy.yml` to build, push, scan, pull, and deploy `git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}` and write `DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}` to `.env`.
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram`
Expected: PASS.
### Task 6: Version Synchronization
**Files:**
- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs`
- Modify: `Directory.Build.props`
- Modify: `compose.yaml`
- Modify: `.gitea/workflows/deploy.yml`
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
- [ ] **Step 1: Write the failing test**
Add to `DiscordProjectStructureTests.cs`:
```csharp
[Fact]
public void Version_ShouldBeSynchronizedForDiscordFeatureRelease()
{
var repoRoot = GetRepoRoot();
Assert.Contains("<Version>2.2.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 2.2.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("v2.2.0", File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter Version_ShouldBeSynchronizedForDiscordFeatureRelease`
Expected: FAIL because current version is `2.1.1`.
- [ ] **Step 3: Write minimal implementation**
Update:
- `Directory.Build.props`: `<Version>2.2.0</Version>`
- `.gitea/workflows/deploy.yml`: `VERSION: 2.2.0`
- `compose.yaml`: `gmrelay-bot:2.2.0`, `gmrelay-web:2.2.0`, `gmrelay-discord-bot:2.2.0`
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`: `v2.2.0`
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter Version_ShouldBeSynchronizedForDiscordFeatureRelease`
Expected: PASS.
### Task 7: Restore, Format, Build, And Full Test Verification
**Files:**
- Generated/updated: `src/GmRelay.DiscordBot/packages.lock.json`
- Generated/updated: `tests/GmRelay.Bot.Tests/packages.lock.json`
- Generated/updated: `src/GmRelay.AppHost/packages.lock.json`
- Any code formatting changes required by `dotnet format`
- [ ] **Step 1: Restore lock files**
Run: `dotnet restore GM-Relay.slnx`
Expected: restore succeeds and creates/updates lock files for the new project references and NetCord dependency.
- [ ] **Step 2: Run targeted tests**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~Discord`
Expected: all Discord tests pass.
- [ ] **Step 3: Run full tests**
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal`
Expected: all tests pass.
- [ ] **Step 4: Run release build**
Run: `dotnet build GM-Relay.slnx -c Release`
Expected: solution build succeeds and includes `src/GmRelay.DiscordBot`.
- [ ] **Step 5: Run format check**
Run: `dotnet format --verify-no-changes --verbosity diagnostic`
Expected: no formatting changes required.
- [ ] **Step 6: Inspect diff for secrets**
Run: `git diff --check`
Expected: no whitespace errors and no Discord token value in tracked files.
Run: `git diff -- . ':!*.lock.json'`
Expected: diff contains configuration variable names such as `Discord__Token` and `DISCORD_BOT_TOKEN`, but not a real token value.
### Task 8: Commit, PR, CI, Deploy, Release, Issue Closure
**Files:**
- All intended implementation, test, lock, workflow, compose, and version files.
- [ ] **Step 1: Create commit**
Run:
```powershell
git status --short
git add GM-Relay.slnx Directory.Build.props compose.yaml .gitea/workflows/deploy.yml .gitea/workflows/pr-checks.yml src/GmRelay.AppHost src/GmRelay.DiscordBot src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests
git commit -m "feat: add Discord NetCord gateway worker"
```
Expected: only intended files are staged and committed. Do not stage untracked `CLAUDE.md`.
- [ ] **Step 2: Push branch and open PR**
Run: `git push -u origin feature/issue-26-discord-netcord-gateway`
Create Gitea PR to `main` with:
- Summary of Discord worker, token validation, runtime wiring, and version bump.
- Test plan showing targeted Discord tests, full tests, release build, format, and secret diff inspection.
- Link to issue `#26`.
- [ ] **Step 3: Store Discord token as a Gitea Actions secret**
Use Gitea Actions configuration to create or update repository secret `DISCORD_BOT_TOKEN` with the user-provided Discord bot token.
Expected: token is stored only as an Actions secret. The token value is not written to source files, plan files, logs, PR text, release notes, or commits.
- [ ] **Step 4: Monitor CI**
Use Gitea Actions run reads until PR checks finish. If CI fails, inspect logs, fix with TDD where the failure is code behavior, push again, and re-check.
- [ ] **Step 5: Review, merge, deploy, release**
After CI passes and review is approved:
- Merge PR.
- Monitor deploy workflow on `main`.
- Create release `v2.2.0` with Russian release notes.
- Close issue `#26` with a comment linking PR and release.
## Self-Review
- Spec coverage: Project creation, NetCord Gateway, slash/component service registration, `Discord__Token`, PostgreSQL service defaults, lifecycle logging, Telegram isolation, solution build, compose/deploy integration, and version sync are covered.
- Placeholder scan: No task uses `TBD`, `TODO`, or an unspecified "add tests" instruction.
- Type consistency: Test class names and file paths are consistent across tasks; NetCord lifecycle handler signatures are explicitly marked for compile-driven adjustment because the package is prerelease and must be verified against installed `1.0.0-alpha.489`.
@@ -1,599 +0,0 @@
# Platform-Neutral Join Leave Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement Gitea issue #25 by making join/leave session interactions use platform-neutral command models while preserving Telegram callback behavior, seat limits, and waitlist semantics.
**Architecture:** Telegram callback routing remains in `UpdateRouter`, but it becomes an adapter that converts callback data into `PlatformUser`, `PlatformGroup`, and `PlatformMessageRef` values. `JoinSessionHandler` and `LeaveSessionHandler` operate on those neutral values, persist players by `(platform, external_user_id)`, and update schedules through `IPlatformMessenger`.
**Tech Stack:** .NET 10, xUnit, Dapper, Npgsql, Gitea Actions.
---
## Issue Context
- Issue: `#25 refactor: obobshchit JoinSession i LeaveSession pod platform-neutral interactions`
- Labels: `area:bot`, `area:platform`, `area:shared`, `platform:multi`, `type:refactor`
- Version bump: patch, `2.1.0` -> `2.1.1`. The issue is labeled refactor, not breaking; do not use a major bump without explicit approval.
- Existing untracked file: `CLAUDE.md`; do not stage or modify it.
## File Map
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs`
- Reflection tests proving join/leave command records expose neutral properties and no Telegram-specific identity/message fields.
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs`
- Source-level regression tests for handler SQL and messenger boundaries.
- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs`
- Add a migration test for nullable legacy `players.telegram_id`, required for non-Telegram player inserts.
- Create: `src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql`
- Drop `NOT NULL` from legacy Telegram-only player columns.
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
- Change `JoinSessionCommand` to neutral properties and query/upsert players by platform identity.
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
- Change `LeaveSessionCommand` to neutral properties and find participants by platform identity.
- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs`
- Convert Telegram callback data into neutral command values using `TelegramPlatformIds`.
- Modify: version files after implementation:
- `Directory.Build.props`
- `compose.yaml`
- `.gitea/workflows/deploy.yml`
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
## Task 1: RED - Command Model Tests
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs`
- [ ] **Step 1: Write failing command-shape tests**
```csharp
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class PlatformNeutralSessionInteractionCommandTests
{
[Fact]
public void JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext()
{
AssertProperty<JoinSessionCommand>("SessionId", typeof(Guid));
AssertProperty<JoinSessionCommand>("User", typeof(PlatformUser));
AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertNoTelegramSpecificProperties<JoinSessionCommand>();
}
[Fact]
public void LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext()
{
AssertProperty<LeaveSessionCommand>("SessionId", typeof(Guid));
AssertProperty<LeaveSessionCommand>("User", typeof(PlatformUser));
AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
}
private static void AssertProperty<T>(string name, Type expectedType)
{
var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name);
Assert.Equal(expectedType, property.PropertyType);
}
private static void AssertNoTelegramSpecificProperties<T>()
{
var names = typeof(T).GetProperties().Select(property => property.Name).ToArray();
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
Assert.DoesNotContain("ChatId", names);
Assert.DoesNotContain("MessageId", names);
Assert.DoesNotContain("TelegramUserId", names);
Assert.DoesNotContain("TelegramUsername", names);
}
}
```
- [ ] **Step 2: Verify RED**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter PlatformNeutralSessionInteractionCommandTests
```
Expected: FAIL because `JoinSessionCommand` and `LeaveSessionCommand` still expose `TelegramUserId`, `ChatId`, and `MessageId`, and do not expose `User`, `Group`, or `ScheduleMessage`.
## Task 2: RED - SQL and Boundary Tests
**Files:**
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs`
- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs`
- [ ] **Step 1: Write failing handler source tests**
```csharp
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class PlatformNeutralSessionInteractionSqlTests
{
[Fact]
public async Task JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity()
{
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
Assert.Contains("platform, external_user_id", handler, StringComparison.Ordinal);
Assert.Contains("ON CONFLICT (platform, external_user_id)", handler, StringComparison.Ordinal);
Assert.Contains("ExternalUserId", handler, StringComparison.Ordinal);
Assert.Contains("ExternalUsername", handler, StringComparison.Ordinal);
Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal);
Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal);
Assert.DoesNotContain("command.TelegramUsername", handler, StringComparison.Ordinal);
}
[Fact]
public async Task LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity()
{
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
Assert.Contains("p.platform = @Platform", handler, StringComparison.Ordinal);
Assert.Contains("p.external_user_id = @ExternalUserId", handler, StringComparison.Ordinal);
Assert.DoesNotContain("p.telegram_id = @TelegramUserId", handler, StringComparison.Ordinal);
Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal);
Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal);
}
[Fact]
public async Task SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference()
{
var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
Assert.Contains("new PlatformScheduleMessage(", joinHandler, StringComparison.Ordinal);
Assert.Contains("command.Group", joinHandler, StringComparison.Ordinal);
Assert.Contains("command.ScheduleMessage", joinHandler, StringComparison.Ordinal);
Assert.Contains("new PlatformScheduleMessage(", leaveHandler, StringComparison.Ordinal);
Assert.Contains("command.Group", leaveHandler, StringComparison.Ordinal);
Assert.Contains("command.ScheduleMessage", leaveHandler, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
```
- [ ] **Step 2: Add failing migration assertion**
Append to `PlatformIdentityMigrationTests`:
```csharp
[Fact]
public async Task MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql");
Assert.Contains("ALTER TABLE players", migration, StringComparison.Ordinal);
Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal);
}
```
- [ ] **Step 3: Verify RED**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionSqlTests|MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId"
```
Expected: FAIL because handlers still use Telegram-specific properties and the V017 migration file does not exist.
## Task 3: GREEN - Add Migration
**Files:**
- Create: `src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql`
- [ ] **Step 1: Create the migration**
```sql
-- =============================================================
-- V017: Allow platform-neutral players
-- =============================================================
-- Legacy Telegram identity columns remain for backward compatibility,
-- but non-Telegram platform users do not have Telegram ids.
-- =============================================================
ALTER TABLE players
ALTER COLUMN telegram_id DROP NOT NULL;
```
- [ ] **Step 2: Verify migration test turns green**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId
```
Expected: PASS.
## Task 4: GREEN - Refactor JoinSessionCommand and Handler
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
- [ ] **Step 1: Replace command record**
Replace the existing `JoinSessionCommand` declaration with:
```csharp
public sealed record JoinSessionCommand(
Guid SessionId,
PlatformUser User,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
```
- [ ] **Step 2: Replace player upsert**
Use platform identity parameters:
```csharp
var platform = command.User.Platform.ToString();
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
: (long?)null;
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
? command.User.ExternalUsername
: null;
var playerId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
platform = EXCLUDED.platform,
external_user_id = EXCLUDED.external_user_id,
external_username = EXCLUDED.external_username
RETURNING id;",
new
{
LegacyTelegramId = legacyTelegramId,
Name = command.User.DisplayName,
LegacyTelegramUsername = legacyTelegramUsername,
Platform = platform,
command.User.ExternalUserId,
command.User.ExternalUsername
},
transaction);
```
Add `using System.Globalization;` at the top.
- [ ] **Step 3: Update participant display query**
Change the participant projection to prefer platform-neutral username:
```sql
COALESCE(p.external_username, p.telegram_username) as TelegramUsername
```
- [ ] **Step 4: Update schedule message and interaction reply usage**
Use:
```csharp
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
```
and:
```csharp
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
```
Replace all `command.CallbackQueryId` calls with `command.InteractionId`.
- [ ] **Step 5: Verify command and SQL tests for join**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext|JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity"
```
Expected: PASS for join-focused tests.
## Task 5: GREEN - Refactor LeaveSessionCommand and Handler
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
- [ ] **Step 1: Replace command record**
Replace the existing `LeaveSessionCommand` declaration with:
```csharp
public sealed record LeaveSessionCommand(
Guid SessionId,
PlatformUser User,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
```
- [ ] **Step 2: Replace participant lookup**
Use platform identity instead of Telegram id:
```csharp
var platform = command.User.Platform.ToString();
var participant = await connection.QuerySingleOrDefaultAsync<LeaveSessionParticipantDto>(
"""
SELECT sp.id AS ParticipantRowId,
p.display_name AS DisplayName,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND sp.is_gm = false
FOR UPDATE OF sp
""",
new { command.SessionId, Platform = platform, command.User.ExternalUserId },
transaction);
```
- [ ] **Step 3: Update participant display query**
Change the participant projection to:
```sql
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername
```
- [ ] **Step 4: Update schedule message and interaction reply usage**
Use:
```csharp
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
```
Replace all `command.CallbackQueryId` calls with `command.InteractionId`.
- [ ] **Step 5: Verify leave tests**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext|LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity|SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference"
```
Expected: PASS.
## Task 6: GREEN - Convert Telegram Router to Neutral Commands
**Files:**
- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs`
- [ ] **Step 1: Add local conversion values in `HandleCallbackQueryAsync`**
After parsing `action`, add:
```csharp
var user = TelegramPlatformIds.User(
query.From.Id,
query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
query.From.Username);
var group = TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title);
var scheduleMessage = TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, message.MessageId);
```
- [ ] **Step 2: Update join command construction**
```csharp
var command = new JoinSessionCommand(
SessionId: joinSessionId,
User: user,
InteractionId: query.Id,
Group: group,
ScheduleMessage: scheduleMessage);
```
- [ ] **Step 3: Update leave command construction**
```csharp
var command = new LeaveSessionCommand(
SessionId: leaveSessionId,
User: user,
InteractionId: query.Id,
Group: group,
ScheduleMessage: scheduleMessage);
```
- [ ] **Step 4: Verify compile**
Run:
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter PlatformNeutralSessionInteractionCommandTests
```
Expected: PASS.
## Task 7: REFACTOR - Clean Up and Full Test Pass
**Files:**
- Modify only files already listed if cleanup is needed.
- [ ] **Step 1: Remove now-unused Telegram handler imports**
Check `JoinSessionHandler.cs` and `LeaveSessionHandler.cs` for unused:
```csharp
using GmRelay.Bot.Infrastructure.Telegram;
```
Remove it from handlers if no longer needed.
- [ ] **Step 2: Run focused tests**
```powershell
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionCommandTests|PlatformNeutralSessionInteractionSqlTests|PlatformIdentityMigrationTests"
```
Expected: PASS.
- [ ] **Step 3: Run full test suite**
```powershell
dotnet test .\GM-Relay.slnx
```
Expected: PASS.
- [ ] **Step 4: Build solution**
```powershell
dotnet build .\GM-Relay.slnx
```
Expected: PASS.
## Task 8: Version Bump
**Files:**
- Modify: `Directory.Build.props`
- Modify: `compose.yaml`
- Modify: `.gitea/workflows/deploy.yml`
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
- [ ] **Step 1: Update version from `2.1.0` to `2.1.1`**
Expected exact replacements:
```xml
<Version>2.1.1</Version>
```
```yaml
VERSION: 2.1.1
```
```yaml
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.1
image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.1
```
```razor
<div class="nav-version">v2.1.1</div>
```
- [ ] **Step 2: Verify synchronized versions**
Run:
```powershell
rg "<Version>|image: git.codeanddice.ru/toutsu/gmrelay-|VERSION:|nav-version" Directory.Build.props compose.yaml .gitea\workflows\deploy.yml src\GmRelay.Web\Components\Layout\NavMenu.razor
```
Expected: all project image/app/deploy UI versions show `2.1.1`.
## Task 9: PR, CI, Review, Merge, Deploy, Release
**Files:**
- No additional source changes expected.
- [ ] **Step 1: Create branch after approval**
```powershell
git checkout -b refactor/issue-25-platform-neutral-join-leave
```
- [ ] **Step 2: Stage only intended files**
```powershell
git add docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor
```
- [ ] **Step 3: Commit**
```powershell
git commit -m "refactor: make session join leave platform-neutral"
```
- [ ] **Step 4: Push and create Gitea PR**
```powershell
git push -u origin refactor/issue-25-platform-neutral-join-leave
```
PR title:
```text
refactor: make session join leave platform-neutral
```
PR body:
```markdown
## Summary
- Closes #25.
- Converts join/leave session interaction commands from Telegram-specific fields to platform-neutral `PlatformUser`, `PlatformGroup`, and `PlatformMessageRef`.
- Persists and looks up session participants by `(platform, external_user_id)`.
- Keeps Telegram callback data and schedule update behavior intact.
## Test plan
- `dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionCommandTests|PlatformNeutralSessionInteractionSqlTests|PlatformIdentityMigrationTests"`
- `dotnet test .\GM-Relay.slnx`
- `dotnet build .\GM-Relay.slnx`
## Workflow
- [ ] CI passes
- [ ] Code review approved
- [ ] Deployed
- [ ] Release published
```
- [ ] **Step 5: Watch CI, request review, merge, deploy, release**
Use Gitea MCP for PR creation, CI polling, review, merge, deploy monitoring, and release `v2.1.1`. Close issue #25 after release and add a comment linking the PR and release.
## Self-Review
- Spec coverage: issue scope is covered by neutral command records, Telegram adapter conversion, platform identity SQL, messenger-based schedule updates, and tests.
- Placeholder scan: no `TBD`, `TODO`, or "fill later" steps remain.
- Type consistency: commands consistently use `PlatformUser User`, `string InteractionId`, `PlatformGroup Group`, and `PlatformMessageRef ScheduleMessage`.
@@ -1,984 +0,0 @@
# Discord /newsession и /listsessions — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:test-driven-development (TDD) for every task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Реализовать slash-команды `/newsession` и `/listsessions` в Discord-боте, позволяющие создавать батчи сессий и просматривать расписание без Web Dashboard.
**Architecture:** Каждая команда — отдельный vertical slice в `GmRelay.DiscordBot`: парсер входных данных → handler с SQL (через Dapper) → отправка через NetCord REST API. Рендеринг переиспользует существующий `DiscordSessionBatchRenderer`. Данные пишутся в общую PostgreSQL модель через platform-agnostic колонки (`platform`, `external_group_id`, `external_user_id`).
**Tech Stack:** .NET 10, NetCord 1.0.0-alpha.489, NetCord.Hosting.Services, Dapper, Npgsql, xUnit.
**Version Bump:** minor (2.3.0 → 2.4.0) — новый функционал.
---
## File Structure
| File | Responsibility |
|------|--------------|
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs` | Slash-команда `/newsession` с параметрами (title, time, seats, link) |
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs` | Handler создания batch + sessions в БД, проверка прав |
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs` | Slash-команда `/listsessions` |
| `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs` | Handler запроса активных сессий и публикации embed |
| `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs` | Проверка прав пользователя в guild (owner/admin/manager) |
| `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs` | Реализация `IPlatformMessenger` для отправки/обновления расписания в Discord |
| `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs` | TDD-тесты создания сессий из Discord |
| `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs` | TDD-тесты вывода расписания |
| `tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs` | TDD-тесты проверки прав |
| `src/GmRelay.DiscordBot/Program.cs` | Регистрация DI: handlers, permission checker, platform messenger |
---
## Task 1: DiscordPermissionChecker
**Files:**
- Create: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs`
**Context:** Discord использует guild-роли. Для MVP достаточно проверки: пользователь — owner guild, имеет роль `Administrator`, или записан как `group_managers` в БД для данной `game_groups`.
### Step 1.1: Write the failing test
```csharp
using GmRelay.DiscordBot.Infrastructure.Discord;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordPermissionCheckerTests
{
[Fact]
public void CanManageSchedule_WhenUserIsGuildOwner_ReturnsTrue()
{
var checker = new DiscordPermissionChecker();
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 123456789ul,
userRoles: Array.Empty<ulong>(),
dbManagerUserIds: Array.Empty<ulong>());
Assert.True(result);
}
[Fact]
public void CanManageSchedule_WhenUserHasAdministratorRole_ReturnsTrue()
{
var checker = new DiscordPermissionChecker();
var adminRole = 999ul;
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 987654321ul,
userRoles: new[] { adminRole },
dbManagerUserIds: Array.Empty<ulong>());
Assert.True(result);
}
[Fact]
public void CanManageSchedule_WhenUserIsDbManager_ReturnsTrue()
{
var checker = new DiscordPermissionChecker();
var managerId = 555ul;
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: managerId,
userRoles: Array.Empty<ulong>(),
dbManagerUserIds: new[] { managerId });
Assert.True(result);
}
[Fact]
public void CanManageSchedule_WhenRegularUser_ReturnsFalse()
{
var checker = new DiscordPermissionChecker();
var result = checker.CanManageSchedule(
guildOwnerId: 123456789ul,
userId: 111ul,
userRoles: Array.Empty<ulong>(),
dbManagerUserIds: new[] { 222ul });
Assert.False(result);
}
}
```
### Step 1.2: Run test to verify it fails
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPermissionCheckerTests" --verbosity normal`
Expected: FAIL — `DiscordPermissionChecker` not found.
### Step 1.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs`:
```csharp
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordPermissionChecker
{
// Discord Administrator permission bitflag
private const ulong AdministratorPermission = 0x8;
public bool CanManageSchedule(
ulong guildOwnerId,
ulong userId,
IEnumerable<ulong> userRoles,
IEnumerable<ulong> dbManagerUserIds)
{
if (userId == guildOwnerId)
return true;
if (dbManagerUserIds.Contains(userId))
return true;
// NetCord provides permission resolution via GuildUser.Permissions;
// here we accept pre-resolved flag for simplicity.
// Actual command handler will pass resolved permissions.
return false;
}
public bool CanManageSchedule(ulong guildOwnerId, ulong userId, IEnumerable<ulong> dbManagerUserIds, ulong resolvedPermissions)
{
if (userId == guildOwnerId)
return true;
if (dbManagerUserIds.Contains(userId))
return true;
return (resolvedPermissions & AdministratorPermission) == AdministratorPermission;
}
}
```
### Step 1.4: Run test to verify it passes
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPermissionCheckerTests" --verbosity normal`
Expected: PASS (4/4).
### Step 1.5: Commit
```bash
git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs
git commit -m "feat(discord): add DiscordPermissionChecker for session management rights
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 2: DiscordListSessionsHandler + Command
**Files:**
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs`
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs`
**Context:** Handler должен:
1. Найти `game_groups` по `external_group_id` = `guild_id`.
2. Выбрать предстоящие сессии (`scheduled_at > NOW()`, `status != Cancelled`).
3. Собрать участников.
4. Построить view через `SessionBatchViewBuilder`.
5. Отрендерить через `DiscordSessionBatchRenderer`.
6. Отправить embed + buttons в Discord channel.
### Step 2.1: Write the failing test
Create `tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs`:
```csharp
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordListSessionsHandlerTests
{
[Fact]
public void BuildSchedule_WithSessions_ReturnsEmbedsAndButtons()
{
var sessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(sessionId, DateTime.UtcNow.AddDays(1), SessionStatus.Planned, 4, "https://example.com")
};
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test Campaign", sessions, participants);
var (embeds, actionRows) = GmRelay.DiscordBot.Rendering.DiscordSessionBatchRenderer.Render(view);
Assert.Single(embeds);
Assert.Single(actionRows);
}
[Fact]
public void BuildSchedule_WithCancelledSession_SkipsActionRows()
{
var cancelledSessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow.AddDays(1), SessionStatus.Cancelled, null, "") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test Campaign", sessions, participants);
var (embeds, actionRows) = GmRelay.DiscordBot.Rendering.DiscordSessionBatchRenderer.Render(view);
Assert.Single(embeds);
Assert.Empty(actionRows);
}
}
```
### Step 2.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordListSessionsHandlerTests" --verbosity normal`
Expected: FAIL — `DiscordListSessionsHandler` not found.
### Step 2.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs`:
```csharp
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using NetCord.Rest;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
internal sealed record DiscordSessionListItemDto(
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
int PlayerCount, int WaitlistCount);
public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
{
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<DiscordSessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.platform = 'Discord'
AND g.external_group_id = @GuildId
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
ORDER BY s.scheduled_at ASC",
new
{
GuildId = guildId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionList = sessions.ToList();
if (sessionList.Count == 0)
return null;
var sessionIds = sessionList.Select(s => s.Id).ToList();
var participants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId,
p.display_name as DisplayName,
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
sp.registration_status as RegistrationStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = ANY(@SessionIds) AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC",
new { SessionIds = sessionIds });
var firstTitle = sessionList.First().Title;
var batchDtos = sessionList.Select(s => new SessionBatchDto(
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
}
}
```
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs`:
```csharp
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
public class DiscordListSessionsCommand : SlashCommandModule<SlashCommandContext>
{
private readonly DiscordListSessionsHandler _handler;
public DiscordListSessionsCommand(DiscordListSessionsHandler handler)
{
_handler = handler;
}
public override async Task ExecuteAsync()
{
var guildId = Context.Guild?.Id.ToString()
?? throw new InvalidOperationException("This command can only be used in a guild.");
var channelId = Context.Channel.Id.ToString();
var view = await _handler.BuildScheduleAsync(guildId, channelId, Context.CancellationToken);
if (view is null)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("📭 В этом сервере нет предстоящих игр."));
return;
}
var (embeds, actionRows) = Rendering.DiscordSessionBatchRenderer.Render(view);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(new InteractionMessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows)));
}
}
```
### Step 2.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordListSessionsHandlerTests" --verbosity normal`
Expected: PASS.
### Step 2.5: Commit
```bash
git add src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs
git commit -m "feat(discord): add /listsessions slash command and handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 3: DiscordNewSessionHandler + Command
**Files:**
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs`
- Create: `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs`
**Context:** Handler должен:
1. Проверить права пользователя (owner/admin/manager).
2. Upsert игрока (GM) в `players` с `platform = 'Discord'`.
3. Upsert `game_groups` с `platform = 'Discord'`, `external_group_id = guild_id`.
4. Создать batch + sessions.
5. Отправить rendered schedule в Discord channel.
6. Сохранить `platform_messages` reference.
### Step 3.1: Write the failing test
Create `tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs`:
```csharp
using GmRelay.DiscordBot.Features.Sessions;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordNewSessionHandlerTests
{
[Fact]
public void ParseTimeInput_ShouldParseDiscordDateFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("2026-05-20 19:30");
Assert.True(result.IsSuccess);
Assert.Equal(2026, result.Value.Year);
Assert.Equal(5, result.Value.Month);
Assert.Equal(20, result.Value.Day);
Assert.Equal(19, result.Value.Hour);
Assert.Equal(30, result.Value.Minute);
}
[Fact]
public void ParseTimeInput_ShouldRejectPastDate()
{
var result = DiscordNewSessionHandler.ParseTimeInput("2020-01-01 00:00");
Assert.False(result.IsSuccess);
}
[Fact]
public void ParseTimeInput_ShouldParseRussianDateFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("20.05.2026 19:30");
Assert.True(result.IsSuccess);
Assert.Equal(2026, result.Value.Year);
Assert.Equal(5, result.Value.Month);
Assert.Equal(20, result.Value.Day);
}
[Fact]
public void ParseTimeInput_ShouldRejectInvalidFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("not-a-date");
Assert.False(result.IsSuccess);
Assert.NotNull(result.Error);
}
}
```
### Step 3.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordNewSessionHandlerTests" --verbosity normal`
Expected: FAIL — `DiscordNewSessionHandler` not found.
### Step 3.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs`:
```csharp
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);
public sealed class DiscordNewSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
IPlatformMessenger messenger,
ILogger<DiscordNewSessionHandler> logger)
{
public static TimeParseResult ParseTimeInput(string input)
{
if (DateTimeOffset.TryParseExact(
input.Trim(),
"yyyy-MM-dd HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var result))
{
if (result < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, result.ToUniversalTime(), null);
}
if (DateTimeOffset.TryParseExact(
input.Trim(),
"dd.MM.yyyy HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var altResult))
{
if (altResult < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, altResult.ToUniversalTime(), null);
}
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
}
public async Task<SessionBatchViewModel> HandleAsync(
string guildId,
string channelId,
ulong userId,
string userDisplayName,
IEnumerable<ulong> userRoles,
ulong guildOwnerId,
string title,
DateTimeOffset scheduledAt,
int? maxPlayers,
string? joinLink,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
// Resolve db managers
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, userRoles, dbManagerUserIds))
{
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут создавать сессии.");
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
// Upsert player
await connection.ExecuteAsync(
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, 'Discord', @UserId, @Name)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE SET display_name = EXCLUDED.display_name,
external_username = EXCLUDED.external_username",
new { Name = userDisplayName, UserId = userId.ToString() },
transaction);
// Upsert group
var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
VALUES (@GuildId, 'Discord', @GuildId, @ChannelId)
ON CONFLICT (platform, external_group_id)
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
DO UPDATE SET name = EXCLUDED.name,
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
RETURNING id",
new { GuildId = guildId, ChannelId = channelId },
transaction);
// Ensure manager record
await connection.ExecuteAsync(
@"INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE p.platform = 'Discord' AND p.external_user_id = @UserId
ON CONFLICT (group_id, player_id) DO NOTHING",
new { GroupId = groupId, UserId = userId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
// Create batch + session
var batchId = Guid.NewGuid();
var sessionId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
RETURNING id",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = joinLink ?? string.Empty,
ScheduledAt = scheduledAt.UtcDateTime,
Status = SessionStatus.Planned,
MaxPlayers = maxPlayers
},
transaction);
await transaction.CommitAsync(cancellationToken);
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
var view = 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
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
}
```
Create `src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs`:
```csharp
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
[SlashCommand("newsession", "Create a new game session")]
public class DiscordNewSessionCommand : SlashCommandModule<SlashCommandContext>
{
private readonly DiscordNewSessionHandler _handler;
public DiscordNewSessionCommand(DiscordNewSessionHandler handler)
{
_handler = handler;
}
[SlashCommandOption("title", "Game title", Required = true)]
public string Title { get; set; } = string.Empty;
[SlashCommandOption("time", "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)", Required = true)]
public string Time { get; set; } = string.Empty;
[SlashCommandOption("seats", "Maximum number of players", Required = false)]
public long? Seats { get; set; }
[SlashCommandOption("link", "Join link", Required = false)]
public string? Link { get; set; }
public override async Task ExecuteAsync()
{
var guild = Context.Guild
?? throw new InvalidOperationException("This command can only be used in a guild.");
var timeResult = DiscordNewSessionHandler.ParseTimeInput(Time);
if (!timeResult.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"❌ {timeResult.Error}"));
return;
}
try
{
var view = await _handler.HandleAsync(
guildId: guild.Id.ToString(),
channelId: Context.Channel.Id.ToString(),
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
userRoles: Context.GuildUser!.RoleIds,
guildOwnerId: guild.OwnerId,
title: Title,
scheduledAt: timeResult.Value,
maxPlayers: Seats is null ? null : (int)Seats.Value,
joinLink: Link,
Context.CancellationToken);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("✅ Сессия создана!"));
}
catch (UnauthorizedAccessException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"⛅ {ex.Message}"));
}
catch (Exception ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("💥 Произошла ошибка при создании сессии."));
}
}
}
```
### Step 3.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordNewSessionHandlerTests" --verbosity normal`
Expected: PASS.
### Step 3.5: Commit
```bash
git add src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs
git commit -m "feat(discord): add /newsession slash command and handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 4: DiscordPlatformMessenger
**Files:**
- Create: `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs`
- Test: `tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs`
**Context:** Необходима реализация `IPlatformMessenger` для отправки schedule embeds и обновления существующих сообщений в Discord. Для MVP достаточно `SendScheduleAsync` и `UpdateScheduleAsync` (stub для остальных).
### Step 4.1: Write the failing test
Create `tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs`:
```csharp
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordPlatformMessengerTests
{
[Fact]
public void Constructor_ShouldAcceptRestClient()
{
// DiscordPlatformMessenger requires a NetCord.Rest.RestClient.
// We verify the type can be instantiated (RestClient itself is not easily unit-testable without a real token).
// This test proves the contract exists and compiles.
var constructor = typeof(DiscordPlatformMessenger).GetConstructor(new[] { typeof(NetCord.Rest.RestClient) });
Assert.NotNull(constructor);
}
[Fact]
public void DiscordPlatformMessenger_ShouldImplementIPlatformMessenger()
{
Assert.True(typeof(IPlatformMessenger).IsAssignableFrom(typeof(DiscordPlatformMessenger)));
}
}
```
### Step 4.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPlatformMessengerTests" --verbosity normal`
Expected: FAIL — `DiscordPlatformMessenger` not found.
### Step 4.3: Write minimal implementation
Create `src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs`:
```csharp
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using NetCord;
using NetCord.Rest;
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformMessenger
{
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
var channelId = ulong.Parse(message.Group.ExternalChannelId
?? message.Group.ExternalGroupId);
var msg = await restClient.SendMessageAsync(
channelId,
new MessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows),
ct);
return new PlatformMessageRef(
PlatformKind.Discord,
message.Group.ExternalGroupId,
null,
msg.Id.ToString());
}
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
if (message.ExistingMessage is null)
return;
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
var channelId = ulong.Parse(message.Group.ExternalChannelId
?? message.Group.ExternalGroupId);
var messageId = ulong.Parse(message.ExistingMessage.ExternalMessageId);
await restClient.ModifyMessageAsync(
channelId,
messageId,
new MessageProperties()
.WithEmbeds(embeds)
.WithComponents(actionRows),
ct);
}
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
{
// MVP: not needed for /newsession and /listsessions
return Task.CompletedTask;
}
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
// MVP: not needed
return Task.CompletedTask;
}
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct)
{
// MVP: not needed (commands answer inline via SlashCommandContext)
return Task.CompletedTask;
}
public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
{
// MVP: not needed
return Task.CompletedTask;
}
}
```
### Step 4.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPlatformMessengerTests" --verbosity normal`
Expected: PASS.
### Step 4.5: Commit
```bash
git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs
git commit -m "feat(discord): add DiscordPlatformMessenger IPlatformMessenger implementation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 5: Wire up DI and Register Commands
**Files:**
- Modify: `src/GmRelay.DiscordBot/Program.cs`
### Step 5.1: Write the failing test (structure test)
Modify `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` — add test that asserts new handlers are registered:
```csharp
[Fact]
public void Program_ShouldRegisterDiscordSessionHandlers()
{
var program = ReadProgram();
Assert.Contains("DiscordListSessionsHandler", program);
Assert.Contains("DiscordNewSessionHandler", program);
Assert.Contains("DiscordPermissionChecker", program);
Assert.Contains("DiscordPlatformMessenger", program);
Assert.Contains("IPlatformMessenger", program);
}
```
### Step 5.2: Run test — verify RED
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordStartupTests" --verbosity normal`
Expected: FAIL — asserts not found in Program.cs.
### Step 5.3: Write minimal implementation
Modify `src/GmRelay.DiscordBot/Program.cs`:
```csharp
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Platform;
// ... existing usings ...
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
// After host.Build():
host.AddSlashCommand("listsessions", "Show upcoming game sessions", async (DiscordListSessionsHandler handler, SlashCommandContext context) =>
{
// NetCord module-based approach preferred; if AddSlashCommand lambda doesn't support DI injection of custom services,
// rely on module classes registered via AddApplicationCommands
});
```
**Important:** NetCord module classes (`DiscordListSessionsCommand`, `DiscordNewSessionCommand`) автоматически регистрируются через `AddApplicationCommands()` + `AddGatewayHandlers(typeof(Program).Assembly)`. Constructor injection в модулях работает через DI контейнер. Никаких дополнительных `AddSlashCommand` для модулей не требуется.
Убедиться, что в Program.cs есть:
```csharp
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
```
### Step 5.4: Run test — verify GREEN
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordStartupTests" --verbosity normal`
Expected: PASS.
### Step 5.5: Commit
```bash
git add src/GmRelay.DiscordBot/Program.cs tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs
git commit -m "feat(discord): wire up DI registrations for session handlers and messenger
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Task 6: Build Verification
### Step 6.1: Build DiscordBot project
Run: `dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore`
Expected: Build succeeds (0 errors, 0 warnings).
### Step 6.2: Run all tests
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal`
Expected: All tests pass.
### Step 6.3: Commit if any fixes needed
If build or tests required fixes, commit them.
---
## Task 7: Version Bump
**Files to modify:**
- `Directory.Build.props`: `<Version>2.4.0</Version>`
- `compose.yaml`: обновить теги `gmrelay-bot`, `gmrelay-web`, `gmrelay-discord-bot``2.4.0`
- `.gitea/workflows/deploy.yml`: `VERSION: 2.4.0`
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`: `<div class="nav-version">v2.4.0</div>`
### Step 7.1: Bump version
Apply изменения ко всем 4 файлам.
### Step 7.2: Update version test
Modify `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` — обновить `Version_ShouldBeSynchronizedForDiscordFeatureRelease` ожидаемое значение на `2.4.0`.
### Step 7.3: Run version test
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Version_ShouldBeSynchronizedForDiscordFeatureRelease" --verbosity normal`
Expected: PASS.
### Step 7.4: Commit
```bash
git add Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs
git commit -m "chore: bump version to 2.4.0
Synchronized across Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
---
## Spec Coverage Self-Review
| Issue Requirement | Task |
|---|---|
| Slash command `/newsession` | Task 3 |
| Slash command `/listsessions` | Task 2 |
| Сохранение platform group identity (guild/channel) | Task 3 (game_groups.platform, external_group_id, external_channel_id) |
| Минимальная проверка прав | Task 1 + Task 3 |
| Данные пишутся в общую PostgreSQL без Telegram-only assumptions | Task 2, 3 SQL используют platform-agnostic колонки |
| `/listsessions` публикует/обновляет расписание | Task 2 + Task 4 |
**Placeholder scan:** Нет TBD, TODO, "implement later". Каждый шаг содержит конкретный код.
**Type consistency:** `DiscordPermissionChecker.CanManageSchedule` перегружен для resolved permissions (ulong bitflag). Handler передает `Context.GuildUser.RoleIds` и `guild.OwnerId`.
---
## Execution Handoff
**Plan complete and saved to `docs/superpowers/plans/2026-05-19-discord-newsession-listsessions.md`.**
**Two execution options:**
1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration
2. **Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints for review
**Which approach?**
@@ -1,44 +0,0 @@
# Telegram Mini App Dashboard Design
## Goal
Issue #17 adds a Telegram Mini App dashboard as the mobile entry point for the existing Web Dashboard. Owner and co-GM users must be able to open the dashboard from Telegram, pass server-side Telegram WebApp `initData` validation, and manage only their own groups.
## Scope
- Add Mini App authentication using Telegram WebApp `initData`.
- Add a `/miniapp` entry page that signs the user into the existing cookie auth flow, then opens the regular dashboard UI in mobile-first mode.
- Reuse `AuthorizedSessionService`, `SessionService`, and existing Blazor pages for groups, sessions, templates, waitlist promotion, edit forms, and bulk batch operations.
- Add bot entry points: a Mini App button in `/start` and a configurable default menu button when `Telegram:MiniAppUrl` is set.
- Update README, wiki, deployment config, and visible version strings to `1.9.0`.
## Architecture
The Mini App is not a second dashboard implementation. It is a Telegram-authenticated entrance into the existing Blazor dashboard. This keeps authorization, domain operations, Telegram message synchronization, and Web Dashboard behavior in one place.
`TelegramAuthService` gains a second verification method for WebApp `initData`. The server accepts the raw URL-encoded init payload at `/auth/telegram-webapp`, verifies the Telegram HMAC with the bot token, extracts the user id/name from the embedded `user` JSON, and issues the same auth cookie as the login widget endpoint.
`/miniapp` loads `telegram-web-app.js`, posts `window.Telegram.WebApp.initData` to the server endpoint, expands the WebApp viewport, and redirects to `/`. If a user opens `/miniapp` outside Telegram, the page shows the regular login fallback.
## Data Flow
1. User opens the Mini App from the bot menu button or `/start` inline button.
2. Telegram injects `initData` into the WebApp JavaScript API.
3. `/miniapp` posts `{ initData }` to `/auth/telegram-webapp`.
4. The server verifies the WebApp signature and expiry.
5. The server creates the same claims used by Telegram Login Widget.
6. Existing Blazor pages load groups through `AuthorizedSessionService`.
7. Any edit, waitlist, template, or batch action still goes through existing services and keeps Telegram messages synchronized.
## Error Handling
- Missing or invalid init data returns `401` and leaves the user on the Mini App page.
- Expired auth data is rejected with the same 24-hour window used by the Login Widget.
- A verified Telegram user with no owner/co-GM groups sees the existing empty dashboard state.
- Direct navigation to a foreign group/session still redirects to `/access-denied` through existing authorization checks.
## Testing
- Unit tests cover valid and invalid WebApp `initData`.
- File-level regression tests ensure `/miniapp`, `/auth/telegram-webapp`, Telegram WebApp script loading, bot Mini App button, menu button setup, and mobile Mini App CSS hooks remain present.
- Existing `AuthorizedSessionServiceTests` continue covering owner/co-GM access behavior.
@@ -1,318 +0,0 @@
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
public sealed record HandleRsvpCommand(
Guid SessionId,
long TelegramUserId,
string Status,
string CallbackQueryId,
long ChatId,
int MessageId);
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
internal sealed record SessionContext(
string Title,
DateTime ScheduledAt,
string Status,
long GmTelegramId,
long TelegramChatId,
int? ThreadId);
internal sealed record ParticipantRsvp(
long TelegramId,
string DisplayName,
string? TelegramUsername,
string RsvpStatus);
public sealed class HandleRsvpHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
ILogger<HandleRsvpHandler> logger)
{
public async Task HandleAsync(HandleRsvpCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var participantExists = await connection.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
)
""",
new { command.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
transaction);
if (!participantExists)
{
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: "Вы не являетесь участником этой сессии.",
cancellationToken: ct);
return;
}
var updated = await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = @Status,
responded_at = now()
WHERE session_id = @SessionId
AND player_id = (SELECT id FROM players WHERE telegram_id = @TelegramUserId)
AND registration_status = @Active
AND rsvp_status != @Status
""",
new { command.SessionId, command.TelegramUserId, command.Status, Active = ParticipantRegistrationStatus.Active },
transaction);
if (updated == 0)
{
var alreadyText = command.Status == RsvpStatus.Confirmed
? "Вы уже подтвердили участие."
: "Вы уже отказались от участия.";
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: alreadyText,
cancellationToken: ct);
return;
}
var session = await connection.QuerySingleAsync<SessionContext>(
"""
SELECT s.title,
s.scheduled_at AS ScheduledAt,
s.status AS Status,
g.gm_telegram_id AS GmTelegramId,
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
""",
new { command.SessionId },
transaction);
if (command.Status == RsvpStatus.Declined)
{
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0);
if (decision.ShouldRevertSessionToConfirmationSent)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @ConfirmationSent, updated_at = now()
WHERE id = @SessionId AND status = @Confirmed
""",
new
{
command.SessionId,
ConfirmationSent = SessionStatus.ConfirmationSent,
Confirmed = SessionStatus.Confirmed
},
transaction);
}
var declinedPlayer = await connection.QuerySingleAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
new { command.TelegramUserId },
transaction);
await transaction.CommitAsync(ct);
try
{
await bot.SendMessage(
chatId: session.GmTelegramId,
text: $"🚨 Отмена! {declinedPlayer} не сможет прийти на игру «{session.Title}».",
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", command.SessionId);
}
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: decision.CallbackText,
cancellationToken: ct);
}
else
{
var counts = await connection.QuerySingleAsync<RsvpCounts>(
"""
SELECT
count(*) AS Total,
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
FROM session_participants
WHERE session_id = @SessionId AND is_gm = false
AND registration_status = @Active
""",
new
{
command.SessionId,
Confirmed = RsvpStatus.Confirmed,
Declined = RsvpStatus.Declined,
Active = ParticipantRegistrationStatus.Active
},
transaction);
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed);
if (decision.ShouldMarkSessionConfirmed)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @Confirmed, updated_at = now()
WHERE id = @SessionId
""",
new { command.SessionId, Confirmed = SessionStatus.Confirmed },
transaction);
}
await transaction.CommitAsync(ct);
if (decision.ShouldNotifyGroup)
{
try
{
await bot.SendMessage(
chatId: session.TelegramChatId,
messageThreadId: session.ThreadId,
text: $"🎉 Игра «{session.Title}» подтверждена! Все участники на месте.",
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", command.SessionId);
}
}
if (decision.ShouldNotifyGm)
{
try
{
await bot.SendMessage(
chatId: session.GmTelegramId,
text: $"✅ Все подтвердили участие в «{session.Title}» ({session.ScheduledAt.FormatMoscow()} МСК).",
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", command.SessionId);
}
}
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: decision.CallbackText,
cancellationToken: ct);
}
await UpdateConfirmationMessage(command, session, ct);
}
private async Task UpdateConfirmationMessage(HandleRsvpCommand command, SessionContext session, CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var participants = (await connection.QueryAsync<ParticipantRsvp>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY sp.responded_at NULLS LAST
""",
new { command.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
var confirmed = participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
var declined = participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
var pending = participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList();
var lines = new List<string>
{
$"🎲 Подтвердите участие в «{session.Title}»",
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
string.Empty
};
foreach (var participant in confirmed)
{
lines.Add($" ✅ {FormatName(participant)}");
}
foreach (var participant in declined)
{
lines.Add($" ❌ ~~{FormatName(participant)}~~");
}
foreach (var participant in pending)
{
lines.Add($" ⏳ {FormatName(participant)}");
}
lines.Add(string.Empty);
if (confirmed.Count == participants.Count)
{
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})");
}
else if (declined.Count > 0)
{
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)");
}
else
{
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})");
}
var text = string.Join("\n", lines);
var replyMarkup = confirmed.Count == participants.Count
? null
: new InlineKeyboardMarkup([
[
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{command.SessionId}"),
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{command.SessionId}")
]
]);
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: text,
replyMarkup: replyMarkup,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId);
}
}
private static string FormatName(ParticipantRsvp participant) =>
participant.TelegramUsername is not null ? $"@{participant.TelegramUsername}" : participant.DisplayName;
}
@@ -1,154 +0,0 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Confirmation.SendConfirmation;
// ── DTOs for Dapper mapping ──────────────────────────────────────────
internal sealed record SessionInfo(
Guid Id,
string Title,
DateTime ScheduledAt,
Guid GroupId,
long TelegramChatId,
int? ThreadId,
string NotificationMode);
internal sealed record ParticipantInfo(
long TelegramId,
string DisplayName,
string? TelegramUsername);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Sends the interactive confirmation message (inline keyboard) to the group chat.
/// Called by SessionSchedulerService at T-24h.
/// </summary>
public sealed class SendConfirmationHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<SendConfirmationHandler> logger) : ISendConfirmationHandler
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
// 1. Load session + group info
var session = await connection.QuerySingleOrDefaultAsync<SessionInfo>(
"""
SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId,
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.status = @Planned
""",
new { SessionId = sessionId, Planned = SessionStatus.Planned });
if (session is null)
{
logger.LogWarning("Session {SessionId} not found or not in Planned status", sessionId);
return;
}
// 2. Load non-GM participants
var participants = (await connection.QueryAsync<ParticipantInfo>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
if (participants.Count == 0)
{
logger.LogWarning("Session {SessionId} has no non-GM participants", sessionId);
return;
}
// 3. Build confirmation message
var playerList = string.Join("\n", participants.Select(p =>
$" ⏳ {FormatPlayerName(p)}"));
var text = $"""
🎲 Подтвердите участие в «{session.Title}»
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
{playerList}
Статус: ожидаем подтверждения (0/{participants.Count})
""";
var keyboard = new InlineKeyboardMarkup([
[
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{sessionId}"),
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{sessionId}")
]
]);
// 4. Send to group
var message = await bot.SendMessage(
chatId: session.TelegramChatId,
messageThreadId: session.ThreadId,
text: text,
replyMarkup: keyboard,
cancellationToken: ct);
// 5. Update session status, store message ID, and mark confirmation sent
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @Status,
confirmation_message_id = @MessageId,
confirmation_sent_at = now(),
updated_at = now()
WHERE id = @SessionId
AND confirmation_sent_at IS NULL
""",
new
{
SessionId = sessionId,
Status = SessionStatus.ConfirmationSent,
MessageId = message.MessageId
});
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var directText = $"""
🎲 <b>Подтвердите участие в игре</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
Ответьте кнопкой в групповом сообщении расписания.
""";
await directSender.SendAsync(
participants.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)),
directText,
"confirmation",
sessionId,
ct);
}
logger.LogInformation(
"Confirmation sent for session {SessionId} ({Title}), message_id={MessageId}",
sessionId, session.Title, message.MessageId);
}
internal static string FormatPlayerName(ParticipantInfo p) =>
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
}
@@ -1,134 +0,0 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Reminders.SendJoinLink;
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record JoinLinkSession(
Guid Id,
string Title,
string JoinLink,
DateTime ScheduledAt,
long TelegramChatId,
int? ThreadId,
string NotificationMode);
internal sealed record ConfirmedPlayer(
long TelegramId,
string DisplayName,
string? TelegramUsername);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Sends the join link to the group chat at T-5min, tagging all confirmed players.
/// Called by SessionSchedulerService.
/// </summary>
public sealed class SendJoinLinkHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<SendJoinLinkHandler> logger) : ISendJoinLinkHandler
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
// 1. Load session
var session = await connection.QuerySingleOrDefaultAsync<JoinLinkSession>(
"""
SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt,
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
AND s.status = @Confirmed
AND s.link_message_id IS NULL
""",
new { SessionId = sessionId, Confirmed = SessionStatus.Confirmed });
if (session is null)
{
logger.LogWarning("Session {SessionId} not eligible for join link", sessionId);
return;
}
// 2. Load confirmed players
var players = (await connection.QueryAsync<ConfirmedPlayer>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.rsvp_status = @Confirmed
AND sp.registration_status = @Active
""",
new
{
SessionId = sessionId,
Confirmed = RsvpStatus.Confirmed,
Active = ParticipantRegistrationStatus.Active
})).ToList();
// 3. Build message with player mentions
var mentions = string.Join(", ", players.Select(p =>
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName));
var text = $"""
🎮 Игра «{session.Title}» начинается через 5 минут!
🔗 Ссылка на подключение:
{session.JoinLink}
Участники: {mentions}
Хорошей игры! 🎲
""";
// 4. Send
var message = await bot.SendMessage(
chatId: session.TelegramChatId,
messageThreadId: session.ThreadId,
text: text,
cancellationToken: ct);
// 5. Mark as sent (idempotent — link_message_id IS NULL guard in query)
await connection.ExecuteAsync(
"""
UPDATE sessions
SET link_message_id = @MessageId, updated_at = now()
WHERE id = @SessionId AND link_message_id IS NULL
""",
new { SessionId = sessionId, MessageId = message.MessageId });
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var directText = $"""
🎮 <b>Игра начинается через 5 минут</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)}
""";
await directSender.SendAsync(
players.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)),
directText,
"join-link",
sessionId,
ct);
}
logger.LogInformation(
"Join link sent for session {SessionId} ({Title}), message_id={MessageId}",
sessionId, session.Title, message.MessageId);
}
}
@@ -1,6 +1,7 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
@@ -17,12 +18,6 @@ internal sealed record AwaitingProposalDto(
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode);
internal sealed record VoteParticipantDto(
Guid PlayerId,
string DisplayName,
string? TelegramUsername,
long TelegramId = 0);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
@@ -1,5 +1,6 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
@@ -13,13 +14,6 @@ public sealed record HandleRescheduleVoteCommand(
long ChatId,
int MessageId);
internal sealed record VoteProposalDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt);
public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
@@ -83,8 +83,8 @@ public sealed class InitiateRescheduleHandler(
// 3. Create proposal in AwaitingTime status
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_proposals (session_id, proposed_by, status)
VALUES (@SessionId, @GmId, 'AwaitingTime')
INSERT INTO reschedule_proposals (session_id, proposed_by, source_platform, status)
VALUES (@SessionId, @GmId, 'Telegram', 'AwaitingTime')
""",
new { command.SessionId, GmId = command.TelegramUserId });
@@ -1,35 +1,25 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record DueRescheduleProposalDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
int? BatchMessageId,
internal sealed record TelegramProposalFieldsDto(
int? VoteMessageId,
int? BatchMessageId,
long TelegramChatId,
int? ThreadId,
string NotificationMode);
int? ThreadId);
public sealed class RescheduleVotingDeadlineService(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender,
ISystemClock clock,
PlatformDirectNotificationSender directSender,
RescheduleVotingFinalizer finalizer,
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -53,18 +43,7 @@ public sealed class RescheduleVotingDeadlineService(
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var proposalIds = (await connection.QueryAsync<Guid>(
"""
SELECT id
FROM reschedule_proposals
WHERE status = 'Voting'
AND voting_deadline_at IS NOT NULL
AND voting_deadline_at <= @Now
ORDER BY voting_deadline_at
LIMIT 25
""",
new { Now = clock.UtcNow.UtcDateTime })).ToList();
var proposalIds = await finalizer.GetDueProposalIdsAsync("Telegram", ct);
foreach (var proposalId in proposalIds)
{
@@ -82,212 +61,101 @@ public sealed class RescheduleVotingDeadlineService(
private async Task FinalizeProposal(Guid proposalId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var result = await finalizer.FinalizeAsync(proposalId, ct);
if (result is null)
return;
var proposal = await connection.QuerySingleOrDefaultAsync<DueRescheduleProposalDto>(
if (result.SourcePlatform != "Telegram")
{
logger.LogInformation(
"Skipping Telegram message handling for proposal {ProposalId} with source platform {SourcePlatform}",
proposalId,
result.SourcePlatform);
return;
}
await using var connection = await dataSource.OpenConnectionAsync(ct);
var telegramFields = await connection.QuerySingleOrDefaultAsync<TelegramProposalFieldsDto>(
"""
SELECT rp.id AS Id,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
rp.vote_message_id AS VoteMessageId,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId,
SELECT rp.vote_message_id AS VoteMessageId,
s.batch_message_id AS BatchMessageId,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId,
g.telegram_chat_id AS TelegramChatId
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.id = @ProposalId
AND rp.status = 'Voting'
AND rp.voting_deadline_at IS NOT NULL
AND rp.voting_deadline_at <= @Now
FOR UPDATE
""",
new { ProposalId = proposalId, Now = clock.UtcNow.UtcDateTime },
transaction);
new { ProposalId = proposalId });
if (proposal is null)
if (telegramFields is null)
{
logger.LogWarning("Could not find Telegram fields for proposal {ProposalId}", proposalId);
return;
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var voteCounts = options
.Select(option => new RescheduleOptionVoteCount(
option.OptionId,
votes.Count(vote => vote.OptionId == option.OptionId)))
.ToList();
var decision = RescheduleVoteRules.SelectWinner(voteCounts);
var selectedOption = decision.SelectedOptionId is { } selectedOptionId
? options.Single(x => x.OptionId == selectedOptionId)
: null;
if (selectedOption is not null)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
confirmation_sent_at = NULL,
link_message_id = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = selectedOption.ProposedAt, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = 'Pending',
responded_at = NULL
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction);
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET status = 'Approved',
selected_option_id = @SelectedOptionId,
proposed_at = @ProposedAt
WHERE id = @ProposalId
""",
new
{
ProposalId = proposal.Id,
SelectedOptionId = selectedOption.OptionId,
ProposedAt = selectedOption.ProposedAt
},
transaction);
}
else
{
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId",
new { ProposalId = proposal.Id },
transaction);
}
var directRecipients = participants
.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName))
var directRecipients = result.Participants
.Select(p => TelegramPlatformIds.User(p.TelegramId, p.DisplayName))
.ToList();
await transaction.CommitAsync(ct);
await TryUpdateVoteMessage(result, telegramFields, ct);
await TryUpdateVoteMessage(proposal, options, participants, votes, decision, selectedOption, ct);
if (selectedOption is not null)
if (result.SelectedOption is not null)
{
await TryUpdateBatchMessage(proposal, ct);
await TryUpdateBatchMessage(result, telegramFields, ct);
}
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(result.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await SendDirectResult(proposal, directRecipients, decision, selectedOption, ct);
await SendDirectResult(result, directRecipients, ct);
}
logger.LogInformation(
"Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
proposal.Id,
proposal.SessionId,
decision.Outcome);
"Updated Telegram messages for finalized reschedule proposal {ProposalId} for session {SessionId}",
result.ProposalId,
result.SessionId);
}
private async Task TryUpdateVoteMessage(
DueRescheduleProposalDto proposal,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyList<RescheduleOptionVoteDto> votes,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
RescheduleVotingFinalizerResult result,
TelegramProposalFieldsDto telegramFields,
CancellationToken ct)
{
if (proposal.VoteMessageId is null)
if (telegramFields.VoteMessageId is null)
return;
try
{
var resultText = selectedOption is not null
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {selectedOption.DisplayOrder}: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(decision.Reason)}";
var text = $"""
{HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title,
proposal.CurrentScheduledAt,
proposal.VotingDeadlineAt,
options,
participants,
votes)}
{resultText}
""";
await bot.EditMessageText(
chatId: proposal.TelegramChatId,
messageId: proposal.VoteMessageId.Value,
text: text,
parseMode: ParseMode.Html,
cancellationToken: ct);
await messenger.UpdateRescheduleVoteAsync(
new PlatformRescheduleVoteUpdate(
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
TelegramPlatformIds.Message(
telegramFields.TelegramChatId,
telegramFields.ThreadId,
telegramFields.VoteMessageId.Value),
result.ProposalId,
result.SessionId,
result.Title,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Decision,
result.SelectedOption,
result.Options,
result.Votes,
result.Participants),
ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", proposal.Id);
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", result.ProposalId);
}
}
private async Task TryUpdateBatchMessage(DueRescheduleProposalDto proposal, CancellationToken ct)
private async Task TryUpdateBatchMessage(
RescheduleVotingFinalizerResult result,
TelegramProposalFieldsDto telegramFields,
CancellationToken ct)
{
try
{
@@ -295,7 +163,7 @@ public sealed class RescheduleVotingDeadlineService(
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
new { result.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
@@ -309,60 +177,49 @@ public sealed class RescheduleVotingDeadlineService(
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
""",
new { proposal.BatchId })).ToList();
new { result.BatchId })).ToList();
if (proposal.BatchMessageId.HasValue)
if (telegramFields.BatchMessageId.HasValue)
{
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
var view = SessionBatchViewBuilder.Build(result.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
view,
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
TelegramPlatformIds.Message(telegramFields.TelegramChatId, telegramFields.ThreadId, telegramFields.BatchMessageId.Value)),
ct);
}
else
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
$"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
$"Расписание обновлено после голосования за перенос сессии \"{System.Net.WebUtility.HtmlEncode(result.Title)}\".",
ct);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", proposal.Id);
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", result.ProposalId);
}
}
private async Task SendDirectResult(
DueRescheduleProposalDto proposal,
IReadOnlyList<DirectNotificationRecipient> recipients,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
RescheduleVotingFinalizerResult result,
IReadOnlyList<PlatformUser> recipients,
CancellationToken ct)
{
var htmlText = selectedOption is not null
? $"""
✅ <b>Сессия перенесена по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Новое время: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)
"""
: $"""
❌ <b>Перенос сессии отклонён по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Время остаётся прежним: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
Причина: {System.Net.WebUtility.HtmlEncode(decision.Reason)}
""";
await directSender.SendAsync(
result.SelectedOption is not null
? PlatformDirectSessionNotificationKind.RescheduleApproved
: PlatformDirectSessionNotificationKind.RescheduleRejected,
recipients,
htmlText,
selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
proposal.SessionId,
result.SessionId,
result.Title,
result.SelectedOption?.ProposedAt.UtcDateTime ?? result.CurrentScheduledAt,
joinLink: null,
actorDisplayName: null,
reason: result.SelectedOption is null ? result.Decision.Reason : null,
ct);
}
}
@@ -1,9 +1,6 @@
namespace GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Shared.Platform;
public interface ISystemClock
{
DateTimeOffset UtcNow { get; }
}
namespace GmRelay.Bot.Infrastructure.Scheduling;
public sealed class SystemClock : ISystemClock
{
@@ -1,4 +1,6 @@
using System.Globalization;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Telegram.Bot;
using Telegram.Bot.Types;
@@ -125,6 +127,135 @@ public sealed class TelegramPlatformMessenger(
cancellationToken: ct);
}
public async Task<PlatformMessageRef> SendConfirmationRequestAsync(PlatformConfirmationRequest request, CancellationToken ct)
{
EnsureTelegram(request.Group.Platform);
var chatId = ParseLong(request.Group.ExternalGroupId);
var threadId = ParseNullableInt(request.Group.ExternalThreadId);
var message = await bot.SendMessage(
chatId: chatId,
messageThreadId: threadId,
text: BuildConfirmationText(request),
parseMode: ParseMode.Html,
replyMarkup: BuildRsvpKeyboard(request.SessionId),
cancellationToken: ct);
return TelegramPlatformIds.Message(chatId, threadId, message.MessageId);
}
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
{
var request = update.Request;
EnsureTelegram(request.Group.Platform);
var existingMessage = request.ExistingMessage
?? throw new ArgumentException("Existing confirmation message reference is required.", nameof(update));
EnsureTelegram(existingMessage.Platform);
await bot.EditMessageText(
chatId: ParseLong(existingMessage.ExternalGroupId),
messageId: ParseInt(existingMessage.ExternalMessageId),
text: BuildConfirmationText(request),
parseMode: ParseMode.Html,
replyMarkup: update.DisableActions ? null : BuildRsvpKeyboard(request.SessionId),
cancellationToken: ct);
}
public async Task<PlatformMessageRef> SendJoinLinkNotificationAsync(
PlatformJoinLinkNotification notification,
CancellationToken ct)
{
EnsureTelegram(notification.Group.Platform);
var chatId = ParseLong(notification.Group.ExternalGroupId);
var threadId = ParseNullableInt(notification.Group.ExternalThreadId);
var message = await bot.SendMessage(
chatId: chatId,
messageThreadId: threadId,
text: BuildJoinLinkText(notification),
cancellationToken: ct);
return TelegramPlatformIds.Message(chatId, threadId, message.MessageId);
}
public Task SendDirectSessionNotificationAsync(
PlatformDirectSessionNotification notification,
CancellationToken ct)
{
EnsureTelegram(notification.Recipient.Platform);
return bot.SendMessage(
chatId: ParseLong(notification.Recipient.ExternalUserId),
text: BuildDirectNotificationText(notification),
parseMode: ParseMode.Html,
cancellationToken: ct);
}
public async Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct)
{
switch (notification.Kind)
{
case PlatformRsvpOutcomeKind.GroupAllConfirmed:
if (notification.Group is null)
{
throw new ArgumentException("Group notification requires a group.", nameof(notification));
}
EnsureTelegram(notification.Group.Platform);
await bot.SendMessage(
chatId: ParseLong(notification.Group.ExternalGroupId),
messageThreadId: ParseNullableInt(notification.Group.ExternalThreadId),
text: $"🎉 Игра «{notification.Title}» подтверждена! Все участники на месте.",
cancellationToken: ct);
break;
case PlatformRsvpOutcomeKind.GmAllConfirmed:
case PlatformRsvpOutcomeKind.GmPlayerDeclined:
foreach (var recipient in notification.Recipients)
{
EnsureTelegram(recipient.Platform);
await bot.SendMessage(
chatId: ParseLong(recipient.ExternalUserId),
text: BuildRsvpOutcomeDirectText(notification),
parseMode: ParseMode.Html,
cancellationToken: ct);
}
break;
default:
throw new ArgumentOutOfRangeException(nameof(notification), notification.Kind, "Unknown RSVP outcome kind.");
}
}
public Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct)
{
EnsureTelegram(update.Group.Platform);
EnsureTelegram(update.ExistingMessage.Platform);
var resultText = update.SelectedOption is not null
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {update.SelectedOption.DisplayOrder}: <b>{update.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(update.Decision.Reason)}";
var text = $"""
{HandleRescheduleTimeInputHandler.BuildVotingMessage(
update.Title,
update.CurrentScheduledAt,
update.VotingDeadlineAt,
update.Options,
update.Participants,
update.Votes)}
{resultText}
""";
return bot.EditMessageText(
chatId: ParseLong(update.ExistingMessage.ExternalGroupId),
messageId: ParseInt(update.ExistingMessage.ExternalMessageId),
text: text,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
private async Task<Message> SendScheduleTextMessage(
long chatId,
int? threadId,
@@ -139,6 +270,134 @@ public sealed class TelegramPlatformMessenger(
replyMarkup: markup,
cancellationToken: ct);
private static string BuildConfirmationText(PlatformConfirmationRequest request)
{
var confirmed = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
var declined = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
var pending = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList();
var lines = new List<string>
{
$"🎲 Подтвердите участие в «{System.Net.WebUtility.HtmlEncode(request.Title)}»",
$"📅 {request.ScheduledAt.FormatMoscow()} (МСК)",
string.Empty
};
foreach (var participant in confirmed)
{
lines.Add($" ✅ {FormatTelegramParticipant(participant)}");
}
foreach (var participant in declined)
{
lines.Add($" ❌ <s>{FormatTelegramParticipant(participant)}</s>");
}
foreach (var participant in pending)
{
lines.Add($" ⏳ {FormatTelegramParticipant(participant)}");
}
lines.Add(string.Empty);
if (request.Participants.Count > 0 && confirmed.Count == request.Participants.Count)
{
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{request.Participants.Count})");
}
else if (declined.Count > 0)
{
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{request.Participants.Count} подтвердили)");
}
else
{
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{request.Participants.Count})");
}
return string.Join("\n", lines);
}
private static string BuildJoinLinkText(PlatformJoinLinkNotification notification)
{
var mentions = string.Join(", ", notification.ConfirmedPlayers.Select(FormatTelegramParticipant));
return $"""
🎮 Игра «{notification.Title}» начинается через 5 минут!
🔗 Ссылка на подключение:
{notification.JoinLink}
Участники: {mentions}
Хорошей игры! 🎲
""";
}
private static string BuildDirectNotificationText(PlatformDirectSessionNotification notification) =>
notification.Kind switch
{
PlatformDirectSessionNotificationKind.ConfirmationRequest => $"""
🎲 <b>Подтвердите участие в игре</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
Ответьте кнопкой в групповом сообщении расписания.
""",
PlatformDirectSessionNotificationKind.OneHourReminder => $"""
⏰ <b>Игра начнётся примерно через 1 час</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
""",
PlatformDirectSessionNotificationKind.JoinLink => $"""
🎮 <b>Игра начинается через 5 минут</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
""",
PlatformDirectSessionNotificationKind.RescheduleApproved => $"""
✅ <b>Сессия перенесена по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 Новое время: <b>{notification.ScheduledAt.FormatMoscow()}</b> (МСК)
""",
PlatformDirectSessionNotificationKind.RescheduleRejected => $"""
❌ <b>Перенос сессии отклонён по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
📅 Время остаётся прежним: <b>{notification.ScheduledAt.FormatMoscow()}</b> (МСК)
Причина: {System.Net.WebUtility.HtmlEncode(notification.Reason ?? string.Empty)}
""",
_ => BuildFallbackDirectText(notification)
};
private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) =>
$"<b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>\n{notification.ScheduledAt.FormatMoscow()} (МСК)";
private static string BuildRsvpOutcomeDirectText(PlatformRsvpOutcomeNotification notification) =>
notification.Kind switch
{
PlatformRsvpOutcomeKind.GmAllConfirmed =>
$"✅ Все подтвердили участие в «{System.Net.WebUtility.HtmlEncode(notification.Title)}» ({notification.ScheduledAt.FormatMoscow()} МСК).",
PlatformRsvpOutcomeKind.GmPlayerDeclined =>
$"🚨 Отмена! {System.Net.WebUtility.HtmlEncode(notification.ActorDisplayName ?? "Игрок")} не сможет прийти на игру «{System.Net.WebUtility.HtmlEncode(notification.Title)}».",
_ => System.Net.WebUtility.HtmlEncode(notification.Title)
};
private static InlineKeyboardMarkup BuildRsvpKeyboard(Guid sessionId) =>
new([
[
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{sessionId}"),
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{sessionId}")
]
]);
private static string FormatTelegramParticipant(PlatformSessionParticipant participant) =>
participant.User.ExternalUsername is not null
? $"@{participant.User.ExternalUsername}"
: System.Net.WebUtility.HtmlEncode(participant.User.DisplayName);
private async Task TrySendScheduleImageOnly(
long chatId,
int? threadId,
@@ -1,7 +1,8 @@
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.ListSessions;
using GmRelay.Bot.Features.Sessions.ExportCalendar;
@@ -186,11 +187,11 @@ public sealed class UpdateRouter(
var command = new HandleRsvpCommand(
SessionId: sessionId,
TelegramUserId: query.From.Id,
User: user,
Status: status,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
InteractionId: query.Id,
Group: group,
ConfirmationMessage: scheduleMessage);
await rsvpHandler.HandleAsync(command, ct);
}
@@ -0,0 +1,19 @@
-- =============================================================
-- V018: Add platform columns to reschedule_proposals
-- =============================================================
-- Add platform columns to reschedule_proposals to support Discord reschedule voting.
-- proposed_by is made nullable so Discord proposals can leave it NULL
-- (Discord snowflakes don't fit in BIGINT safely).
-- =============================================================
ALTER TABLE reschedule_proposals
ALTER COLUMN proposed_by DROP NOT NULL;
ALTER TABLE reschedule_proposals
ADD COLUMN source_platform VARCHAR(50),
ADD COLUMN proposed_by_external_user_id VARCHAR(255);
UPDATE reschedule_proposals
SET source_platform = 'Telegram',
proposed_by_external_user_id = proposed_by::TEXT
WHERE source_platform IS NULL;
@@ -0,0 +1,18 @@
-- =============================================================
-- V019: Rename session_audit_log.actor_telegram_id to actor_external_user_id
-- =============================================================
-- Scope: Support platform-agnostic audit log identity.
-- =============================================================
ALTER TABLE session_audit_log
ADD COLUMN actor_external_user_id VARCHAR(255);
UPDATE session_audit_log
SET actor_external_user_id = actor_telegram_id::TEXT
WHERE actor_external_user_id IS NULL;
ALTER TABLE session_audit_log
ALTER COLUMN actor_external_user_id SET NOT NULL;
ALTER TABLE session_audit_log
DROP COLUMN actor_telegram_id;
+15 -7
View File
@@ -1,15 +1,18 @@
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Health;
using GmRelay.Bot.Infrastructure.Logging;
using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Features.Reminders.SendJoinLink;
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Infrastructure.Scheduling;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
@@ -52,17 +55,19 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
});
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Telegram));
// ── Feature handlers (explicit registration — AOT safe) ──────────────
builder.Services.AddSingleton<SendConfirmationHandler>();
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
builder.Services.AddSingleton<DirectSessionNotificationSender>();
builder.Services.AddSingleton<PlatformDirectNotificationSender>();
builder.Services.AddSingleton<HandleRsvpHandler>();
builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<CreateSessionHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
@@ -73,6 +78,9 @@ builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.Expor
builder.Services.AddSingleton<InitiateRescheduleHandler>();
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
builder.Services.AddSingleton<DirectSessionNotificationSender>();
// ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>();
@@ -81,7 +89,7 @@ builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
builder.Services.AddHostedService<TelegramBotService>();
// ── Clock and scheduling ──────────────────────────────────────────────
builder.Services.AddSingleton<ISystemClock, SystemClock>();
builder.Services.AddSingleton<ISystemClock, GmRelay.Bot.Infrastructure.Scheduling.SystemClock>();
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
// ── Session scheduler ────────────────────────────────────────────────
+7 -1
View File
@@ -661,7 +661,13 @@
}
},
"gmrelay.shared": {
"type": "Project"
"type": "Project",
"dependencies": {
"Dapper": "[2.1.72, )",
"Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )",
"Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )",
"Npgsql": "[10.0.2, )"
}
}
},
"net10.0/win-x64": {
+6 -1
View File
@@ -13,8 +13,13 @@ WORKDIR /src/src/GmRelay.DiscordBot
RUN dotnet publish "GmRelay.DiscordBot.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/runtime:10.0-noble AS final
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS final
WORKDIR /app
# Install wget for healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends wget \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "GmRelay.DiscordBot.dll"]
@@ -0,0 +1,117 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
[SlashCommand("reschedule", "Initiate reschedule voting for a session")]
public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordRescheduleHandler _handler;
private readonly ILogger<DiscordRescheduleCommand> _logger;
public DiscordRescheduleCommand(DiscordRescheduleHandler handler, ILogger<DiscordRescheduleCommand> logger)
{
_handler = handler;
_logger = logger;
}
public async Task ExecuteAsync(
[SlashCommandParameter(Name = "session", Description = "Session ID to reschedule")] string sessionIdText,
[SlashCommandParameter(Name = "option1", Description = "First time option (YYYY-MM-DD HH:mm)")] string option1,
[SlashCommandParameter(Name = "option2", Description = "Second time option (YYYY-MM-DD HH:mm)")] string option2,
[SlashCommandParameter(Name = "option3", Description = "Third time option (optional)")] string? option3 = null,
[SlashCommandParameter(Name = "deadline", Description = "Voting deadline (YYYY-MM-DD HH:mm)")] string deadline = "")
{
var guild = Context.Guild
?? throw new InvalidOperationException("This command can only be used in a guild.");
if (!Guid.TryParse(sessionIdText, out var sessionId))
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("❌ Некорректный ID сессии."));
return;
}
var options = new List<string> { option1, option2 };
if (!string.IsNullOrWhiteSpace(option3))
options.Add(option3);
var parsedOptions = new List<DateTimeOffset>();
foreach (var opt in options)
{
var result = DiscordNewSessionHandler.ParseTimeInput(opt);
if (!result.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"❌ {opt}: {result.Error}"));
return;
}
parsedOptions.Add(result.Value);
}
var deadlineResult = DiscordNewSessionHandler.ParseTimeInput(deadline);
if (!deadlineResult.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"❌ Дедлайн: {deadlineResult.Error}"));
return;
}
if (deadlineResult.Value >= parsedOptions.Min())
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("❌ Дедлайн должен быть раньше первого варианта времени."));
return;
}
var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id);
try
{
var result = await _handler.HandleAsync(
guildId: guild.Id.ToString(),
channelId: Context.Channel.Id.ToString(),
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
resolvedPermissions: resolvedPermissions,
guildOwnerId: guild.OwnerId,
sessionId: sessionId,
options: parsedOptions,
deadline: deadlineResult.Value,
CancellationToken.None);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(
$"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC."));
}
catch (UnauthorizedAccessException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($":no_entry: {ex.Message}"));
}
catch (InvalidOperationException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($":warning: {ex.Message}"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initiate reschedule for session {SessionId}", sessionId);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(":boom: Ошибка при запуске голосования."));
}
}
private static ulong GetResolvedPermissions(NetCord.Gateway.Guild guild, ulong userId)
{
if (!guild.Users.TryGetValue(userId, out var guildUser))
return 0;
ulong resolved = 0;
foreach (var roleId in guildUser.RoleIds)
{
if (guild.Roles.TryGetValue(roleId, out var role))
resolved |= (ulong)role.Permissions;
}
return resolved;
}
}
@@ -0,0 +1,155 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using NetCord;
using NetCord.Rest;
using Npgsql;
public sealed record DiscordRescheduleResult(Guid ProposalId, IReadOnlyList<RescheduleOptionDto> Options, DateTimeOffset Deadline);
public sealed class DiscordRescheduleHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
RestClient restClient,
ILogger<DiscordRescheduleHandler> logger)
{
public async Task<DiscordRescheduleResult> HandleAsync(
string guildId,
string channelId,
ulong userId,
string userDisplayName,
ulong resolvedPermissions,
ulong guildOwnerId,
Guid sessionId,
IReadOnlyList<DateTimeOffset> options,
DateTimeOffset deadline,
CancellationToken ct)
{
// 1. Permission check + read-only validation (before Discord message)
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
var dbManagerUserIds = await readConnection.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))
{
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут переносить сессии.");
}
// 2. Ensure player exists
await readConnection.ExecuteAsync(
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, 'Discord', @UserId, @Name)
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",
new { Name = userDisplayName, UserId = userId.ToString() });
// 3. Verify session exists
var session = await readConnection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
"""
SELECT s.title AS Title, s.scheduled_at AS CurrentScheduledAt
FROM sessions s
WHERE s.id = @SessionId AND s.status != @Cancelled
""",
new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled });
if (session is null)
throw new InvalidOperationException("Сессия не найдена или отменена.");
// 4. Check no active proposal
var hasActive = await readConnection.ExecuteScalarAsync<bool>(
"SELECT EXISTS (SELECT 1 FROM reschedule_proposals WHERE session_id = @SessionId AND status IN ('AwaitingTime', 'Voting'))",
new { SessionId = sessionId });
if (hasActive)
throw new InvalidOperationException("Уже есть активный запрос на перенос этой сессии.");
// 5. Load participants for rendering
var participants = (await readConnection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active
""",
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
// 6. Prepare proposal data
var proposalId = Guid.NewGuid();
var optionDtos = options.Select((o, i) => new RescheduleOptionDto(Guid.NewGuid(), i + 1, o)).ToList();
// 7. Build and send Discord vote message BEFORE transaction
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(session.Title, session.CurrentScheduledAt, deadline, optionDtos, participants, []);
var channelIdUlong = ulong.Parse(channelId);
// NOTE: Discord message is sent before DB transaction to avoid orphaned proposals
// if the send fails. There is a negligible race window where the message is visible
// before the DB commit; in practice users cannot click faster than the transaction commits.
var sentMessage = await restClient.SendMessageAsync(
channelIdUlong,
new MessageProperties()
.WithEmbeds(new[] { embed })
.WithComponents(new[] { actionRow }));
// 8. Create proposal + options + platform_messages in transaction
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_proposals (id, session_id, proposed_by, source_platform, proposed_by_external_user_id, status, voting_deadline_at)
VALUES (@Id, @SessionId, NULL, 'Discord', @ProposedBy, 'Voting', @Deadline)
""",
new { Id = proposalId, SessionId = sessionId, ProposedBy = userId.ToString(), Deadline = deadline.UtcDateTime },
transaction);
foreach (var option in optionDtos)
{
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
""",
new { option.OptionId, ProposalId = proposalId, option.ProposedAt, option.DisplayOrder },
transaction);
}
await connection.ExecuteAsync(
"""
INSERT INTO platform_messages (platform, group_id, session_id, external_channel_id, external_message_id, purpose)
VALUES ('Discord', (SELECT id FROM game_groups WHERE platform = 'Discord' AND external_group_id = @GuildId), @SessionId, @ChannelId, @MessageId, 'reschedule_vote')
""",
new { GuildId = guildId, SessionId = sessionId, ChannelId = channelId, MessageId = sentMessage.Id.ToString() },
transaction);
await transaction.CommitAsync(ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Transaction failed after Discord message sent; deleting orphaned message");
try { await restClient.DeleteMessageAsync(channelIdUlong, sentMessage.Id); } catch { /* best effort */ }
throw;
}
logger.LogInformation("Discord reschedule voting started for session {SessionId}, proposal {ProposalId}", sessionId, proposalId);
return new DiscordRescheduleResult(proposalId, optionDtos, deadline);
}
}
internal sealed record RescheduleSessionInfoDto(string Title, DateTime CurrentScheduledAt);
@@ -0,0 +1,131 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using Dapper;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Npgsql;
using NetCord.Rest;
public sealed record DiscordRescheduleVoteInput(
Guid OptionId, ulong UserId, string InteractionId,
string GuildId, string ChannelId, string MessageId);
public sealed class DiscordRescheduleVoteHandler(
NpgsqlDataSource dataSource,
RestClient restClient,
ILogger<DiscordRescheduleVoteHandler> logger)
{
public async Task<string> HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Load proposal + option
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title, s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { input.OptionId },
transaction);
if (proposal is null)
return "Голосование уже завершено или не найдено.";
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
return "Дедлайн уже прошёл. Результаты скоро будут применены.";
// 2. Verify participant (Discord platform)
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.platform = 'Discord'
AND p.external_user_id = @UserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, UserId = input.UserId.ToString(), Active = ParticipantRegistrationStatus.Active },
transaction);
if (playerId is null)
return "Вы не являетесь участником этой сессии.";
// 3. Upsert vote
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id, voted_at = now()
""",
new { ProposalId = proposal.Id, PlayerId = playerId.Value, input.OptionId },
transaction);
// 4. Reload participants, options, votes for re-rendering
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId, display_order AS DisplayOrder, proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
// 5. Re-render and update Discord vote message
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt,
options, participants, votes);
var channelIdUlong = ulong.Parse(input.ChannelId);
var messageIdUlong = ulong.Parse(input.MessageId);
try
{
await restClient.ModifyMessageAsync(channelIdUlong, messageIdUlong, options =>
{
options.Embeds = new[] { embed };
options.Components = new[] { actionRow };
});
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id);
}
return "Ваш голос учтён. До дедлайна его можно изменить.";
}
}
@@ -0,0 +1,196 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using Dapper;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
public sealed class DiscordRescheduleVotingDeadlineService(
NpgsqlDataSource dataSource,
RescheduleVotingFinalizer finalizer,
IPlatformMessenger messenger,
ILogger<DiscordRescheduleVotingDeadlineService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await ProcessDueProposals(stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await ProcessDueProposals(stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
}
private async Task ProcessDueProposals(CancellationToken ct)
{
try
{
var proposalIds = await finalizer.GetDueProposalIdsAsync("Discord", ct);
foreach (var id in proposalIds)
{
await TryFinalizeAsync(id, ct);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process Discord reschedule proposals");
}
}
private async Task TryFinalizeAsync(Guid proposalId, CancellationToken ct)
{
try
{
var result = await finalizer.FinalizeAsync(proposalId, ct);
if (result is null)
return;
if (result.SourcePlatform != "Discord")
return;
await TryUpdateDiscordVoteMessage(result, ct);
if (result.SelectedOption is not null)
{
await TryUpdateBatchScheduleAsync(result, ct);
}
logger.LogInformation(
"Finalized Discord reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
proposalId,
result.SessionId,
result.Decision.Outcome);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to finalize Discord proposal {ProposalId}", proposalId);
}
}
private async Task TryUpdateDiscordVoteMessage(RescheduleVotingFinalizerResult result, CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var msgRef = await connection.QuerySingleOrDefaultAsync<PlatformMessageRefDto>(
"""
SELECT g.external_group_id AS ExternalGroupId,
COALESCE(pm.external_channel_id, g.external_channel_id, g.external_group_id) AS ExternalChannelId,
pm.external_message_id AS ExternalMessageId
FROM platform_messages pm
JOIN game_groups g ON g.id = pm.group_id
WHERE pm.session_id = @SessionId AND pm.purpose = 'reschedule_vote' AND pm.platform = 'Discord'
ORDER BY pm.created_at DESC
LIMIT 1
""",
new { result.SessionId });
if (msgRef is null)
return;
var group = CreateDiscordGroup(msgRef);
await messenger.UpdateRescheduleVoteAsync(
new PlatformRescheduleVoteUpdate(
group,
new PlatformMessageRef(
PlatformKind.Discord,
msgRef.ExternalGroupId,
null,
msgRef.ExternalMessageId),
result.ProposalId,
result.SessionId,
result.Title,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Decision,
result.SelectedOption,
result.Options,
result.Votes,
result.Participants),
ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update Discord vote message for session {SessionId}", result.SessionId);
}
}
private async Task TryUpdateBatchScheduleAsync(RescheduleVotingFinalizerResult result, CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var batchRef = await connection.QuerySingleOrDefaultAsync<PlatformMessageRefDto>(
"""
SELECT g.external_group_id AS ExternalGroupId,
COALESCE(pm.external_channel_id, g.external_channel_id, g.external_group_id) AS ExternalChannelId,
pm.external_message_id AS ExternalMessageId
FROM platform_messages pm
JOIN game_groups g ON g.id = pm.group_id
WHERE pm.batch_id = @BatchId AND pm.purpose = 'schedule' AND pm.platform = 'Discord'
ORDER BY pm.created_at DESC
LIMIT 1
""",
new { result.BatchId });
if (batchRef is null)
return;
var sessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { result.BatchId })).ToList();
var participants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC
""",
new { result.BatchId })).ToList();
var view = SessionBatchViewBuilder.Build(result.Title, sessions, participants);
var group = CreateDiscordGroup(batchRef);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
group,
view,
new PlatformMessageRef(
PlatformKind.Discord,
batchRef.ExternalGroupId,
null,
batchRef.ExternalMessageId)),
ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update Discord batch schedule for session {SessionId}", result.SessionId);
}
}
private static PlatformGroup CreateDiscordGroup(PlatformMessageRefDto message) =>
new(
PlatformKind.Discord,
message.ExternalGroupId,
message.ExternalGroupId,
message.ExternalChannelId);
internal sealed record PlatformMessageRefDto(
string ExternalGroupId,
string ExternalChannelId,
string ExternalMessageId);
}
@@ -0,0 +1,64 @@
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record DiscordSessionInteractionInput(
Guid SessionId,
string InteractionId,
string GuildId,
string ChannelId,
string MessageId,
ulong UserId,
string Username,
string? DisplayName);
public static class DiscordSessionInteractionMapper
{
public static bool TryParseCustomId(string customId, string expectedAction, out Guid sessionId)
{
sessionId = default;
var parts = customId.Split(':', 2);
return parts.Length == 2
&& string.Equals(parts[0], expectedAction, StringComparison.Ordinal)
&& Guid.TryParse(parts[1], out sessionId);
}
public static JoinSessionCommand CreateJoinCommand(DiscordSessionInteractionInput input) =>
new(
SessionId: input.SessionId,
User: CreateUser(input),
InteractionId: input.InteractionId,
Group: CreateGroup(input),
ScheduleMessage: CreateMessageRef(input));
public static LeaveSessionCommand CreateLeaveCommand(DiscordSessionInteractionInput input) =>
new(
SessionId: input.SessionId,
User: CreateUser(input),
InteractionId: input.InteractionId,
Group: CreateGroup(input),
ScheduleMessage: CreateMessageRef(input));
private static PlatformUser CreateUser(DiscordSessionInteractionInput input) =>
new(
PlatformKind.Discord,
input.UserId.ToString(System.Globalization.CultureInfo.InvariantCulture),
string.IsNullOrWhiteSpace(input.DisplayName) ? input.Username : input.DisplayName,
input.Username);
private static PlatformGroup CreateGroup(DiscordSessionInteractionInput input) =>
new(
PlatformKind.Discord,
input.GuildId,
input.GuildId,
input.ChannelId);
private static PlatformMessageRef CreateMessageRef(DiscordSessionInteractionInput input) =>
new(
PlatformKind.Discord,
input.GuildId,
null,
input.MessageId);
}
@@ -0,0 +1,201 @@
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
using System.Globalization;
using NetCord;
using NetCord.Rest;
using NetCord.Services.ComponentInteractions;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed class DiscordSessionInteractionModule(
JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
HandleRsvpHandler rsvpHandler,
DiscordRescheduleVoteHandler voteHandler,
DiscordInteractionReplyCache interactionReplies,
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
{
[ComponentInteraction("join_session")]
public async Task JoinAsync(string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
try
{
await joinSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateJoinCommand(input),
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
}
[ComponentInteraction("leave_session")]
public async Task LeaveAsync(string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
try
{
await leaveSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateLeaveCommand(input),
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
}
[ComponentInteraction("rsvp")]
public async Task RsvpAsync(string status, string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var rsvpStatus = status switch
{
"confirm" => RsvpStatus.Confirmed,
"decline" => RsvpStatus.Declined,
_ => null
};
if (rsvpStatus is null)
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
try
{
await rsvpHandler.HandleAsync(
new HandleRsvpCommand(
parsedSessionId,
new PlatformUser(
PlatformKind.Discord,
Context.User.Id.ToString(CultureInfo.InvariantCulture),
string.IsNullOrWhiteSpace(Context.User.GlobalName) ? Context.User.Username : Context.User.GlobalName,
Context.User.Username),
rsvpStatus,
input.InteractionId,
new PlatformGroup(
PlatformKind.Discord,
input.GuildId,
input.GuildId,
input.ChannelId),
new PlatformMessageRef(
PlatformKind.Discord,
input.GuildId,
null,
input.MessageId)),
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
}
[ComponentInteraction("reschedule_vote")]
public async Task RescheduleVoteAsync(string optionId)
{
if (!Guid.TryParse(optionId, out var parsedOptionId))
{
await RespondAsync(CreateEphemeralReply("Vote button is outdated."));
return;
}
var input = CreateInput(Guid.Empty); // sessionId not needed for vote routing
var voteInput = new DiscordRescheduleVoteInput(
parsedOptionId,
Context.User.Id,
Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
input.GuildId,
input.ChannelId,
input.MessageId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
string replyText;
try
{
replyText = await voteHandler.HandleAsync(voteInput, CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord reschedule vote for option {OptionId}", parsedOptionId);
await CompleteResponseAsync("Не удалось обработать голос.");
return;
}
await CompleteResponseAsync(replyText);
}
private DiscordSessionInteractionInput CreateInput(Guid sessionId)
{
var guild = Context.Guild
?? throw new InvalidOperationException("Session buttons can only be used in a guild.");
var message = Context.Interaction.Message
?? throw new InvalidOperationException("Session button interaction must include a message.");
return new DiscordSessionInteractionInput(
SessionId: sessionId,
InteractionId: Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
GuildId: guild.Id.ToString(CultureInfo.InvariantCulture),
ChannelId: Context.Channel.Id.ToString(CultureInfo.InvariantCulture),
MessageId: message.Id.ToString(CultureInfo.InvariantCulture),
UserId: Context.User.Id,
Username: Context.User.Username,
DisplayName: Context.User.GlobalName);
}
private async Task CompleteWithStoredReplyAsync(string interactionId)
{
var reply = interactionReplies.Take(interactionId);
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
}
private Task CompleteResponseAsync(string text) =>
ModifyResponseAsync(options => options.Content = text);
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent(text)
.WithFlags(MessageFlags.Ephemeral));
}
@@ -0,0 +1,17 @@
using System.Collections.Concurrent;
using GmRelay.Shared.Platform;
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordInteractionReplyCache
{
private readonly ConcurrentDictionary<string, PlatformInteractionReply> replies = new(StringComparer.Ordinal);
public void Store(PlatformInteractionReply reply) =>
replies[reply.InteractionId] = reply;
public PlatformInteractionReply? Take(string interactionId) =>
replies.TryRemove(interactionId, out var reply)
? reply
: null;
}
@@ -1,19 +1,43 @@
using System.Globalization;
using System.Text;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging;
using NetCord;
using NetCord.Rest;
namespace GmRelay.DiscordBot.Infrastructure.Discord;
public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformMessenger
public sealed class DiscordPlatformMessenger : IPlatformMessenger
{
private readonly RestClient restClient;
private readonly DiscordInteractionReplyCache interactionReplies;
private readonly ILogger<DiscordPlatformMessenger>? logger;
public DiscordPlatformMessenger(
RestClient restClient,
DiscordInteractionReplyCache interactionReplies)
: this(restClient, interactionReplies, logger: null)
{
}
public DiscordPlatformMessenger(
RestClient restClient,
DiscordInteractionReplyCache interactionReplies,
ILogger<DiscordPlatformMessenger>? logger)
{
this.restClient = restClient;
this.interactionReplies = interactionReplies;
this.logger = logger;
}
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
var channelId = ulong.Parse(message.Group.ExternalChannelId
?? message.Group.ExternalGroupId);
var channelId = GetChannelId(message.Group);
var msg = await restClient.SendMessageAsync(
channelId,
@@ -25,7 +49,7 @@ public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformM
PlatformKind.Discord,
message.Group.ExternalGroupId,
null,
msg.Id.ToString());
msg.Id.ToString(CultureInfo.InvariantCulture));
}
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
@@ -35,9 +59,8 @@ public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformM
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
var channelId = ulong.Parse(message.Group.ExternalChannelId
?? message.Group.ExternalGroupId);
var messageId = ulong.Parse(message.ExistingMessage.ExternalMessageId);
var channelId = GetChannelId(message.Group);
var messageId = ParseSnowflake(message.ExistingMessage.ExternalMessageId);
await restClient.ModifyMessageAsync(
channelId,
@@ -49,18 +72,19 @@ public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformM
});
}
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
{
return Task.CompletedTask;
await restClient.SendMessageAsync(GetChannelId(group), htmlText);
}
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
return Task.CompletedTask;
await SendDirectContentAsync(message.Recipient, message.HtmlText, ct);
}
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct)
{
interactionReplies.Store(reply);
return Task.CompletedTask;
}
@@ -68,4 +92,281 @@ public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformM
{
return Task.CompletedTask;
}
public async Task<PlatformMessageRef> SendConfirmationRequestAsync(
PlatformConfirmationRequest request,
CancellationToken ct)
{
var channelId = GetChannelId(request.Group);
var message = await restClient.SendMessageAsync(
channelId,
new MessageProperties()
.WithEmbeds([BuildConfirmationEmbed(request)])
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
return new PlatformMessageRef(
PlatformKind.Discord,
request.Group.ExternalGroupId,
null,
message.Id.ToString(CultureInfo.InvariantCulture));
}
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
{
if (update.Request.ExistingMessage is null)
return;
var channelId = GetChannelId(update.Request.Group);
var messageId = ParseSnowflake(update.Request.ExistingMessage.ExternalMessageId);
var components = BuildRsvpRows(update.Request.SessionId, update.DisableActions);
await restClient.ModifyMessageAsync(
channelId,
messageId,
options =>
{
options.Embeds = [BuildConfirmationEmbed(update.Request)];
options.Components = components;
});
}
public async Task<PlatformMessageRef> SendJoinLinkNotificationAsync(
PlatformJoinLinkNotification notification,
CancellationToken ct)
{
var channelId = GetChannelId(notification.Group);
var message = await restClient.SendMessageAsync(
channelId,
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
return new PlatformMessageRef(
PlatformKind.Discord,
notification.Group.ExternalGroupId,
null,
message.Id.ToString(CultureInfo.InvariantCulture));
}
public async Task SendDirectSessionNotificationAsync(
PlatformDirectSessionNotification notification,
CancellationToken ct)
{
try
{
await SendDirectContentAsync(
notification.Recipient,
BuildDirectContent(notification),
ct);
}
catch (Exception ex)
{
logger?.LogWarning(
ex,
"Failed to send Discord direct notification {NotificationKind} for session {SessionId} to user {ExternalUserId}",
notification.Kind,
notification.SessionId,
notification.Recipient.ExternalUserId);
}
}
public async Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct)
{
if (notification.Kind == PlatformRsvpOutcomeKind.GroupAllConfirmed && notification.Group is not null)
{
await restClient.SendMessageAsync(
GetChannelId(notification.Group),
BuildRsvpGroupOutcomeContent(notification));
return;
}
var directKind = notification.Kind == PlatformRsvpOutcomeKind.GmPlayerDeclined
? PlatformDirectSessionNotificationKind.RsvpDeclined
: PlatformDirectSessionNotificationKind.RsvpAllConfirmed;
foreach (var recipient in notification.Recipients)
{
await SendDirectSessionNotificationAsync(
new PlatformDirectSessionNotification(
directKind,
recipient,
notification.SessionId,
notification.Title,
notification.ScheduledAt,
ActorDisplayName: notification.ActorDisplayName),
ct);
}
}
public async Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct)
{
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
update.Title,
update.CurrentScheduledAt,
update.VotingDeadlineAt,
update.Options,
update.Participants,
update.Votes);
var disabledRow = new ActionRowProperties();
foreach (var button in actionRow.OfType<ButtonProperties>())
{
disabledRow.Add(new ButtonProperties(
button.CustomId,
button.Label ?? string.Empty,
ButtonStyle.Secondary)
{
Disabled = true
});
}
var updatedEmbed = embed.WithDescription(
$"{embed.Description}\n\n{BuildRescheduleResultText(update)}");
await restClient.ModifyMessageAsync(
GetChannelId(update.Group),
ParseSnowflake(update.ExistingMessage.ExternalMessageId),
options =>
{
options.Embeds = [updatedEmbed];
options.Components = [disabledRow];
});
}
private static EmbedProperties BuildConfirmationEmbed(PlatformConfirmationRequest request)
{
var embed = new EmbedProperties()
.WithTitle($"Подтверждение: {request.Title}")
.WithDescription(BuildConfirmationDescription(request))
.WithColor(new Color(0x5865F2));
return embed.AddFields(
[
BuildParticipantField("Подтвердили", request.Participants, RsvpStatus.Confirmed),
BuildParticipantField("Отказались", request.Participants, RsvpStatus.Declined),
BuildParticipantField("Ожидаем ответ", request.Participants, RsvpStatus.Pending)
]);
}
private static string BuildConfirmationDescription(PlatformConfirmationRequest request) =>
$"Время: **{request.ScheduledAt.FormatMoscow()}** (МСК)\n" +
"Подтвердите участие кнопкой ниже.";
private static EmbedFieldProperties BuildParticipantField(
string title,
IReadOnlyList<PlatformSessionParticipant> participants,
string status)
{
var values = participants
.Where(participant => participant.RsvpStatus == status)
.Select(FormatDiscordParticipant)
.ToList();
return new EmbedFieldProperties()
.WithName(title)
.WithValue(values.Count == 0 ? "—" : string.Join("\n", values))
.WithInline();
}
private static EmbedProperties BuildJoinLinkEmbed(PlatformJoinLinkNotification notification)
{
var mentions = notification.ConfirmedPlayers.Count == 0
? "—"
: string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User)));
return new EmbedProperties()
.WithTitle($"Ссылка на игру: {notification.Title}")
.WithDescription(
$"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" +
$"Ссылка: {notification.JoinLink}\n\n" +
$"Участники: {mentions}")
.WithUrl(notification.JoinLink)
.WithColor(new Color(0x57F287));
}
private static IReadOnlyList<ActionRowProperties> BuildRsvpRows(Guid sessionId, bool disabled)
{
var row = new ActionRowProperties();
row.Add(new ButtonProperties($"rsvp:confirm:{sessionId}", "Буду", ButtonStyle.Success)
{
Disabled = disabled
});
row.Add(new ButtonProperties($"rsvp:decline:{sessionId}", "Не смогу", ButtonStyle.Danger)
{
Disabled = disabled
});
return [row];
}
private static string BuildDirectContent(PlatformDirectSessionNotification notification)
{
var builder = new StringBuilder();
builder.AppendLine(notification.Kind switch
{
PlatformDirectSessionNotificationKind.ConfirmationRequest => "Нужно подтвердить участие",
PlatformDirectSessionNotificationKind.OneHourReminder => "Напоминание: сессия через час",
PlatformDirectSessionNotificationKind.JoinLink => "Ссылка на игру",
PlatformDirectSessionNotificationKind.RsvpAllConfirmed => "Все игроки подтвердили участие",
PlatformDirectSessionNotificationKind.RsvpDeclined => "Игрок отказался от участия",
PlatformDirectSessionNotificationKind.RescheduleApproved => "Сессия перенесена",
PlatformDirectSessionNotificationKind.RescheduleRejected => "Перенос сессии отклонен",
_ => "Уведомление по сессии"
});
builder.AppendLine();
builder.AppendLine($"**{notification.Title}**");
builder.AppendLine($"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)");
if (!string.IsNullOrWhiteSpace(notification.JoinLink))
builder.AppendLine($"Ссылка: {notification.JoinLink}");
if (!string.IsNullOrWhiteSpace(notification.ActorDisplayName))
builder.AppendLine($"Игрок: {notification.ActorDisplayName}");
if (!string.IsNullOrWhiteSpace(notification.Reason))
builder.AppendLine($"Причина: {notification.Reason}");
return builder.ToString();
}
private static string BuildRsvpGroupOutcomeContent(PlatformRsvpOutcomeNotification notification) =>
$"Все участники подтвердили сессию **{notification.Title}** на " +
$"**{notification.ScheduledAt.FormatMoscow()}** (МСК).";
private static string BuildRescheduleResultText(PlatformRescheduleVoteUpdate update)
{
if (update.SelectedOption is not null)
{
return "Голосование завершено. " +
$"Победил вариант {update.SelectedOption.DisplayOrder}: " +
$"**{update.SelectedOption.ProposedAt.FormatMoscow()}** (МСК).";
}
return $"Голосование завершено. {update.Decision.Reason}";
}
private async Task SendDirectContentAsync(PlatformUser recipient, string content, CancellationToken ct)
{
var userId = ParseSnowflake(recipient.ExternalUserId);
var dm = await restClient.GetDMChannelAsync(userId, cancellationToken: ct);
await restClient.SendMessageAsync(
dm.Id,
new MessageProperties().WithContent(content),
cancellationToken: ct);
}
private static string FormatDiscordParticipant(PlatformSessionParticipant participant) =>
$"{Mention(participant.User)} ({participant.User.DisplayName})";
private static string Mention(PlatformUser user) => $"<@{user.ExternalUserId}>";
private static ulong GetChannelId(PlatformGroup group)
{
var channelId = group.ExternalChannelId ?? group.ExternalGroupId
?? throw new InvalidOperationException("Discord group has no channel or group identifier.");
return ParseSnowflake(channelId);
}
private static ulong ParseSnowflake(string value) =>
ulong.Parse(value, CultureInfo.InvariantCulture);
}
@@ -0,0 +1,101 @@
using System.Net;
namespace GmRelay.DiscordBot.Infrastructure.Health;
public sealed class DiscordHealthCheckHostedService : IHostedService
{
private readonly ILogger<DiscordHealthCheckHostedService> _logger;
private readonly string _prefix;
private HttpListener? _listener;
private CancellationTokenSource? _cts;
private Task? _listenerTask;
public DiscordHealthCheckHostedService(
ILogger<DiscordHealthCheckHostedService> logger,
IConfiguration configuration)
{
_logger = logger;
_prefix = configuration.GetValue("HealthCheck:Prefix", "http://+:8082/")!;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_cts = new CancellationTokenSource();
_listener = new HttpListener();
_listener.Prefixes.Add(_prefix);
_listener.Start();
_logger.LogInformation("Discord health check server started on {Prefix}", _prefix);
_listenerTask = Task.Run(async () => await ListenAsync(_cts.Token), cancellationToken);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_cts?.Cancel();
_listener?.Stop();
if (_listenerTask != null)
{
await Task.WhenAny(_listenerTask, Task.Delay(TimeSpan.FromSeconds(5), cancellationToken));
}
_listener?.Close();
_logger.LogInformation("Discord health check server stopped");
}
private async Task ListenAsync(CancellationToken cancellationToken)
{
while (_listener?.IsListening == true && !cancellationToken.IsCancellationRequested)
{
try
{
var context = await _listener.GetContextAsync();
_ = Task.Run(() => HandleRequestAsync(context), cancellationToken);
}
catch (HttpListenerException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (ObjectDisposedException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in Discord health check listener");
}
}
}
private async Task HandleRequestAsync(HttpListenerContext context)
{
var response = context.Response;
try
{
var request = context.Request;
if (request.Url?.AbsolutePath == "/health")
{
response.StatusCode = (int)HttpStatusCode.OK;
response.ContentType = "application/json";
var body = "{\"status\":\"healthy\"}"u8.ToArray();
await response.OutputStream.WriteAsync(body);
}
else
{
response.StatusCode = (int)HttpStatusCode.NotFound;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling Discord health check request");
}
finally
{
response.Close();
}
}
}
@@ -0,0 +1,6 @@
namespace GmRelay.DiscordBot.Infrastructure;
public sealed class SystemClock : GmRelay.Shared.Platform.ISystemClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
+31
View File
@@ -1,7 +1,17 @@
using GmRelay.DiscordBot;
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Infrastructure;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Infrastructure.Health;
using GmRelay.DiscordBot.Infrastructure.Logging;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Features.Reminders.SendJoinLink;
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Infrastructure.Scheduling;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -43,7 +53,28 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<DiscordRescheduleHandler>();
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<DiscordInteractionReplyCache>();
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
builder.Services.AddSingleton<ISystemClock, SystemClock>();
builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Discord));
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
builder.Services.AddSingleton<PlatformDirectNotificationSender>();
builder.Services.AddSingleton<SendConfirmationHandler>();
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<HandleRsvpHandler>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
builder.Services.AddHostedService<SessionSchedulerService>();
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
builder.Services.AddHostedService<DiscordHealthCheckHostedService>();
builder.Services
.AddDiscordGateway(options =>
@@ -0,0 +1,67 @@
namespace GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using NetCord;
using NetCord.Rest;
public static class DiscordRescheduleVotingRenderer
{
public static (EmbedProperties Embed, ActionRowProperties ActionRow) Render(
string title,
DateTime currentTime,
DateTimeOffset deadline,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyList<RescheduleOptionVoteDto> votes)
{
var votesByOption = votes.GroupBy(v => v.OptionId).ToDictionary(g => g.Key, g => g.ToList());
var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet();
var pending = participants.Where(p => !votedPlayerIds.Contains(p.PlayerId)).Select(p => p.DisplayName).ToList();
var sb = new System.Text.StringBuilder();
sb.AppendLine($"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)");
sb.AppendLine($"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)");
sb.AppendLine();
sb.AppendLine("Выберите один из вариантов:");
foreach (var option in options.OrderBy(o => o.DisplayOrder))
{
var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []);
sb.AppendLine($"{option.DisplayOrder}. **{option.ProposedAt.FormatMoscow()}** (МСК) — {optionVotes.Count} голосов");
if (optionVotes.Count > 0)
{
sb.AppendLine($" {string.Join(", ", optionVotes.Select(v => v.DisplayName))}");
}
}
if (pending.Count > 0)
{
sb.AppendLine();
sb.AppendLine($"Не проголосовали: {string.Join(", ", pending)}");
}
sb.AppendLine();
sb.AppendLine($"Голосов: {votedPlayerIds.Count}/{participants.Count}");
sb.AppendLine("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется.");
var embed = new EmbedProperties()
.WithTitle($"🔄 Перенос сессии «{title}»")
.WithDescription(sb.ToString())
.WithColor(new Color(0xFEE75C));
var actionRow = new ActionRowProperties();
foreach (var option in options.OrderBy(o => o.DisplayOrder))
{
actionRow.Add(new ButtonProperties(
$"reschedule_vote:{option.OptionId}",
$"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}",
ButtonStyle.Primary));
}
return (embed, actionRow);
}
private static string FormatButtonTime(DateTimeOffset utc)
=> utc.ToOffset(TimeSpan.FromHours(3)).ToString("dd.MM HH:mm", System.Globalization.CultureInfo.InvariantCulture);
}
+7 -1
View File
@@ -666,7 +666,13 @@
}
},
"gmrelay.shared": {
"type": "Project"
"type": "Project",
"dependencies": {
"Dapper": "[2.1.72, )",
"Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )",
"Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )",
"Npgsql": "[10.0.2, )"
}
}
}
}
+1
View File
@@ -0,0 +1 @@
[module: Dapper.DapperAot]
@@ -0,0 +1,356 @@
using System.Globalization;
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Shared.Features.Confirmation.HandleRsvp;
public sealed record HandleRsvpCommand(
Guid SessionId,
PlatformUser User,
string Status,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ConfirmationMessage);
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
internal sealed record RsvpSessionContext(
Guid GroupId,
string Title,
DateTime ScheduledAt,
string Status);
internal sealed record ParticipantRsvpRow(
string Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername,
string RsvpStatus,
string RegistrationStatus,
bool IsGm);
internal sealed record RsvpRecipientRow(
string Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername);
public sealed class HandleRsvpHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
ILogger<HandleRsvpHandler> logger)
{
public async Task HandleAsync(HandleRsvpCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var participantExists = await connection.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND COALESCE(p.platform, 'Telegram') = @Platform
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @ExternalUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
)
""",
new
{
command.SessionId,
Platform = command.User.Platform.ToString(),
command.User.ExternalUserId,
Active = ParticipantRegistrationStatus.Active
},
transaction);
if (!participantExists)
{
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(
command.InteractionId,
"Вы не являетесь участником этой сессии."),
ct);
return;
}
var updated = await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = @Status,
responded_at = now()
WHERE session_id = @SessionId
AND player_id = (
SELECT id
FROM players
WHERE COALESCE(platform, 'Telegram') = @Platform
AND COALESCE(external_user_id, telegram_id::TEXT) = @ExternalUserId
LIMIT 1
)
AND registration_status = @Active
AND rsvp_status != @Status
""",
new
{
command.SessionId,
command.Status,
Platform = command.User.Platform.ToString(),
command.User.ExternalUserId,
Active = ParticipantRegistrationStatus.Active
},
transaction);
if (updated == 0)
{
var alreadyText = command.Status == RsvpStatus.Confirmed
? "Вы уже подтвердили участие."
: "Вы уже отказались от участия.";
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.InteractionId, alreadyText),
ct);
return;
}
var session = await connection.QuerySingleAsync<RsvpSessionContext>(
"""
SELECT s.group_id AS GroupId,
s.title,
s.scheduled_at AS ScheduledAt,
s.status AS Status
FROM sessions s
WHERE s.id = @SessionId
""",
new { command.SessionId },
transaction);
if (command.Status == RsvpStatus.Declined)
{
var decision = RsvpFlowRules.Evaluate(
command.Status,
session.Status,
totalParticipants: 0,
confirmedParticipants: 0);
if (decision.ShouldRevertSessionToConfirmationSent)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @ConfirmationSent, updated_at = now()
WHERE id = @SessionId AND status = @Confirmed
""",
new
{
command.SessionId,
ConfirmationSent = SessionStatus.ConfirmationSent,
Confirmed = SessionStatus.Confirmed
},
transaction);
}
var gmRecipients = (await GetGmRecipientsAsync(connection, session.GroupId, transaction))
.ToList();
await transaction.CommitAsync(ct);
if (gmRecipients.Count > 0)
{
await messenger.SendRsvpOutcomeAsync(
new PlatformRsvpOutcomeNotification(
PlatformRsvpOutcomeKind.GmPlayerDeclined,
Group: null,
gmRecipients,
command.SessionId,
session.Title,
session.ScheduledAt,
ActorDisplayName: command.User.DisplayName),
ct);
}
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.InteractionId, decision.CallbackText),
ct);
}
else
{
var counts = await connection.QuerySingleAsync<RsvpCounts>(
"""
SELECT
count(*) AS Total,
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
FROM session_participants
WHERE session_id = @SessionId AND is_gm = false
AND registration_status = @Active
""",
new
{
command.SessionId,
Confirmed = RsvpStatus.Confirmed,
Declined = RsvpStatus.Declined,
Active = ParticipantRegistrationStatus.Active
},
transaction);
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed);
if (decision.ShouldMarkSessionConfirmed)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @Confirmed, updated_at = now()
WHERE id = @SessionId
""",
new { command.SessionId, Confirmed = SessionStatus.Confirmed },
transaction);
}
var gmRecipients = decision.ShouldNotifyGm
? (await GetGmRecipientsAsync(connection, session.GroupId, transaction)).ToList()
: [];
await transaction.CommitAsync(ct);
if (decision.ShouldNotifyGroup)
{
await messenger.SendRsvpOutcomeAsync(
new PlatformRsvpOutcomeNotification(
PlatformRsvpOutcomeKind.GroupAllConfirmed,
command.Group,
[],
command.SessionId,
session.Title,
session.ScheduledAt),
ct);
}
if (decision.ShouldNotifyGm && gmRecipients.Count > 0)
{
await messenger.SendRsvpOutcomeAsync(
new PlatformRsvpOutcomeNotification(
PlatformRsvpOutcomeKind.GmAllConfirmed,
Group: null,
gmRecipients,
command.SessionId,
session.Title,
session.ScheduledAt),
ct);
}
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.InteractionId, decision.CallbackText),
ct);
}
await UpdateConfirmationMessage(command, session, ct);
}
private async Task UpdateConfirmationMessage(
HandleRsvpCommand command,
RsvpSessionContext session,
CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var participants = (await connection.QueryAsync<ParticipantRsvpRow>(
"""
SELECT 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,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY sp.responded_at NULLS LAST
""",
new { command.SessionId, Active = ParticipantRegistrationStatus.Active }))
.Select(ToParticipant)
.ToList();
var disableActions = participants.Count > 0 &&
participants.All(participant => participant.RsvpStatus == RsvpStatus.Confirmed);
await messenger.UpdateConfirmationRequestAsync(
new PlatformRsvpMessageUpdate(
new PlatformConfirmationRequest(
command.Group,
command.SessionId,
session.Title,
session.ScheduledAt,
participants,
command.ConfirmationMessage),
disableActions),
ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId);
}
}
private static async Task<IEnumerable<PlatformUser>> GetGmRecipientsAsync(
NpgsqlConnection connection,
Guid groupId,
NpgsqlTransaction transaction)
{
var rows = await connection.QueryAsync<RsvpRecipientRow>(
"""
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 group_managers gm
JOIN players p ON p.id = gm.player_id
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 },
transaction);
return rows.Select(row => new PlatformUser(
ParsePlatform(row.Platform),
row.ExternalUserId,
row.DisplayName,
row.ExternalUsername));
}
private static PlatformSessionParticipant ToParticipant(ParticipantRsvpRow row) =>
new(
new PlatformUser(
ParsePlatform(row.Platform),
row.ExternalUserId,
row.DisplayName,
row.ExternalUsername),
row.RsvpStatus,
row.RegistrationStatus,
row.IsGm);
private static PlatformKind ParsePlatform(string platform) =>
Enum.Parse<PlatformKind>(platform, ignoreCase: true);
}
@@ -1,8 +1,8 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
namespace GmRelay.Shared.Features.Confirmation.HandleRsvp;
internal sealed record RsvpFlowDecision(
public sealed record RsvpFlowDecision(
string CallbackText,
bool ShouldAlertGm,
bool ShouldRevertSessionToConfirmationSent,
@@ -10,7 +10,7 @@ internal sealed record RsvpFlowDecision(
bool ShouldNotifyGroup,
bool ShouldNotifyGm);
internal static class RsvpFlowRules
public static class RsvpFlowRules
{
public static RsvpFlowDecision Evaluate(
string requestedStatus,
@@ -21,7 +21,7 @@ internal static class RsvpFlowRules
if (requestedStatus == RsvpStatus.Declined)
{
return new RsvpFlowDecision(
CallbackText: "\u0412\u044b \u043e\u0442\u043a\u0430\u0437\u0430\u043b\u0438\u0441\u044c \u043e\u0442 \u0443\u0447\u0430\u0441\u0442\u0438\u044f.",
CallbackText: "Вы отказались от участия.",
ShouldAlertGm: true,
ShouldRevertSessionToConfirmationSent: currentSessionStatus == SessionStatus.Confirmed,
ShouldMarkSessionConfirmed: false,
@@ -32,7 +32,7 @@ internal static class RsvpFlowRules
var everyoneConfirmed = confirmedParticipants == totalParticipants;
return new RsvpFlowDecision(
CallbackText: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u0443\u0447\u0430\u0441\u0442\u0438\u0435!",
CallbackText: "Вы подтвердили участие!",
ShouldAlertGm: false,
ShouldRevertSessionToConfirmationSent: false,
ShouldMarkSessionConfirmed: everyoneConfirmed,
@@ -1,4 +1,4 @@
namespace GmRelay.Bot.Features.Confirmation.SendConfirmation;
namespace GmRelay.Shared.Features.Confirmation.SendConfirmation;
public interface ISendConfirmationHandler
{
@@ -0,0 +1,217 @@
using System.Globalization;
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Shared.Features.Confirmation.SendConfirmation;
internal sealed record ConfirmationSessionRow(
Guid Id,
string Title,
DateTime ScheduledAt,
Guid GroupId,
string Platform,
string ExternalGroupId,
string DisplayName,
string? ExternalChannelId,
int? ThreadId,
string NotificationMode);
internal sealed record ConfirmationParticipantRow(
string Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername,
string RsvpStatus,
string RegistrationStatus,
bool IsGm);
public sealed class SendConfirmationHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
PlatformDirectNotificationSender directSender,
ILogger<SendConfirmationHandler> logger) : ISendConfirmationHandler
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var session = await connection.QuerySingleOrDefaultAsync<ConfirmationSessionRow>(
"""
SELECT s.id,
s.title,
s.scheduled_at AS ScheduledAt,
s.group_id AS GroupId,
COALESCE(g.platform, 'Telegram') AS Platform,
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
g.name AS DisplayName,
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.status = @Planned
""",
new { SessionId = sessionId, Planned = SessionStatus.Planned });
if (session is null)
{
logger.LogWarning("Session {SessionId} not found or not in Planned status", sessionId);
return;
}
var participants = (await connection.QueryAsync<ConfirmationParticipantRow>(
"""
SELECT 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,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY sp.created_at ASC
""",
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active }))
.Select(ToParticipant)
.ToList();
if (participants.Count == 0)
{
logger.LogWarning("Session {SessionId} has no non-GM participants", sessionId);
return;
}
var group = CreateGroup(session);
var message = await messenger.SendConfirmationRequestAsync(
new PlatformConfirmationRequest(
group,
session.Id,
session.Title,
session.ScheduledAt,
participants),
ct);
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @Status,
confirmation_message_id = @MessageId,
confirmation_sent_at = now(),
updated_at = now()
WHERE id = @SessionId
AND confirmation_sent_at IS NULL
""",
new
{
SessionId = sessionId,
Status = SessionStatus.ConfirmationSent,
MessageId = TryGetTelegramMessageId(message)
});
await PersistPlatformMessageAsync(
connection,
message,
session.GroupId,
session.Id,
batchId: null,
purpose: "confirmation");
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await directSender.SendAsync(
PlatformDirectSessionNotificationKind.ConfirmationRequest,
participants.Select(p => p.User),
session.Id,
session.Title,
session.ScheduledAt,
joinLink: null,
actorDisplayName: null,
reason: null,
ct);
}
logger.LogInformation(
"Confirmation sent for session {SessionId} ({Title}), platform={Platform}, message_id={MessageId}",
sessionId,
session.Title,
message.Platform,
message.ExternalMessageId);
}
private static PlatformSessionParticipant ToParticipant(ConfirmationParticipantRow row) =>
new(
new PlatformUser(
ParsePlatform(row.Platform),
row.ExternalUserId,
row.DisplayName,
row.ExternalUsername),
row.RsvpStatus,
row.RegistrationStatus,
row.IsGm);
private static PlatformGroup CreateGroup(ConfirmationSessionRow row) =>
new(
ParsePlatform(row.Platform),
row.ExternalGroupId,
row.DisplayName,
row.ExternalChannelId,
row.ThreadId?.ToString(CultureInfo.InvariantCulture));
private static PlatformKind ParsePlatform(string platform) =>
Enum.Parse<PlatformKind>(platform, ignoreCase: true);
private static int? TryGetTelegramMessageId(PlatformMessageRef message) =>
message.Platform == PlatformKind.Telegram &&
int.TryParse(message.ExternalMessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId)
? messageId
: null;
private static Task PersistPlatformMessageAsync(
NpgsqlConnection connection,
PlatformMessageRef message,
Guid groupId,
Guid? sessionId,
Guid? batchId,
string purpose) =>
connection.ExecuteAsync(
"""
INSERT INTO platform_messages (
platform,
group_id,
batch_id,
session_id,
external_channel_id,
external_thread_id,
external_message_id,
purpose)
VALUES (
@Platform,
@GroupId,
@BatchId,
@SessionId,
@ExternalChannelId,
@ExternalThreadId,
@ExternalMessageId,
@Purpose)
""",
new
{
Platform = message.Platform.ToString(),
GroupId = groupId,
BatchId = batchId,
SessionId = sessionId,
ExternalChannelId = message.ExternalGroupId,
message.ExternalThreadId,
message.ExternalMessageId,
Purpose = purpose
});
}
@@ -0,0 +1,50 @@
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
namespace GmRelay.Shared.Features.Notifications;
public sealed class PlatformDirectNotificationSender(
IPlatformMessenger messenger,
ILogger<PlatformDirectNotificationSender> logger)
{
public async Task SendAsync(
PlatformDirectSessionNotificationKind kind,
IEnumerable<PlatformUser> recipients,
Guid sessionId,
string title,
DateTime scheduledAt,
string? joinLink,
string? actorDisplayName,
string? reason,
CancellationToken ct)
{
foreach (var recipient in recipients)
{
try
{
await messenger.SendDirectSessionNotificationAsync(
new PlatformDirectSessionNotification(
kind,
recipient,
sessionId,
title,
scheduledAt,
joinLink,
actorDisplayName,
reason),
ct);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Failed to send {NotificationKind} notification for session {SessionId} to {Platform} user {ExternalUserId} ({DisplayName})",
kind,
sessionId,
recipient.Platform,
recipient.ExternalUserId,
recipient.DisplayName);
}
}
}
}
@@ -1,4 +1,4 @@
namespace GmRelay.Bot.Features.Reminders.SendJoinLink;
namespace GmRelay.Shared.Features.Reminders.SendJoinLink;
public interface ISendJoinLinkHandler
{
@@ -0,0 +1,228 @@
using System.Globalization;
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Shared.Features.Reminders.SendJoinLink;
internal sealed record JoinLinkSessionRow(
Guid Id,
Guid GroupId,
string Title,
string JoinLink,
DateTime ScheduledAt,
string Platform,
string ExternalGroupId,
string DisplayName,
string? ExternalChannelId,
int? ThreadId,
string NotificationMode);
internal sealed record JoinLinkPlayerRow(
string Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername,
string RsvpStatus,
string RegistrationStatus,
bool IsGm);
public sealed class SendJoinLinkHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
PlatformDirectNotificationSender directSender,
ILogger<SendJoinLinkHandler> logger) : ISendJoinLinkHandler
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var session = await connection.QuerySingleOrDefaultAsync<JoinLinkSessionRow>(
"""
SELECT s.id,
s.group_id AS GroupId,
s.title,
s.join_link AS JoinLink,
s.scheduled_at AS ScheduledAt,
COALESCE(g.platform, 'Telegram') AS Platform,
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
g.name AS DisplayName,
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
AND s.status = @Confirmed
AND (
(COALESCE(g.platform, 'Telegram') = 'Telegram' AND s.link_message_id IS NULL)
OR (
COALESCE(g.platform, 'Telegram') <> 'Telegram'
AND NOT EXISTS (
SELECT 1
FROM platform_messages pm
WHERE pm.session_id = s.id
AND pm.platform = COALESCE(g.platform, 'Telegram')
AND pm.purpose = 'join_link'
)
)
)
""",
new { SessionId = sessionId, Confirmed = SessionStatus.Confirmed });
if (session is null)
{
logger.LogWarning("Session {SessionId} not eligible for join link", sessionId);
return;
}
var players = (await connection.QueryAsync<JoinLinkPlayerRow>(
"""
SELECT 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,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.rsvp_status = @Confirmed
AND sp.registration_status = @Active
ORDER BY sp.created_at ASC
""",
new
{
SessionId = sessionId,
Confirmed = RsvpStatus.Confirmed,
Active = ParticipantRegistrationStatus.Active
}))
.Select(ToParticipant)
.ToList();
var group = CreateGroup(session);
var message = await messenger.SendJoinLinkNotificationAsync(
new PlatformJoinLinkNotification(
group,
session.Id,
session.Title,
session.ScheduledAt,
session.JoinLink,
players),
ct);
await connection.ExecuteAsync(
"""
UPDATE sessions
SET link_message_id = @MessageId, updated_at = now()
WHERE id = @SessionId
""",
new
{
SessionId = sessionId,
MessageId = TryGetTelegramMessageId(message)
});
await PersistPlatformMessageAsync(
connection,
message,
session.GroupId,
session.Id,
batchId: null,
purpose: "join_link");
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await directSender.SendAsync(
PlatformDirectSessionNotificationKind.JoinLink,
players.Select(p => p.User),
session.Id,
session.Title,
session.ScheduledAt,
session.JoinLink,
actorDisplayName: null,
reason: null,
ct);
}
logger.LogInformation(
"Join link sent for session {SessionId} ({Title}), platform={Platform}, message_id={MessageId}",
sessionId,
session.Title,
message.Platform,
message.ExternalMessageId);
}
private static PlatformSessionParticipant ToParticipant(JoinLinkPlayerRow row) =>
new(
new PlatformUser(
ParsePlatform(row.Platform),
row.ExternalUserId,
row.DisplayName,
row.ExternalUsername),
row.RsvpStatus,
row.RegistrationStatus,
row.IsGm);
private static PlatformGroup CreateGroup(JoinLinkSessionRow row) =>
new(
ParsePlatform(row.Platform),
row.ExternalGroupId,
row.DisplayName,
row.ExternalChannelId,
row.ThreadId?.ToString(CultureInfo.InvariantCulture));
private static PlatformKind ParsePlatform(string platform) =>
Enum.Parse<PlatformKind>(platform, ignoreCase: true);
private static int? TryGetTelegramMessageId(PlatformMessageRef message) =>
message.Platform == PlatformKind.Telegram &&
int.TryParse(message.ExternalMessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId)
? messageId
: null;
private static Task PersistPlatformMessageAsync(
NpgsqlConnection connection,
PlatformMessageRef message,
Guid groupId,
Guid? sessionId,
Guid? batchId,
string purpose) =>
connection.ExecuteAsync(
"""
INSERT INTO platform_messages (
platform,
group_id,
batch_id,
session_id,
external_channel_id,
external_thread_id,
external_message_id,
purpose)
VALUES (
@Platform,
@GroupId,
@BatchId,
@SessionId,
@ExternalChannelId,
@ExternalThreadId,
@ExternalMessageId,
@Purpose)
""",
new
{
Platform = message.Platform.ToString(),
GroupId = groupId,
BatchId = batchId,
SessionId = sessionId,
ExternalChannelId = message.ExternalGroupId,
message.ExternalThreadId,
message.ExternalMessageId,
Purpose = purpose
});
}
@@ -1,4 +1,4 @@
namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder;
namespace GmRelay.Shared.Features.Reminders.SendOneHourReminder;
public interface ISendOneHourReminderHandler
{
@@ -1,27 +1,35 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder;
namespace GmRelay.Shared.Features.Reminders.SendOneHourReminder;
internal sealed record OneHourReminderSession(
internal sealed record OneHourReminderSessionRow(
Guid Id,
string Title,
string JoinLink,
DateTime ScheduledAt,
string NotificationMode);
internal sealed record OneHourReminderRecipientRow(
string Platform,
string ExternalUserId,
string DisplayName,
string? ExternalUsername);
public sealed class SendOneHourReminderHandler(
NpgsqlDataSource dataSource,
DirectSessionNotificationSender directSender,
PlatformDirectNotificationSender directSender,
ILogger<SendOneHourReminderHandler> logger) : ISendOneHourReminderHandler
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var session = await connection.QuerySingleOrDefaultAsync<OneHourReminderSession>(
var session = await connection.QuerySingleOrDefaultAsync<OneHourReminderSessionRow>(
"""
SELECT id,
title,
@@ -46,10 +54,12 @@ public sealed class SendOneHourReminderHandler(
return;
}
var recipients = (await connection.QueryAsync<DirectNotificationRecipient>(
var recipients = (await connection.QueryAsync<OneHourReminderRecipientRow>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName
SELECT 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 session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
@@ -62,20 +72,27 @@ public sealed class SendOneHourReminderHandler(
SessionId = sessionId,
Active = ParticipantRegistrationStatus.Active,
Declined = RsvpStatus.Declined
})).ToList();
}))
.Select(row => new PlatformUser(
ParsePlatform(row.Platform),
row.ExternalUserId,
row.DisplayName,
row.ExternalUsername))
.ToList();
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages() && recipients.Count > 0)
{
var text = $"""
<b>Игра начнётся примерно через 1 час</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)}
""";
await directSender.SendAsync(recipients, text, "one-hour-reminder", session.Id, ct);
await directSender.SendAsync(
PlatformDirectSessionNotificationKind.OneHourReminder,
recipients,
session.Id,
session.Title,
session.ScheduledAt,
session.JoinLink,
actorDisplayName: null,
reason: null,
ct);
}
await connection.ExecuteAsync(
@@ -94,4 +111,7 @@ public sealed class SendOneHourReminderHandler(
session.Title,
session.NotificationMode);
}
private static PlatformKind ParsePlatform(string platform) =>
Enum.Parse<PlatformKind>(platform, ignoreCase: true);
}
@@ -4,8 +4,9 @@ using Npgsql;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record JoinSessionCommand(
Guid SessionId,
@@ -15,15 +16,17 @@ public sealed record JoinSessionCommand(
PlatformMessageRef ScheduleMessage);
// DTOs for AOT compilation
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxPlayers);
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
public sealed class JoinSessionHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<JoinSessionHandler> logger)
{
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
{
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false;
@@ -64,7 +67,7 @@ public sealed class JoinSessionHandler(
// 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав.
var batchInfo = await connection.QuerySingleOrDefaultAsync<JoinSessionBatchDto>(
@"SELECT batch_id as BatchId, title as Title, max_players as MaxPlayers
@"SELECT batch_id as BatchId, title as Title, status as Status, max_players as MaxPlayers
FROM sessions
WHERE id = @SessionId
FOR UPDATE",
@@ -78,6 +81,13 @@ public sealed class JoinSessionHandler(
return;
}
if (SessionStatus.IsCancelled(batchInfo.Status))
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
return;
}
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
"""
SELECT sp.registration_status
@@ -2,9 +2,10 @@ using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record LeaveSessionCommand(
Guid SessionId,
@@ -20,10 +21,12 @@ internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string Di
public sealed class LeaveSessionHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<LeaveSessionHandler> logger)
{
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
{
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false;
@@ -0,0 +1,39 @@
using System.Collections.Concurrent;
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public interface IScheduleMessageUpdateLock
{
ValueTask<IAsyncDisposable> AcquireAsync(PlatformMessageRef scheduleMessage, CancellationToken ct);
}
public sealed class ScheduleMessageUpdateLock : IScheduleMessageUpdateLock
{
private readonly ConcurrentDictionary<string, SemaphoreSlim> locks = new(StringComparer.Ordinal);
public async ValueTask<IAsyncDisposable> AcquireAsync(PlatformMessageRef scheduleMessage, CancellationToken ct)
{
var key = CreateKey(scheduleMessage);
var semaphore = locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync(ct);
return new Releaser(semaphore);
}
private static string CreateKey(PlatformMessageRef scheduleMessage) =>
string.Join(
'\u001F',
scheduleMessage.Platform.ToString(),
scheduleMessage.ExternalGroupId,
scheduleMessage.ExternalThreadId ?? string.Empty,
scheduleMessage.ExternalMessageId);
private sealed class Releaser(SemaphoreSlim semaphore) : IAsyncDisposable
{
public ValueTask DisposeAsync()
{
semaphore.Release();
return ValueTask.CompletedTask;
}
}
}
@@ -0,0 +1,29 @@
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record RescheduleOptionDto(
Guid OptionId,
int DisplayOrder,
DateTimeOffset ProposedAt);
public sealed record VoteProposalDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt);
public sealed record RescheduleOptionVoteDto(
Guid OptionId,
Guid PlayerId,
string DisplayName,
string? TelegramUsername);
public sealed record RescheduleOptionVoteCount(
Guid OptionId,
int VoteCount);
public sealed record VoteParticipantDto(
Guid PlayerId,
string DisplayName,
string? TelegramUsername,
long TelegramId = 0);
@@ -1,13 +1,13 @@
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
internal enum RescheduleVoteOutcome
public enum RescheduleVoteOutcome
{
Pending,
Rejected,
Approved
}
internal sealed record RescheduleVoteDecision(
public sealed record RescheduleVoteDecision(
RescheduleVoteOutcome Outcome,
string Reason,
Guid? SelectedOptionId = null,
@@ -15,7 +15,7 @@ internal sealed record RescheduleVoteDecision(
bool ShouldRescheduleSession = false,
bool ShouldResetParticipantRsvps = false);
internal static class RescheduleVoteRules
public static class RescheduleVoteRules
{
public static RescheduleVoteDecision SelectWinner(IReadOnlyList<RescheduleOptionVoteCount> voteCounts)
{
@@ -49,8 +49,8 @@ internal static class RescheduleVoteRules
{
return new RescheduleVoteDecision(
Outcome: RescheduleVoteOutcome.Rejected,
Reason: "\u041e\u0434\u0438\u043d \u0438\u0437 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u043e\u0432 \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b \u043f\u0435\u0440\u0435\u043d\u043e\u0441.",
CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.");
Reason: "Один из участников отклонил перенос.",
CallbackText: "Вы проголосовали против переноса.");
}
var everyoneApproved = approvedParticipants == totalParticipants;
@@ -58,11 +58,11 @@ internal static class RescheduleVoteRules
return new RescheduleVoteDecision(
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending,
Reason: everyoneApproved
? "\u0412\u0441\u0435 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u0438 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b."
: "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0430\u0435\u0442\u0441\u044f.",
? "Все участники согласны."
: "Голосование продолжается.",
CallbackText: everyoneApproved
? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e."
: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!",
? "Вы подтвердили перенос! Все согласны — время обновлено."
: "Вы подтвердили перенос!",
ShouldRescheduleSession: everyoneApproved,
ShouldResetParticipantRsvps: everyoneApproved);
}
@@ -0,0 +1,215 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record RescheduleVotingFinalizerResult(
Guid ProposalId,
Guid SessionId,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
string NotificationMode,
string SourcePlatform,
DateTimeOffset VotingDeadlineAt,
RescheduleVoteDecision Decision,
RescheduleOptionDto? SelectedOption,
IReadOnlyList<RescheduleOptionDto> Options,
IReadOnlyList<RescheduleOptionVoteDto> Votes,
IReadOnlyList<VoteParticipantDto> Participants);
public sealed class RescheduleVotingFinalizer(
NpgsqlDataSource dataSource,
ISystemClock clock,
ILogger<RescheduleVotingFinalizer> logger)
{
public async Task<IReadOnlyList<Guid>> GetDueProposalIdsAsync(string sourcePlatform, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var proposalIds = (await connection.QueryAsync<Guid>(
"""
SELECT id
FROM reschedule_proposals
WHERE status = 'Voting'
AND voting_deadline_at IS NOT NULL
AND voting_deadline_at <= @Now
AND source_platform = @SourcePlatform
ORDER BY voting_deadline_at
LIMIT 25
""",
new { Now = clock.UtcNow.UtcDateTime, SourcePlatform = sourcePlatform })).ToList();
return proposalIds;
}
public async Task<RescheduleVotingFinalizerResult?> FinalizeAsync(Guid proposalId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var proposal = await connection.QuerySingleOrDefaultAsync<ProposalRow>(
"""
SELECT rp.id AS ProposalId,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
rp.source_platform AS SourcePlatform,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId,
s.notification_mode AS NotificationMode
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
WHERE rp.id = @ProposalId
AND rp.status = 'Voting'
AND rp.voting_deadline_at IS NOT NULL
AND rp.voting_deadline_at <= @Now
FOR UPDATE
""",
new { ProposalId = proposalId, Now = clock.UtcNow.UtcDateTime },
transaction);
if (proposal is null)
return null;
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.ProposalId },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.ProposalId },
transaction)).ToList();
var voteCounts = options
.Select(option => new RescheduleOptionVoteCount(
option.OptionId,
votes.Count(vote => vote.OptionId == option.OptionId)))
.ToList();
var decision = RescheduleVoteRules.SelectWinner(voteCounts);
var selectedOption = decision.SelectedOptionId is { } selectedOptionId
? options.Single(x => x.OptionId == selectedOptionId)
: null;
if (selectedOption is not null)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
confirmation_sent_at = NULL,
link_message_id = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = selectedOption.ProposedAt, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = 'Pending',
responded_at = NULL
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction);
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET status = 'Approved',
selected_option_id = @SelectedOptionId,
proposed_at = @ProposedAt
WHERE id = @ProposalId
""",
new
{
ProposalId = proposal.ProposalId,
SelectedOptionId = selectedOption.OptionId,
ProposedAt = selectedOption.ProposedAt
},
transaction);
}
else
{
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId",
new { ProposalId = proposal.ProposalId },
transaction);
}
await transaction.CommitAsync(ct);
logger.LogInformation(
"Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
proposal.ProposalId,
proposal.SessionId,
decision.Outcome);
return new RescheduleVotingFinalizerResult(
proposal.ProposalId,
proposal.SessionId,
proposal.Title,
proposal.CurrentScheduledAt,
proposal.BatchId,
proposal.NotificationMode,
proposal.SourcePlatform,
proposal.VotingDeadlineAt,
decision,
selectedOption,
options,
votes,
participants);
}
private sealed record ProposalRow(
Guid ProposalId,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string SourcePlatform,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
string NotificationMode);
}
@@ -1,9 +1,9 @@
using System.Text.RegularExpressions;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
internal sealed record RescheduleVotingInput(
public sealed record RescheduleVotingInput(
IReadOnlyList<DateTimeOffset> Options,
DateTimeOffset Deadline)
{
@@ -93,18 +93,3 @@ internal sealed record RescheduleVotingInput(
|| normalized.StartsWith("до:", StringComparison.Ordinal);
}
}
internal sealed record RescheduleOptionDto(
Guid OptionId,
int DisplayOrder,
DateTimeOffset ProposedAt);
internal sealed record RescheduleOptionVoteDto(
Guid OptionId,
Guid PlayerId,
string DisplayName,
string? TelegramUsername);
internal sealed record RescheduleOptionVoteCount(
Guid OptionId,
int VoteCount);
+9
View File
@@ -5,6 +5,15 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Dapper.AOT" Version="1.0.48" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup>
</Project>
@@ -2,7 +2,7 @@ using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
namespace GmRelay.Bot.Infrastructure.Scheduling;
namespace GmRelay.Shared.Infrastructure.Scheduling;
public interface ISessionTriggerStore
{
@@ -11,7 +11,9 @@ public interface ISessionTriggerStore
Task<IReadOnlyList<Guid>> GetSessionsNeedingJoinLinkAsync(DateTimeOffset now, CancellationToken ct);
}
public sealed class DbSessionTriggerStore(NpgsqlDataSource dataSource) : ISessionTriggerStore
public sealed class DbSessionTriggerStore(
NpgsqlDataSource dataSource,
PlatformSchedulerOptions options) : ISessionTriggerStore
{
private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24);
private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1);
@@ -23,14 +25,17 @@ public sealed class DbSessionTriggerStore(NpgsqlDataSource dataSource) : ISessio
var results = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status = @Planned
AND scheduled_at - @LeadTime <= @Now
AND confirmation_sent_at IS NULL
SELECT s.id
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE g.platform = @Platform
AND s.status = @Planned
AND s.scheduled_at - @LeadTime <= @Now
AND s.confirmation_sent_at IS NULL
""",
new
{
Platform = options.Platform.ToString(),
Planned = SessionStatus.Planned,
LeadTime = ConfirmationLeadTime,
Now = now.UtcDateTime
@@ -45,14 +50,17 @@ public sealed class DbSessionTriggerStore(NpgsqlDataSource dataSource) : ISessio
var results = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status IN (@Confirmed, @ConfirmationSent)
AND scheduled_at - @LeadTime <= @Now
AND one_hour_reminder_processed_at IS NULL
SELECT s.id
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE g.platform = @Platform
AND s.status IN (@Confirmed, @ConfirmationSent)
AND s.scheduled_at - @LeadTime <= @Now
AND s.one_hour_reminder_processed_at IS NULL
""",
new
{
Platform = options.Platform.ToString(),
Confirmed = SessionStatus.Confirmed,
ConfirmationSent = SessionStatus.ConfirmationSent,
LeadTime = OneHourReminderLeadTime,
@@ -68,14 +76,29 @@ public sealed class DbSessionTriggerStore(NpgsqlDataSource dataSource) : ISessio
var results = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status = @Confirmed
AND scheduled_at - @LeadTime <= @Now
AND link_message_id IS NULL
SELECT s.id
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE g.platform = @Platform
AND s.status = @Confirmed
AND s.scheduled_at - @LeadTime <= @Now
AND (
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
OR (
g.platform <> 'Telegram'
AND NOT EXISTS (
SELECT 1
FROM platform_messages pm
WHERE pm.session_id = s.id
AND pm.platform = g.platform
AND pm.purpose = 'join_link'
)
)
)
""",
new
{
Platform = options.Platform.ToString(),
Confirmed = SessionStatus.Confirmed,
LeadTime = JoinLinkLeadTime,
Now = now.UtcDateTime
@@ -0,0 +1,5 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Infrastructure.Scheduling;
public sealed record PlatformSchedulerOptions(PlatformKind Platform);
@@ -1,17 +1,15 @@
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
using GmRelay.Shared.Features.Reminders.SendJoinLink;
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace GmRelay.Bot.Infrastructure.Scheduling;
namespace GmRelay.Shared.Infrastructure.Scheduling;
/// <summary>
/// Stateless scheduler: wakes every 60 seconds, queries PostgreSQL for actionable sessions.
/// Three triggers:
/// T-24h: send confirmation request with inline keyboard
/// T-1h: send one-hour direct reminder
/// T-5min: send join link to all confirmed players
///
/// If the Raspberry Pi reboots, nothing is lost — all state is in the DB.
/// All state is kept in the database so worker restarts do not lose scheduled work.
/// </summary>
public sealed class SessionSchedulerService(
ISessionTriggerStore triggerStore,
@@ -49,10 +47,6 @@ public sealed class SessionSchedulerService(
logger.LogInformation("Session scheduler stopped");
}
/// <summary>
/// Runs a single scheduler tick using the current clock time.
/// Public so it can be called from integration tests with a fake clock.
/// </summary>
public async Task TickAsync(CancellationToken ct)
{
var now = clock.UtcNow;
@@ -13,4 +13,22 @@ public interface IPlatformMessenger
Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct);
Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct);
Task<PlatformMessageRef> SendConfirmationRequestAsync(PlatformConfirmationRequest request, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support confirmation requests.");
Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support confirmation request updates.");
Task<PlatformMessageRef> SendJoinLinkNotificationAsync(PlatformJoinLinkNotification notification, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support join-link notifications.");
Task SendDirectSessionNotificationAsync(PlatformDirectSessionNotification notification, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support direct session notifications.");
Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support RSVP outcome notifications.");
Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support reschedule vote updates.");
}
@@ -0,0 +1,6 @@
namespace GmRelay.Shared.Platform;
public interface ISystemClock
{
DateTimeOffset UtcNow { get; }
}
@@ -1,3 +1,4 @@
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Rendering;
namespace GmRelay.Shared.Platform;
@@ -34,3 +35,81 @@ public sealed record PlatformCalendarFile(
byte[] Content,
string CaptionHtml,
IReadOnlyList<PlatformMessageAction> Actions);
public sealed record PlatformSessionParticipant(
PlatformUser User,
string RsvpStatus,
string RegistrationStatus,
bool IsGm = false);
public sealed record PlatformConfirmationRequest(
PlatformGroup Group,
Guid SessionId,
string Title,
DateTime ScheduledAt,
IReadOnlyList<PlatformSessionParticipant> Participants,
PlatformMessageRef? ExistingMessage = null);
public sealed record PlatformJoinLinkNotification(
PlatformGroup Group,
Guid SessionId,
string Title,
DateTime ScheduledAt,
string JoinLink,
IReadOnlyList<PlatformSessionParticipant> ConfirmedPlayers,
PlatformMessageRef? ExistingMessage = null);
public enum PlatformDirectSessionNotificationKind
{
ConfirmationRequest = 0,
OneHourReminder = 1,
JoinLink = 2,
RsvpAllConfirmed = 3,
RsvpDeclined = 4,
RescheduleApproved = 5,
RescheduleRejected = 6
}
public sealed record PlatformDirectSessionNotification(
PlatformDirectSessionNotificationKind Kind,
PlatformUser Recipient,
Guid SessionId,
string Title,
DateTime ScheduledAt,
string? JoinLink = null,
string? ActorDisplayName = null,
string? Reason = null);
public sealed record PlatformRsvpMessageUpdate(
PlatformConfirmationRequest Request,
bool DisableActions);
public enum PlatformRsvpOutcomeKind
{
GroupAllConfirmed = 0,
GmAllConfirmed = 1,
GmPlayerDeclined = 2
}
public sealed record PlatformRsvpOutcomeNotification(
PlatformRsvpOutcomeKind Kind,
PlatformGroup? Group,
IReadOnlyList<PlatformUser> Recipients,
Guid SessionId,
string Title,
DateTime ScheduledAt,
string? ActorDisplayName = null);
public sealed record PlatformRescheduleVoteUpdate(
PlatformGroup Group,
PlatformMessageRef ExistingMessage,
Guid ProposalId,
Guid SessionId,
string Title,
DateTime CurrentScheduledAt,
DateTimeOffset VotingDeadlineAt,
RescheduleVoteDecision Decision,
RescheduleOptionDto? SelectedOption,
IReadOnlyList<RescheduleOptionDto> Options,
IReadOnlyList<RescheduleOptionVoteDto> Votes,
IReadOnlyList<VoteParticipantDto> Participants);
+87
View File
@@ -2,11 +2,98 @@
"version": 1,
"dependencies": {
"net10.0": {
"Dapper": {
"type": "Direct",
"requested": "[2.1.72, )",
"resolved": "2.1.72",
"contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
},
"Dapper.AOT": {
"type": "Direct",
"requested": "[1.0.48, )",
"resolved": "1.0.48",
"contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA=="
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Npgsql": {
"type": "Direct",
"requested": "[10.0.2, )",
"resolved": "10.0.2",
"contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
}
},
"SecurityCodeScan.VS2019": {
"type": "Direct",
"requested": "[5.6.7, )",
"resolved": "5.6.7",
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA=="
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g=="
}
}
}
@@ -39,9 +39,19 @@
<div class="nav-footer">
<div class="nav-user">
<div class="nav-user-avatar">
@(context.User.Identity?.Name?.Substring(0, 1).ToUpper() ?? "?")
@if (!string.IsNullOrWhiteSpace(context.User.GetAvatarUrl()))
{
<img src="@context.User.GetAvatarUrl()" alt="" />
}
else
{
@(context.User.Identity?.Name?.Substring(0, 1).ToUpper() ?? "?")
}
</div>
<div class="nav-user-info">
<span class="nav-user-name">@context.User.Identity?.Name</span>
<span class="nav-user-platform">@GetPlatformLabel(context.User)</span>
</div>
<span class="nav-user-name">@context.User.Identity?.Name</span>
</div>
<form action="/auth/logout" method="post">
@@ -56,7 +66,7 @@
</button>
</form>
<div class="nav-version">v2.4.0</div>
<div class="nav-version">v2.8.0</div>
</div>
</Authorized>
<NotAuthorized>
@@ -79,4 +89,7 @@
private void ToggleMenu() => isOpen = !isOpen;
private void CloseMenu() => isOpen = false;
private static string GetPlatformLabel(System.Security.Claims.ClaimsPrincipal user) =>
user.TryGetDiscordId(out _) ? "Discord" : "Telegram";
}
@@ -185,7 +185,7 @@
private Guid selectedGroupId;
private Guid? deletingTemplateId;
private bool isCreatingTemplate;
private long telegramId;
private string? externalUserId;
private string? errorMessage;
private string? successMessage;
private CampaignTemplateEditModel templateModel = new();
@@ -195,13 +195,13 @@
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out telegramId))
if (!authState.User.TryGetPlatformIdentity(out var platform, out externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
}
groups = await SessionService.GetGroupsForGmAsync(telegramId);
groups = await SessionService.GetGroupsForCurrentUserAsync();
selectedGroupId = groups.FirstOrDefault()?.Id ?? Guid.Empty;
if (selectedGroupId != Guid.Empty)
@@ -228,7 +228,7 @@
campaignTemplates = null;
campaignTemplateModels = [];
var templates = await SessionService.GetCampaignTemplatesForGmAsync(selectedGroupId, telegramId);
var templates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(selectedGroupId);
if (templates is null)
{
Navigation.NavigateTo("/access-denied");
@@ -260,9 +260,8 @@
try
{
await SessionService.CreateCampaignTemplateForGmAsync(
await SessionService.CreateCampaignTemplateForCurrentUserAsync(
selectedGroupId,
telegramId,
new CreateCampaignTemplateRequest(
templateModel.Name,
templateModel.Title,
@@ -298,7 +297,7 @@
try
{
await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId);
await SessionService.DeleteCampaignTemplateForCurrentUserAsync(template.Id);
successMessage = "Шаблон кампании удалён.";
await LoadTemplates();
}
@@ -87,13 +87,13 @@
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
}
session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
if (session is null)
{
Navigation.NavigateTo("/access-denied");
@@ -114,7 +114,7 @@
try
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
@@ -122,7 +122,7 @@
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
Navigation.NavigateTo($"/group/{session!.GroupId}");
}
catch (SessionAccessDeniedException)
@@ -40,8 +40,8 @@
</span>
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
{
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.TelegramId)" @onclick="() => RemoveCoGm(manager.TelegramId)">
@(removingCoGmId == manager.TelegramId ? "⏳ Удаляем..." : "Убрать")
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.ExternalUserId)" @onclick="() => RemoveCoGm(manager.ExternalUserId ?? manager.TelegramId.ToString())">
@(removingCoGmId == manager.ExternalUserId ? "⏳ Удаляем..." : "Убрать")
</button>
}
}
@@ -403,9 +403,9 @@
private Guid? promotingSessionId;
private Guid? processingBatchId;
private Guid? processingTemplateId;
private long? removingCoGmId;
private string? removingCoGmId;
private bool isAddingCoGm;
private long telegramId;
private string? externalUserId;
private string? errorMessage;
private string? successMessage;
private CoGmEditModel coGmModel = new();
@@ -417,7 +417,7 @@
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out telegramId))
if (!authState.User.TryGetPlatformIdentity(out var platform, out externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
@@ -428,21 +428,21 @@
private async Task LoadSessions()
{
groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId);
groupManagement = await SessionService.GetGroupManagementForCurrentUserAsync(GroupId);
if (groupManagement is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
sessions = await SessionService.GetUpcomingSessionsForCurrentUserAsync(GroupId);
if (sessions is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
campaignTemplates = await SessionService.GetCampaignTemplatesForGmAsync(GroupId, telegramId);
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
if (campaignTemplates is null)
{
Navigation.NavigateTo("/access-denied");
@@ -470,8 +470,8 @@
{
await SessionService.AddCoGmForOwnerAsync(
GroupId,
telegramId,
coGmModel.TelegramId.Value,
"Telegram",
coGmModel.TelegramId.Value.ToString(),
coGmModel.DisplayName,
coGmModel.TelegramUsername);
@@ -493,15 +493,15 @@
}
}
private async Task RemoveCoGm(long coGmTelegramId)
private async Task RemoveCoGm(string coGmExternalUserId)
{
errorMessage = null;
successMessage = null;
removingCoGmId = coGmTelegramId;
removingCoGmId = coGmExternalUserId;
try
{
await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId);
await SessionService.RemoveCoGmForOwnerAsync(GroupId, "Telegram", coGmExternalUserId);
successMessage = "Co-GM удалён.";
await LoadSessions();
}
@@ -527,7 +527,7 @@
try
{
await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId);
await SessionService.PromoteWaitlistedPlayerForCurrentUserAsync(sessionId);
await LoadSessions();
}
catch (SessionAccessDeniedException)
@@ -559,7 +559,7 @@
loadingParticipantsSessionId = sessionId;
try
{
var participants = await SessionService.GetSessionParticipantsForGmAsync(sessionId, telegramId);
var participants = await SessionService.GetSessionParticipantsForCurrentUserAsync(sessionId);
participantsCache[sessionId] = participants ?? [];
}
catch (Exception ex)
@@ -582,7 +582,7 @@
try
{
await SessionService.RemovePlayerFromSessionForGmAsync(sessionId, telegramId, participantId);
await SessionService.RemovePlayerFromSessionForCurrentUserAsync(sessionId, participantId);
participantsCache.Remove(sessionId);
successMessage = "Игрок исключён.";
await LoadSessions();
@@ -658,10 +658,9 @@
try
{
await SessionService.UpdateBatchDetailsForGmAsync(batch.BatchId, telegramId, batch.Title, batch.JoinLink);
await SessionService.UpdateBatchNotificationModeForGmAsync(
await SessionService.UpdateBatchDetailsForCurrentUserAsync(batch.BatchId, batch.Title, batch.JoinLink);
await SessionService.UpdateBatchNotificationModeForCurrentUserAsync(
batch.BatchId,
telegramId,
SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode));
successMessage = "Настройки batch обновлены.";
await LoadSessions();
@@ -696,7 +695,7 @@
try
{
var utcTime = new DateTimeOffset(batch.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.RescheduleBatchForGmAsync(batch.BatchId, telegramId, utcTime, batch.IntervalDays);
await SessionService.RescheduleBatchForCurrentUserAsync(batch.BatchId, utcTime, batch.IntervalDays);
successMessage = "Расписание пачки обновлено.";
await LoadSessions();
}
@@ -726,7 +725,7 @@
? BatchCloneInterval.NextMonth
: BatchCloneInterval.NextWeek;
var clonedBatch = await SessionService.CloneBatchForGmAsync(batch.BatchId, telegramId, interval);
var clonedBatch = await SessionService.CloneBatchForCurrentUserAsync(batch.BatchId, interval);
successMessage = $"Пачка склонирована: {clonedBatch.SessionCount} игр.";
await LoadSessions();
}
@@ -759,7 +758,7 @@
return;
}
var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForGmAsync(template.Id, telegramId, utcTime);
var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForCurrentUserAsync(template.Id, utcTime);
successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр.";
await LoadSessions();
}
@@ -836,7 +835,7 @@
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
private string CurrentUserRole =>
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
groupManagement?.Managers.FirstOrDefault(manager => manager.ExternalUserId == externalUserId)?.Role
?? GroupManagerRoleExtensions.CoGmValue;
private static string FormatRole(string role) =>
@@ -5,7 +5,7 @@
@using Microsoft.AspNetCore.Components.Authorization
@using System.Security.Claims
@attribute [Authorize]
@inject ISessionStore SessionStore
@inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@@ -85,9 +85,9 @@
<td>
<div class="player-info">
<span class="player-name">@s.DisplayName</span>
@if (!string.IsNullOrEmpty(s.TelegramUsername))
@if (!string.IsNullOrEmpty(s.ExternalUsername))
{
<span class="player-username">@@@s.TelegramUsername</span>
<span class="player-username">@@@s.ExternalUsername</span>
}
</div>
</td>
@@ -171,21 +171,20 @@
Navigation.NavigateTo("/login");
return;
}
var telegramIdClaim = user.FindFirst("telegram_id")?.Value
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!long.TryParse(telegramIdClaim, out var telegramId))
if (!user.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
Navigation.NavigateTo("/login");
return;
}
try
{
if (!await SessionStore.IsGroupManagerAsync(GroupId, telegramId))
var groupManagement = await SessionService.GetGroupManagementForCurrentUserAsync(GroupId);
if (groupManagement is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
stats = await SessionStore.GetGroupAttendanceStatsAsync(GroupId) ?? new();
stats = await SessionService.GetGroupAttendanceStatsForCurrentUserAsync(GroupId) ?? new();
UpdateSortedStats();
}
catch (Exception ex)
+3 -3
View File
@@ -43,7 +43,7 @@
<div class="glass-card group-card">
<div class="group-card-icon">🎮</div>
<h3 class="group-card-title">@group.Name</h3>
<p class="group-card-id">ID: @group.TelegramChatId</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;">
@FormatRole(group.ManagerRole)
</span>
@@ -93,13 +93,13 @@
var user = authState.User;
userName = user.Identity?.Name ?? "Мастер Игры";
if (!user.TryGetTelegramId(out var telegramId))
if (!user.TryGetPlatformIdentity(out _, out _))
{
Navigation.NavigateTo("/access-denied");
return;
}
groups = await SessionService.GetGroupsForGmAsync(telegramId);
groups = await SessionService.GetGroupsForCurrentUserAsync();
}
private static string FormatRole(string role) =>
+12 -1
View File
@@ -10,7 +10,7 @@
<div class="login-card">
<img src="logo.png" alt="GM-Relay" class="login-logo" />
<h1 class="login-title">GM-Relay</h1>
<p class="login-subtitle">Войдите через Telegram для управления игровыми сессиями</p>
<p class="login-subtitle">Войдите для управления игровыми сессиями</p>
@if (Navigation.Uri.Contains("error=auth_failed"))
{
@@ -20,6 +20,17 @@
}
<div id="telegram-login-container"></div>
<div class="login-divider">
<span>или</span>
</div>
<a href="/auth/discord" class="login-btn login-btn-discord">
<svg class="login-btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.086 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.086 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
Войти через Discord
</a>
</div>
</div>
@@ -56,7 +56,7 @@
{
<tr>
<td>@entry.ChangedAt.ToString("dd.MM.yyyy HH:mm") UTC</td>
<td>@entry.ActorName (@entry.ActorTelegramId)</td>
<td>@entry.ActorName (@entry.ActorExternalUserId)</td>
<td>
<span class="status-badge @(GetBadgeClass(entry.ChangeType))">
@GetChangeTypeLabel(entry.ChangeType)
@@ -82,13 +82,13 @@
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
}
var session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
var session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
if (session is null)
{
Navigation.NavigateTo("/access-denied");
@@ -97,7 +97,7 @@
sessionTitle = session.Title;
groupId = session.GroupId;
entries = await SessionService.GetSessionHistoryForGmAsync(SessionId, telegramId);
entries = await SessionService.GetSessionHistoryForCurrentUserAsync(SessionId);
}
private string GetChangeTypeLabel(string changeType) => changeType switch
+19
View File
@@ -0,0 +1,19 @@
namespace GmRelay.Web;
public sealed class DiscordOAuthOptions
{
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
public string RedirectUri { get; set; } = string.Empty;
public string[] Scopes { get; set; } = ["identify", "guilds"];
public void Validate()
{
if (string.IsNullOrWhiteSpace(ClientId))
throw new InvalidOperationException("Discord:ClientId is required.");
if (string.IsNullOrWhiteSpace(ClientSecret))
throw new InvalidOperationException("Discord:ClientSecret is required.");
if (string.IsNullOrWhiteSpace(RedirectUri))
throw new InvalidOperationException("Discord:RedirectUri is required.");
}
}
+79 -1
View File
@@ -1,3 +1,4 @@
using GmRelay.Web;
using GmRelay.Web.Components;
using GmRelay.Web.Health;
using GmRelay.Web.Services;
@@ -7,6 +8,7 @@ using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Telegram.Bot;
using Npgsql;
@@ -16,6 +18,12 @@ var builder = WebApplication.CreateBuilder(args);
// Add Aspire service defaults
builder.AddServiceDefaults();
// Add HttpClient
builder.Services.AddHttpClient();
// Add HttpContextAccessor for platform-agnostic identity resolution
builder.Services.AddHttpContextAccessor();
// Add health checks
builder.Services.AddHealthChecks()
.AddCheck<NpgsqlHealthCheck>("npgsql");
@@ -29,6 +37,8 @@ builder.AddNpgsqlDataSource("gmrelaydb");
// Add Services
builder.Services.AddSingleton<TelegramAuthService>();
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
builder.Services.AddSingleton<DiscordAuthService>();
builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>();
builder.Services.AddScoped<CalendarSubscriptionService>();
@@ -174,6 +184,56 @@ app.MapPost("/auth/logout", async (HttpContext context) =>
return Results.Redirect("/");
});
// Discord OAuth endpoints
app.MapGet("/auth/discord", (HttpContext context, DiscordAuthService discordAuth) =>
{
var state = Guid.NewGuid().ToString("N");
context.Response.Cookies.Append("__DiscordOAuthState", state, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
MaxAge = TimeSpan.FromMinutes(5)
});
var url = discordAuth.BuildAuthorizeUrl(state);
return Results.Redirect(url);
});
app.MapGet("/auth/discord/callback", async (
HttpContext context,
DiscordAuthService discordAuth,
ISessionStore sessionStore) =>
{
var code = context.Request.Query["code"].ToString();
var state = context.Request.Query["state"].ToString();
var storedState = context.Request.Cookies["__DiscordOAuthState"];
context.Response.Cookies.Delete("__DiscordOAuthState");
if (string.IsNullOrWhiteSpace(code) ||
string.IsNullOrWhiteSpace(state) ||
!CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(state),
System.Text.Encoding.UTF8.GetBytes(storedState ?? string.Empty)))
{
return Results.Redirect("/login?error=auth_failed");
}
var user = await discordAuth.ExchangeCodeAsync(code);
if (user is null)
return Results.Redirect("/login?error=auth_failed");
await sessionStore.UpsertDiscordUserAsync(user.Id, user.DisplayName, user.AvatarUrl);
var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
CreateDiscordPrincipal(user.Id, user.DisplayName, user.AvatarUrl),
authProperties);
return Results.Redirect("/");
});
// Public calendar subscription endpoint (no auth required)
app.MapGet("/calendar/{token}.ics", async (
string token,
@@ -200,11 +260,29 @@ static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name)
{
new(ClaimTypes.NameIdentifier, telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new(ClaimTypes.Name, name),
new("TelegramId", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture))
new("TelegramId", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new("Platform", "Telegram")
};
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
return new ClaimsPrincipal(claimsIdentity);
}
static ClaimsPrincipal CreateDiscordPrincipal(string discordId, string name, string? avatarUrl)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, discordId),
new(ClaimTypes.Name, name),
new("DiscordId", discordId),
new("Platform", "Discord")
};
if (!string.IsNullOrWhiteSpace(avatarUrl))
claims.Add(new Claim("AvatarUrl", avatarUrl));
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
return new ClaimsPrincipal(claimsIdentity);
}
public sealed record TelegramWebAppAuthRequest(string InitData);
@@ -1,182 +1,237 @@
using System.Security.Claims;
using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services;
public sealed class AuthorizedSessionService(ISessionStore sessionStore)
public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpContextAccessor httpContextAccessor)
{
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
sessionStore.GetGroupsForGmAsync(gmId);
public async Task<WebGroupManagement?> GetGroupManagementForGmAsync(Guid groupId, long gmId)
private (string Platform, string ExternalUserId, string Name)? GetCurrentIdentity()
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
var user = httpContextAccessor.HttpContext?.User;
if (user is null || !user.TryGetPlatformIdentity(out var platform, out var externalUserId))
return null;
var name = user.FindFirst(ClaimTypes.Name)?.Value ?? externalUserId;
return (platform, externalUserId, name);
}
public Task<List<WebGameGroup>> GetGroupsForCurrentUserAsync()
{
var identity = GetCurrentIdentity();
if (identity is null)
return Task.FromResult(new List<WebGameGroup>());
return sessionStore.GetGroupsForUserAsync(identity.Value.Platform, identity.Value.ExternalUserId);
}
public async Task<WebGroupManagement?> GetGroupManagementForCurrentUserAsync(Guid groupId)
{
var identity = GetCurrentIdentity();
if (identity is null)
return null;
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
return null;
}
var group = await sessionStore.GetGroupAsync(groupId);
if (group is null)
{
return null;
}
var managers = await sessionStore.GetGroupManagersAsync(groupId);
var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, gmId);
var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId);
return new WebGroupManagement(group, managers, isOwner);
}
public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId)
public async Task<List<WebSession>?> GetUpcomingSessionsForCurrentUserAsync(Guid groupId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
var identity = GetCurrentIdentity();
if (identity is null)
return null;
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
return null;
}
return await sessionStore.GetUpcomingSessionsAsync(groupId);
}
public async Task<WebSession?> GetSessionForGmAsync(Guid sessionId, long gmId)
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
{
var identity = GetCurrentIdentity();
if (identity is null)
return null;
var session = await sessionStore.GetSessionAsync(sessionId);
if (session is null)
{
return null;
}
return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null;
return await sessionStore.IsGroupManagerAsync(session.GroupId, identity.Value.Platform, identity.Value.ExternalUserId) ? session : null;
}
public async Task<WebSessionBatch?> GetBatchForGmAsync(Guid batchId, long gmId)
public async Task<WebSessionBatch?> GetBatchForCurrentUserAsync(Guid batchId)
{
var identity = GetCurrentIdentity();
if (identity is null)
return null;
var batch = await sessionStore.GetBatchAsync(batchId);
if (batch is null)
{
return null;
}
return await GroupBelongsToGmAsync(batch.GroupId, gmId) ? batch : null;
return await sessionStore.IsGroupManagerAsync(batch.GroupId, identity.Value.Platform, identity.Value.ExternalUserId) ? batch : null;
}
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
public async Task UpdateSessionForCurrentUserAsync(Guid sessionId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var session = await GetSessionForCurrentUserAsync(sessionId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, gmId);
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
}
await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink, maxPlayers);
if (session.Title != title)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Title", session.Title, title);
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Title", session.Title, title);
if (session.ScheduledAt != scheduledAt)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Time", session.ScheduledAt.ToString("O"), scheduledAt.ToString("O"));
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Time", session.ScheduledAt.ToString("O"), scheduledAt.ToString("O"));
if (session.JoinLink != joinLink)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Link", session.JoinLink, joinLink);
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Link", session.JoinLink, joinLink);
if (session.MaxPlayers != maxPlayers)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "MaxPlayers", session.MaxPlayers?.ToString(), maxPlayers?.ToString());
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "MaxPlayers", session.MaxPlayers?.ToString(), maxPlayers?.ToString());
}
public async Task PromoteWaitlistedPlayerForGmAsync(Guid sessionId, long gmId)
public async Task PromoteWaitlistedPlayerForCurrentUserAsync(Guid sessionId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var session = await GetSessionForCurrentUserAsync(sessionId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, gmId);
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
}
await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId);
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "WaitlistPromote", null, null);
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "WaitlistPromote", null, null);
}
public async Task UpdateBatchDetailsForGmAsync(Guid batchId, long gmId, string title, string joinLink)
public async Task UpdateBatchDetailsForCurrentUserAsync(Guid batchId, string title, string joinLink)
{
var batch = await GetBatchForGmAsync(batchId, gmId);
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var batch = await GetBatchForCurrentUserAsync(batchId);
if (batch is null)
{
throw new SessionAccessDeniedException(batchId, gmId);
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
}
await sessionStore.UpdateBatchDetailsAsync(batchId, batch.GroupId, title.Trim(), joinLink.Trim());
}
public async Task UpdateBatchNotificationModeForGmAsync(Guid batchId, long gmId, SessionNotificationMode notificationMode)
public async Task UpdateBatchNotificationModeForCurrentUserAsync(Guid batchId, SessionNotificationMode notificationMode)
{
var batch = await GetBatchForGmAsync(batchId, gmId);
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var batch = await GetBatchForCurrentUserAsync(batchId);
if (batch is null)
{
throw new SessionAccessDeniedException(batchId, gmId);
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
}
await sessionStore.UpdateBatchNotificationModeAsync(batchId, batch.GroupId, notificationMode);
}
public async Task RescheduleBatchForGmAsync(Guid batchId, long gmId, DateTime firstScheduledAt, int intervalDays)
public async Task RescheduleBatchForCurrentUserAsync(Guid batchId, DateTime firstScheduledAt, int intervalDays)
{
if (intervalDays <= 0)
{
throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be greater than zero.");
}
var batch = await GetBatchForGmAsync(batchId, gmId);
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var batch = await GetBatchForCurrentUserAsync(batchId);
if (batch is null)
{
throw new SessionAccessDeniedException(batchId, gmId);
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
}
await sessionStore.RescheduleBatchAsync(batchId, batch.GroupId, firstScheduledAt, intervalDays);
await sessionStore.LogSessionChangeAsync(batchId, gmId, "ГМ", "BatchRescheduled", batch.FirstScheduledAt.ToString("O"), firstScheduledAt.ToString("O"));
await sessionStore.LogSessionChangeAsync(batchId, identity.Value.ExternalUserId, identity.Value.Name, "BatchRescheduled", batch.FirstScheduledAt.ToString("O"), firstScheduledAt.ToString("O"));
}
public async Task<WebSessionBatch> CloneBatchForGmAsync(Guid batchId, long gmId, BatchCloneInterval interval)
public async Task<WebSessionBatch> CloneBatchForCurrentUserAsync(Guid batchId, BatchCloneInterval interval)
{
var batch = await GetBatchForGmAsync(batchId, gmId);
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var batch = await GetBatchForCurrentUserAsync(batchId);
if (batch is null)
{
throw new SessionAccessDeniedException(batchId, gmId);
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
}
return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval);
}
public async Task<List<WebCampaignTemplate>?> GetCampaignTemplatesForGmAsync(Guid groupId, long gmId)
public async Task<List<WebCampaignTemplate>?> GetCampaignTemplatesForCurrentUserAsync(Guid groupId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
var identity = GetCurrentIdentity();
if (identity is null)
return null;
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
return null;
}
return await sessionStore.GetCampaignTemplatesAsync(groupId);
}
public async Task<WebCampaignTemplate> CreateCampaignTemplateForGmAsync(
public async Task<WebCampaignTemplate> CreateCampaignTemplateForCurrentUserAsync(
Guid groupId,
long gmId,
CreateCampaignTemplateRequest request)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
{
throw new SessionAccessDeniedException(groupId, gmId);
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
}
var normalizedRequest = NormalizeCampaignTemplateRequest(request);
return await sessionStore.CreateCampaignTemplateAsync(groupId, normalizedRequest);
}
public async Task DeleteCampaignTemplateForGmAsync(Guid templateId, long gmId)
public async Task DeleteCampaignTemplateForCurrentUserAsync(Guid templateId)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var template = await sessionStore.GetCampaignTemplateAsync(templateId);
if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId))
if (template is null || !await sessionStore.IsGroupManagerAsync(template.GroupId, identity.Value.Platform, identity.Value.ExternalUserId))
{
throw new SessionAccessDeniedException(templateId, gmId);
throw new SessionAccessDeniedException(templateId, identity.Value.ExternalUserId);
}
await sessionStore.DeleteCampaignTemplateAsync(templateId, template.GroupId);
}
public async Task<WebSessionBatch> CreateBatchFromCampaignTemplateForGmAsync(
public async Task<WebSessionBatch> CreateBatchFromCampaignTemplateForCurrentUserAsync(
Guid templateId,
long gmId,
DateTime firstScheduledAt)
{
if (firstScheduledAt <= DateTime.UtcNow)
@@ -184,89 +239,112 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
throw new ArgumentOutOfRangeException(nameof(firstScheduledAt), firstScheduledAt, "First scheduled time must be in the future.");
}
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var template = await sessionStore.GetCampaignTemplateAsync(templateId);
if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId))
if (template is null || !await sessionStore.IsGroupManagerAsync(template.GroupId, identity.Value.Platform, identity.Value.ExternalUserId))
{
throw new SessionAccessDeniedException(templateId, gmId);
throw new SessionAccessDeniedException(templateId, identity.Value.ExternalUserId);
}
return await sessionStore.CreateBatchFromTemplateAsync(templateId, template.GroupId, firstScheduledAt);
}
public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
public async Task AddCoGmForOwnerAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername)
{
if (coGmTelegramId <= 0)
if (string.IsNullOrWhiteSpace(coGmExternalUserId))
{
throw new ArgumentOutOfRangeException(nameof(coGmTelegramId), coGmTelegramId, "Telegram id must be greater than zero.");
throw new ArgumentException("External user id must not be empty.", nameof(coGmExternalUserId));
}
if (ownerTelegramId == coGmTelegramId)
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
if (identity.Value.ExternalUserId == coGmExternalUserId && identity.Value.Platform == coGmPlatform)
{
throw new InvalidOperationException("Owner is already a group manager.");
}
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
if (!await sessionStore.IsGroupOwnerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
{
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
}
var normalizedName = string.IsNullOrWhiteSpace(displayName)
? $"Telegram {coGmTelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
? $"{coGmPlatform} {coGmExternalUserId}"
: displayName.Trim();
var normalizedUsername = string.IsNullOrWhiteSpace(telegramUsername)
var normalizedUsername = string.IsNullOrWhiteSpace(externalUsername)
? null
: telegramUsername.Trim().TrimStart('@');
: externalUsername.Trim().TrimStart('@');
await sessionStore.AddGroupCoGmAsync(groupId, ownerTelegramId, coGmTelegramId, normalizedName, normalizedUsername);
await sessionStore.AddGroupCoGmAsync(
groupId,
identity.Value.Platform, identity.Value.ExternalUserId,
coGmPlatform, coGmExternalUserId,
normalizedName, normalizedUsername);
}
public async Task RemoveCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId)
public async Task RemoveCoGmForOwnerAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId)
{
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
if (!await sessionStore.IsGroupOwnerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
{
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
}
await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId);
await sessionStore.RemoveGroupCoGmAsync(groupId, coGmPlatform, coGmExternalUserId);
}
public async Task<List<WebParticipant>?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId)
public async Task<List<WebParticipant>?> GetSessionParticipantsForCurrentUserAsync(Guid sessionId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
var session = await GetSessionForCurrentUserAsync(sessionId);
if (session is null)
{
return null;
}
return await sessionStore.GetSessionParticipantsAsync(sessionId);
}
public async Task<List<SessionAuditLogEntry>?> GetSessionHistoryForGmAsync(Guid sessionId, long gmId)
public async Task<List<SessionAuditLogEntry>?> GetSessionHistoryForCurrentUserAsync(Guid sessionId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
var session = await GetSessionForCurrentUserAsync(sessionId);
if (session is null)
{
return null;
}
return await sessionStore.GetSessionHistoryAsync(sessionId);
}
public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId)
public async Task RemovePlayerFromSessionForCurrentUserAsync(Guid sessionId, Guid participantId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var session = await GetSessionForCurrentUserAsync(sessionId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, gmId);
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
}
await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId);
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "PlayerRemoved", participantId.ToString(), null);
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "PlayerRemoved", participantId.ToString(), null);
}
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
public async Task<List<PlayerAttendanceStats>?> GetGroupAttendanceStatsForCurrentUserAsync(Guid groupId)
{
return await sessionStore.IsGroupManagerAsync(groupId, gmId);
var identity = GetCurrentIdentity();
if (identity is null)
return null;
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
return null;
return await sessionStore.GetGroupAttendanceStatsAsync(groupId);
}
private static CreateCampaignTemplateRequest NormalizeCampaignTemplateRequest(CreateCampaignTemplateRequest request)
@@ -6,4 +6,37 @@ public static class ClaimsPrincipalExtensions
{
public static bool TryGetTelegramId(this ClaimsPrincipal user, out long telegramId) =>
long.TryParse(user.FindFirst("TelegramId")?.Value, out telegramId);
public static bool TryGetDiscordId(this ClaimsPrincipal user, out string? discordId)
{
discordId = user.FindFirst("DiscordId")?.Value;
return !string.IsNullOrWhiteSpace(discordId);
}
public static bool TryGetPlatformIdentity(this ClaimsPrincipal user, out string platform, out string externalUserId)
{
platform = string.Empty;
externalUserId = string.Empty;
var platformClaim = user.FindFirst("Platform")?.Value;
if (!string.IsNullOrWhiteSpace(platformClaim))
{
platform = platformClaim;
externalUserId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
return !string.IsNullOrWhiteSpace(externalUserId);
}
// Fallback for legacy Telegram users before Platform claim was added
if (TryGetTelegramId(user, out var telegramId))
{
platform = "Telegram";
externalUserId = telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture);
return true;
}
return false;
}
public static string? GetAvatarUrl(this ClaimsPrincipal user) =>
user.FindFirst("AvatarUrl")?.Value;
}
@@ -0,0 +1,82 @@
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace GmRelay.Web.Services;
public sealed class DiscordAuthService(IHttpClientFactory httpClientFactory, IConfiguration configuration)
{
private readonly DiscordOAuthOptions _options = configuration.GetSection("Discord").Get<DiscordOAuthOptions>() ?? new DiscordOAuthOptions();
public string BuildAuthorizeUrl(string state)
{
_options.Validate();
var scopes = string.Join(" ", _options.Scopes);
return $"https://discord.com/oauth2/authorize?client_id={_options.ClientId}&redirect_uri={Uri.EscapeDataString(_options.RedirectUri)}&response_type=code&scope={Uri.EscapeDataString(scopes)}&state={Uri.EscapeDataString(state)}";
}
public async Task<DiscordUser?> ExchangeCodeAsync(string code)
{
_options.Validate();
var client = httpClientFactory.CreateClient();
var tokenResponse = await ExchangeCodeForTokenAsync(client, code);
if (tokenResponse is null)
return null;
return await FetchUserProfileAsync(client, tokenResponse.AccessToken);
}
private async Task<DiscordTokenResponse?> ExchangeCodeForTokenAsync(HttpClient client, string code)
{
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
["code"] = code,
["redirect_uri"] = _options.RedirectUri,
["client_id"] = _options.ClientId,
["client_secret"] = _options.ClientSecret
});
var response = await client.PostAsync("https://discord.com/api/oauth2/token", content);
if (!response.IsSuccessStatusCode)
return null;
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<DiscordTokenResponse>(json);
}
private static async Task<DiscordUser?> FetchUserProfileAsync(HttpClient client, string accessToken)
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var response = await client.GetAsync("https://discord.com/api/users/@me");
if (!response.IsSuccessStatusCode)
return null;
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<DiscordUser>(json);
}
}
public sealed record DiscordTokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("token_type")] string TokenType,
[property: JsonPropertyName("expires_in")] int ExpiresIn,
[property: JsonPropertyName("scope")] string Scope);
public sealed record DiscordUser(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("username")] string Username,
[property: JsonPropertyName("discriminator")] string Discriminator,
[property: JsonPropertyName("avatar")] string? Avatar,
[property: JsonPropertyName("email")] string? Email)
{
public string DisplayName =>
Discriminator == "0" ? Username : $"{Username}#{Discriminator}";
public string? AvatarUrl =>
!string.IsNullOrWhiteSpace(Avatar)
? $"https://cdn.discordapp.com/avatars/{Id}/{Avatar}.png"
: null;
}
+11 -12
View File
@@ -5,33 +5,31 @@ namespace GmRelay.Web.Services;
public sealed record PlayerAttendanceStats(
Guid PlayerId,
string DisplayName,
string? TelegramUsername,
string? ExternalUsername,
long TotalSessions,
long ConfirmedCount,
long DeclinedCount,
long NoResponseCount,
long WaitlistedCount,
long CancellationAffectedCount,
decimal AttendanceRate
);
decimal AttendanceRate);
public sealed record SessionAuditLogEntry(
Guid Id,
Guid SessionId,
long ActorTelegramId,
string ActorExternalUserId,
string ActorName,
string ChangeType,
string? OldValue,
string? NewValue,
DateTime ChangedAt
);
DateTime ChangedAt);
public interface ISessionStore
{
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId);
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId);
Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId);
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
Task<WebSession?> GetSessionAsync(Guid sessionId);
@@ -47,11 +45,12 @@ public interface ISessionStore
Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request);
Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId);
Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt);
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername);
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId);
Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername);
Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId);
Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);
Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId);
Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue);
Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue);
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
}
@@ -1,4 +1,4 @@
namespace GmRelay.Web.Services;
public sealed class SessionAccessDeniedException(Guid sessionId, long gmId)
: InvalidOperationException($"Session '{sessionId}' is not accessible for GM '{gmId}'.");
public sealed class SessionAccessDeniedException(Guid sessionId, string externalUserId)
: InvalidOperationException($"Session '{sessionId}' is not accessible for user '{externalUserId}'.");
+101 -55
View File
@@ -10,16 +10,32 @@ namespace GmRelay.Web.Services;
public sealed record WebGameGroup(
Guid Id,
long TelegramChatId,
string? ExternalGroupId,
string Name,
long GmTelegramId,
string ManagerRole = GroupManagerRoleExtensions.OwnerValue);
string? Platform,
string ManagerRole = GroupManagerRoleExtensions.OwnerValue)
{
public long GmTelegramId { get; init; }
public WebGameGroup(Guid id, long telegramChatId, string name, long gmTelegramId)
: this(id, telegramChatId, null, name, null)
{
GmTelegramId = gmTelegramId;
}
}
public sealed record WebGroupManager(
long TelegramId,
string? ExternalUserId,
string DisplayName,
string? TelegramUsername,
string? ExternalUsername,
string Role,
DateTime AddedAt);
DateTime AddedAt)
{
public WebGroupManager(long telegramId, string displayName, string? telegramUsername, string role, DateTime addedAt)
: this(telegramId, null, displayName, telegramUsername, null, role, addedAt) { }
}
public sealed record WebGroupManagement(
WebGameGroup Group,
@@ -44,8 +60,10 @@ public sealed record WebSession(
public sealed record WebParticipant(
Guid Id,
long TelegramId,
string? ExternalUserId,
string DisplayName,
string? TelegramUsername,
string? ExternalUsername,
string RsvpStatus,
string RegistrationStatus,
bool IsGm,
@@ -83,23 +101,25 @@ public sealed class SessionService(
ITelegramBotClient bot,
ILogger<SessionService> logger) : ISessionStore
{
public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId)
public async Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGameGroup>(
"""
SELECT g.id,
g.telegram_chat_id AS TelegramChatId,
g.external_group_id AS ExternalGroupId,
g.name,
g.gm_telegram_id AS GmTelegramId,
g.platform AS Platform,
gm.role AS ManagerRole
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE p.telegram_id = @GmId
WHERE p.platform = @Platform
AND p.external_user_id = @ExternalUserId
ORDER BY g.name
""",
new { GmId = gmId })).ToList();
new { Platform = platform, ExternalUserId = externalUserId })).ToList();
}
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
@@ -109,8 +129,9 @@ public sealed class SessionService(
"""
SELECT g.id,
g.telegram_chat_id AS TelegramChatId,
g.external_group_id AS ExternalGroupId,
g.name,
g.gm_telegram_id AS GmTelegramId,
g.platform AS Platform,
@OwnerRole AS ManagerRole
FROM game_groups g
WHERE g.id = @GroupId
@@ -118,7 +139,7 @@ public sealed class SessionService(
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
}
public async Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId)
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>(
@@ -128,13 +149,14 @@ public sealed class SessionService(
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
AND p.telegram_id = @TelegramId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
)
""",
new { GroupId = groupId, TelegramId = telegramId });
new { GroupId = groupId, Platform = platform, ExternalUserId = externalUserId });
}
public async Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId)
public async Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>(
@@ -144,11 +166,12 @@ public sealed class SessionService(
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
AND p.telegram_id = @TelegramId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND gm.role = @OwnerRole
)
""",
new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
new { GroupId = groupId, Platform = platform, ExternalUserId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
}
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
@@ -156,9 +179,11 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGroupManager>(
"""
SELECT p.telegram_id AS TelegramId,
SELECT COALESCE(p.telegram_id, 0) AS TelegramId,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
gm.role AS Role,
gm.created_at AS AddedAt
FROM group_managers gm
@@ -179,7 +204,7 @@ public sealed class SessionService(
SELECT
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
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 = 'Declined' THEN s.id END) AS DeclinedCount,
@@ -198,21 +223,21 @@ public sealed class SessionService(
WHERE s.group_id = @GroupId
AND s.scheduled_at <= now()
AND sp.is_gm = false
GROUP BY p.id, p.display_name, p.telegram_username
GROUP BY p.id, p.display_name, p.external_username, p.telegram_username
ORDER BY AttendanceRate DESC, ConfirmedCount DESC
""",
new { GroupId = groupId })).ToList();
}
public async Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue)
public async Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"""
INSERT INTO session_audit_log (session_id, actor_telegram_id, actor_name, change_type, old_value, new_value)
VALUES (@SessionId, @ActorTelegramId, @ActorName, @ChangeType, @OldValue, @NewValue)
INSERT INTO session_audit_log (session_id, actor_external_user_id, actor_name, change_type, old_value, new_value)
VALUES (@SessionId, @ActorExternalUserId, @ActorName, @ChangeType, @OldValue, @NewValue)
""",
new { SessionId = sessionId, ActorTelegramId = actorTelegramId, ActorName = actorName, ChangeType = changeType, OldValue = oldValue, NewValue = newValue });
new { SessionId = sessionId, ActorExternalUserId = actorExternalUserId, ActorName = actorName, ChangeType = changeType, OldValue = oldValue, NewValue = newValue });
}
public async Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId)
@@ -220,7 +245,7 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync();
var entries = await conn.QueryAsync<SessionAuditLogEntry>(
"""
SELECT id, session_id AS SessionId, actor_telegram_id AS ActorTelegramId, actor_name AS ActorName,
SELECT id, session_id AS SessionId, actor_external_user_id AS ActorExternalUserId, actor_name AS ActorName,
change_type AS ChangeType, old_value AS OldValue, new_value AS NewValue, changed_at AS ChangedAt
FROM session_audit_log
WHERE session_id = @SessionId
@@ -230,32 +255,47 @@ public sealed class SessionService(
return entries.ToList();
}
public async Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"""
INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@DisplayName, 'Discord', @DiscordId, @DisplayName)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE
SET display_name = EXCLUDED.display_name,
external_username = EXCLUDED.external_username
""",
new { DisplayName = displayName, DiscordId = discordId });
}
public async Task AddGroupCoGmAsync(
Guid groupId,
long ownerTelegramId,
long coGmTelegramId,
string displayName,
string? telegramUsername)
string ownerPlatform, string ownerExternalUserId,
string coGmPlatform, string coGmExternalUserId,
string displayName, string? externalUsername)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
await conn.ExecuteAsync(
"""
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TelegramId, @DisplayName, @TelegramUsername, 'Telegram', @TelegramId::TEXT, @TelegramUsername)
ON CONFLICT (telegram_id) DO UPDATE
INSERT INTO players (display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@DisplayName, @ExternalUsername, @Platform, @ExternalUserId, @ExternalUsername)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username,
platform = COALESCE(players.platform, 'Telegram'),
external_user_id = COALESCE(players.external_user_id, EXCLUDED.telegram_id::TEXT),
external_username = COALESCE(players.external_username, EXCLUDED.telegram_username)
external_username = EXCLUDED.external_username
""",
new
{
TelegramId = coGmTelegramId,
DisplayName = displayName,
TelegramUsername = telegramUsername
ExternalUsername = externalUsername,
Platform = coGmPlatform,
ExternalUserId = coGmExternalUserId
},
transaction);
@@ -267,8 +307,8 @@ public sealed class SessionService(
@CoGmRole,
owner_player.id
FROM players co_gm
LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId
WHERE co_gm.telegram_id = @CoGmTelegramId
LEFT JOIN players owner_player ON owner_player.platform = @OwnerPlatform AND owner_player.external_user_id = @OwnerExternalUserId
WHERE co_gm.platform = @CoGmPlatform AND co_gm.external_user_id = @CoGmExternalUserId
ON CONFLICT (group_id, player_id) DO UPDATE
SET role = CASE
WHEN group_managers.role = @OwnerRole THEN group_managers.role
@@ -279,8 +319,10 @@ public sealed class SessionService(
new
{
GroupId = groupId,
OwnerTelegramId = ownerTelegramId,
CoGmTelegramId = coGmTelegramId,
OwnerPlatform = ownerPlatform,
OwnerExternalUserId = ownerExternalUserId,
CoGmPlatform = coGmPlatform,
CoGmExternalUserId = coGmExternalUserId,
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
},
@@ -289,7 +331,7 @@ public sealed class SessionService(
await transaction.CommitAsync();
}
public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
public async Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
@@ -298,13 +340,15 @@ public sealed class SessionService(
USING players p
WHERE gm.player_id = p.id
AND gm.group_id = @GroupId
AND p.telegram_id = @CoGmTelegramId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND gm.role = @CoGmRole
""",
new
{
GroupId = groupId,
CoGmTelegramId = coGmTelegramId,
Platform = coGmPlatform,
ExternalUserId = coGmExternalUserId,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
});
}
@@ -426,7 +470,7 @@ public sealed class SessionService(
if (oldSession is null)
{
throw new SessionAccessDeniedException(sessionId, 0);
throw new SessionAccessDeniedException(sessionId, "0");
}
var updatedRows = await conn.ExecuteAsync(
@@ -454,7 +498,7 @@ public sealed class SessionService(
if (updatedRows == 0)
{
throw new SessionAccessDeniedException(sessionId, 0);
throw new SessionAccessDeniedException(sessionId, "0");
}
await conn.ExecuteAsync(
@@ -513,7 +557,7 @@ public sealed class SessionService(
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, 0);
throw new SessionAccessDeniedException(sessionId, "0");
}
var activeParticipants = await conn.ExecuteScalarAsync<int>(
@@ -597,9 +641,11 @@ public sealed class SessionService(
return (await conn.QueryAsync<WebParticipant>(
"""
SELECT sp.id AS Id,
p.telegram_id AS TelegramId,
COALESCE(p.telegram_id, 0) AS TelegramId,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm,
@@ -637,7 +683,7 @@ public sealed class SessionService(
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, 0);
throw new SessionAccessDeniedException(sessionId, "0");
}
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
@@ -744,7 +790,7 @@ public sealed class SessionService(
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
if (batch is null)
{
throw new SessionAccessDeniedException(batchId, 0);
throw new SessionAccessDeniedException(batchId, "0");
}
var updatedRows = await conn.ExecuteAsync(
@@ -767,7 +813,7 @@ public sealed class SessionService(
if (updatedRows == 0)
{
throw new SessionAccessDeniedException(batchId, 0);
throw new SessionAccessDeniedException(batchId, "0");
}
await transaction.CommitAsync();
@@ -786,7 +832,7 @@ public sealed class SessionService(
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
if (batch is null)
{
throw new SessionAccessDeniedException(batchId, 0);
throw new SessionAccessDeniedException(batchId, "0");
}
var updatedRows = await conn.ExecuteAsync(
@@ -807,7 +853,7 @@ public sealed class SessionService(
if (updatedRows == 0)
{
throw new SessionAccessDeniedException(batchId, 0);
throw new SessionAccessDeniedException(batchId, "0");
}
await transaction.CommitAsync();
@@ -844,7 +890,7 @@ public sealed class SessionService(
if (batchSessions.Count == 0)
{
throw new SessionAccessDeniedException(batchId, 0);
throw new SessionAccessDeniedException(batchId, "0");
}
var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule(
@@ -928,7 +974,7 @@ public sealed class SessionService(
if (sourceSessions.Count == 0)
{
throw new SessionAccessDeniedException(batchId, 0);
throw new SessionAccessDeniedException(batchId, "0");
}
var newBatchId = Guid.NewGuid();
@@ -1130,7 +1176,7 @@ public sealed class SessionService(
if (template is null)
{
throw new SessionAccessDeniedException(templateId, 0);
throw new SessionAccessDeniedException(templateId, "0");
}
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
@@ -1140,7 +1186,7 @@ public sealed class SessionService(
if (group is null)
{
throw new SessionAccessDeniedException(groupId, 0);
throw new SessionAccessDeniedException(groupId, "0");
}
var schedule = BatchSchedulePlanner.BuildRecurringSchedule(
+5 -1
View File
@@ -243,7 +243,11 @@
}
},
"gmrelay.shared": {
"type": "Project"
"type": "Project",
"dependencies": {
"Dapper": "[2.1.72, )",
"Npgsql": "[10.0.2, )"
}
}
}
}
+75
View File
@@ -1619,3 +1619,78 @@ body.telegram-mini-app .session-card-mobile {
display: flex;
}
}
/* === Discord Login Button === */
.login-btn-discord {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1.25rem;
border-radius: var(--radius-md);
font-weight: 500;
font-size: 0.9375rem;
text-decoration: none;
transition: all 0.2s ease;
background-color: #5865F2;
color: white;
border: none;
cursor: pointer;
}
.login-btn-discord:hover {
background-color: #4752C4;
transform: translateY(-1px);
}
.login-btn-discord:active {
transform: translateY(0);
}
.login-btn-discord .login-btn-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.login-divider {
display: flex;
align-items: center;
margin: 1.25rem 0;
color: var(--text-muted);
font-size: 0.875rem;
}
.login-divider::before,
.login-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--border-color);
}
.login-divider span {
padding: 0 1rem;
}
/* === Platform Indicator in Nav === */
.nav-user-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.nav-user-platform {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.025em;
}
.nav-user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
@@ -20,11 +20,14 @@ public sealed class DiscordNewSessionHandlerTests
[Fact]
public void ParseTimeInput_ShouldParseDiscordDateFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("2026-05-20 19:30");
var expected = FutureDateAt1930();
var result = DiscordNewSessionHandler.ParseTimeInput(
expected.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture));
Assert.True(result.IsSuccess);
Assert.Equal(2026, result.Value.Year);
Assert.Equal(5, result.Value.Month);
Assert.Equal(20, result.Value.Day);
Assert.Equal(expected.Year, result.Value.Year);
Assert.Equal(expected.Month, result.Value.Month);
Assert.Equal(expected.Day, result.Value.Day);
Assert.Equal(19, result.Value.Hour);
Assert.Equal(30, result.Value.Minute);
}
@@ -39,11 +42,14 @@ public sealed class DiscordNewSessionHandlerTests
[Fact]
public void ParseTimeInput_ShouldParseRussianDateFormat()
{
var result = DiscordNewSessionHandler.ParseTimeInput("20.05.2026 19:30");
var expected = FutureDateAt1930();
var result = DiscordNewSessionHandler.ParseTimeInput(
expected.ToString("dd.MM.yyyy HH:mm", System.Globalization.CultureInfo.InvariantCulture));
Assert.True(result.IsSuccess);
Assert.Equal(2026, result.Value.Year);
Assert.Equal(5, result.Value.Month);
Assert.Equal(20, result.Value.Day);
Assert.Equal(expected.Year, result.Value.Year);
Assert.Equal(expected.Month, result.Value.Month);
Assert.Equal(expected.Day, result.Value.Day);
}
[Fact]
@@ -141,4 +147,17 @@ public sealed class DiscordNewSessionHandlerTests
Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal);
Assert.Contains("WithEmbeds", source, StringComparison.Ordinal);
}
private static DateTimeOffset FutureDateAt1930()
{
var future = DateTimeOffset.UtcNow.AddDays(7);
return new DateTimeOffset(
future.Year,
future.Month,
future.Day,
19,
30,
0,
TimeSpan.Zero);
}
}
@@ -7,9 +7,13 @@ namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordPlatformMessengerTests
{
[Fact]
public void Constructor_ShouldAcceptRestClient()
public void Constructor_ShouldAcceptRestClientAndReplyCache()
{
var constructor = typeof(DiscordPlatformMessenger).GetConstructor(new[] { typeof(NetCord.Rest.RestClient) });
var constructor = typeof(DiscordPlatformMessenger).GetConstructor(new[]
{
typeof(NetCord.Rest.RestClient),
typeof(DiscordInteractionReplyCache)
});
Assert.NotNull(constructor);
}
@@ -18,4 +22,45 @@ public sealed class DiscordPlatformMessengerTests
{
Assert.True(typeof(IPlatformMessenger).IsAssignableFrom(typeof(DiscordPlatformMessenger)));
}
[Fact]
public async Task AnswerInteractionAsync_ShouldStoreReplyForComponentModule()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs");
Assert.Contains("DiscordInteractionReplyCache", source, StringComparison.Ordinal);
Assert.Contains("interactionReplies.Store(reply)", source, StringComparison.Ordinal);
}
[Fact]
public async Task DiscordPlatformMessenger_ShouldSupportSchedulerNotifications()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs");
Assert.Contains("SendConfirmationRequestAsync", source, StringComparison.Ordinal);
Assert.Contains("UpdateConfirmationRequestAsync", source, StringComparison.Ordinal);
Assert.Contains("SendJoinLinkNotificationAsync", source, StringComparison.Ordinal);
Assert.Contains("SendDirectSessionNotificationAsync", source, StringComparison.Ordinal);
Assert.Contains("UpdateRescheduleVoteAsync", source, StringComparison.Ordinal);
Assert.Contains("DiscordSessionBatchRenderer", source, StringComparison.Ordinal);
Assert.Contains("DiscordRescheduleVotingRenderer", source, StringComparison.Ordinal);
Assert.Contains("GetDMChannelAsync", source, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -61,7 +61,7 @@ public sealed class DiscordProjectStructureTests
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
Assert.Contains("gmrelay-discord-bot:2.4.0", compose);
Assert.Contains("gmrelay-discord-bot:2.8.0", compose);
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
@@ -75,13 +75,39 @@ public sealed class DiscordProjectStructureTests
{
var repoRoot = GetRepoRoot();
Assert.Contains("<Version>2.4.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 2.4.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:2.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:2.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:2.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("<Version>2.8.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 2.8.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains(
"v2.4.0",
"v2.8.0",
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
}
[Fact]
public void EnvExample_ShouldContainDiscordBotToken()
{
var repoRoot = GetRepoRoot();
var envExample = File.ReadAllText(Path.Combine(repoRoot, ".env.example"));
Assert.Contains("DISCORD_BOT_TOKEN", envExample);
}
[Fact]
public void Compose_ShouldIncludeDiscordHealthcheck()
{
var repoRoot = GetRepoRoot();
var compose = File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"));
var discordIndex = compose.IndexOf("discord:", StringComparison.Ordinal);
Assert.True(discordIndex >= 0, "compose.yaml should contain discord service");
var nextServiceIndex = compose.IndexOf(" web:", StringComparison.Ordinal);
var discordBlock = compose[discordIndex..nextServiceIndex];
Assert.Contains("healthcheck:", discordBlock);
Assert.Contains("test:", discordBlock);
Assert.Contains("localhost:8082/health", discordBlock);
}
}
@@ -0,0 +1,31 @@
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordRescheduleDeadlineBoundaryTests
{
[Fact]
public async Task DiscordDeadlineService_ShouldUsePlatformMessengerForMessageUpdates()
{
var source = await ReadRepositoryFileAsync(
"src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVotingDeadlineService.cs");
Assert.DoesNotContain("RestClient", source, StringComparison.Ordinal);
Assert.DoesNotContain("ModifyMessageAsync", source, StringComparison.Ordinal);
Assert.Contains("UpdateRescheduleVoteAsync", source, StringComparison.Ordinal);
Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
return await File.ReadAllTextAsync(candidate);
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -0,0 +1,96 @@
using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordSessionInteractionMapperTests
{
[Fact]
public void TryParseCustomId_WhenActionAndSessionIdMatch_ReturnsSessionId()
{
var sessionId = Guid.NewGuid();
var result = DiscordSessionInteractionMapper.TryParseCustomId(
$"join_session:{sessionId}",
"join_session",
out var parsedSessionId);
Assert.True(result);
Assert.Equal(sessionId, parsedSessionId);
}
[Fact]
public void TryParseCustomId_WhenActionDoesNotMatch_ReturnsFalse()
{
var result = DiscordSessionInteractionMapper.TryParseCustomId(
$"leave_session:{Guid.NewGuid()}",
"join_session",
out _);
Assert.False(result);
}
[Fact]
public void TryParseCustomId_WhenSessionIdIsInvalid_ReturnsFalse()
{
var result = DiscordSessionInteractionMapper.TryParseCustomId(
"join_session:not-a-guid",
"join_session",
out _);
Assert.False(result);
}
[Fact]
public void CreateJoinCommand_ShouldBuildPlatformNeutralDiscordCommand()
{
var sessionId = Guid.NewGuid();
var input = CreateInput(sessionId, displayName: "Alice GM");
JoinSessionCommand command = DiscordSessionInteractionMapper.CreateJoinCommand(input);
Assert.Equal(sessionId, command.SessionId);
Assert.Equal("interaction-1", command.InteractionId);
Assert.Equal(PlatformKind.Discord, command.User.Platform);
Assert.Equal("42", command.User.ExternalUserId);
Assert.Equal("Alice GM", command.User.DisplayName);
Assert.Equal("alice", command.User.ExternalUsername);
Assert.Equal(PlatformKind.Discord, command.Group.Platform);
Assert.Equal("guild-1", command.Group.ExternalGroupId);
Assert.Equal("channel-1", command.Group.ExternalChannelId);
Assert.Equal(PlatformKind.Discord, command.ScheduleMessage.Platform);
Assert.Equal("guild-1", command.ScheduleMessage.ExternalGroupId);
Assert.Equal("message-1", command.ScheduleMessage.ExternalMessageId);
}
[Fact]
public void CreateLeaveCommand_ShouldBuildPlatformNeutralDiscordCommand()
{
var sessionId = Guid.NewGuid();
var input = CreateInput(sessionId, displayName: null);
LeaveSessionCommand command = DiscordSessionInteractionMapper.CreateLeaveCommand(input);
Assert.Equal(sessionId, command.SessionId);
Assert.Equal("interaction-1", command.InteractionId);
Assert.Equal(PlatformKind.Discord, command.User.Platform);
Assert.Equal("42", command.User.ExternalUserId);
Assert.Equal("alice", command.User.DisplayName);
Assert.Equal("alice", command.User.ExternalUsername);
Assert.Equal("guild-1", command.Group.ExternalGroupId);
Assert.Equal("channel-1", command.Group.ExternalChannelId);
Assert.Equal("message-1", command.ScheduleMessage.ExternalMessageId);
}
private static DiscordSessionInteractionInput CreateInput(Guid sessionId, string? displayName)
=> new(
SessionId: sessionId,
InteractionId: "interaction-1",
GuildId: "guild-1",
ChannelId: "channel-1",
MessageId: "message-1",
UserId: 42,
Username: "alice",
DisplayName: displayName);
}
@@ -0,0 +1,50 @@
namespace GmRelay.Bot.Tests.Discord;
public sealed class DiscordSessionInteractionModuleSourceTests
{
[Fact]
public async Task Module_ShouldRouteJoinAndLeaveButtonsToNeutralHandlers()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
Assert.Contains("ComponentInteractionModule<ButtonInteractionContext>", source, StringComparison.Ordinal);
Assert.Contains("[ComponentInteraction(\"join_session\")]", source, StringComparison.Ordinal);
Assert.Contains("[ComponentInteraction(\"leave_session\")]", source, StringComparison.Ordinal);
Assert.Contains("JoinSessionHandler", source, StringComparison.Ordinal);
Assert.Contains("LeaveSessionHandler", source, StringComparison.Ordinal);
Assert.Contains("DiscordSessionInteractionMapper.CreateJoinCommand", source, StringComparison.Ordinal);
Assert.Contains("DiscordSessionInteractionMapper.CreateLeaveCommand", source, StringComparison.Ordinal);
Assert.Contains("RespondAsync", source, StringComparison.Ordinal);
Assert.Contains("InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)", source, StringComparison.Ordinal);
Assert.Contains("ModifyResponseAsync", source, StringComparison.Ordinal);
Assert.Contains("Не удалось обработать кнопку.", source, StringComparison.Ordinal);
Assert.Contains("MessageFlags.Ephemeral", source, StringComparison.Ordinal);
}
[Fact]
public async Task Module_ShouldRouteRsvpButtonsToNeutralHandler()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
Assert.Contains("[ComponentInteraction(\"rsvp\")", source, StringComparison.Ordinal);
Assert.Contains("HandleRsvpHandler", source, StringComparison.Ordinal);
Assert.Contains("PlatformKind.Discord", source, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -77,9 +77,22 @@ public sealed class DiscordStartupTests
var program = ReadProgram();
Assert.Contains("DiscordListSessionsHandler", program);
Assert.Contains("DiscordNewSessionHandler", program);
Assert.Contains("JoinSessionHandler", program);
Assert.Contains("LeaveSessionHandler", program);
Assert.Contains("DiscordPermissionChecker", program);
Assert.Contains("DiscordPlatformMessenger", program);
Assert.Contains("IPlatformMessenger", program);
Assert.Contains("PlatformSchedulerOptions(PlatformKind.Discord)", program);
Assert.Contains("AddHostedService<SessionSchedulerService>", program);
Assert.Contains("HandleRsvpHandler", program);
}
[Fact]
public void Program_ShouldRegisterDiscordHealthCheckHostedService()
{
var program = ReadProgram();
Assert.Contains("DiscordHealthCheckHostedService", program);
Assert.Contains("AddHostedService<DiscordHealthCheckHostedService>", program);
}
private static string ReadProgram()
@@ -1,4 +1,4 @@
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Features.Confirmation.HandleRsvp;
@@ -0,0 +1,410 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Rendering;
using NetCord.Rest;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Tests.Features.Landing;
public sealed class DiscordLandingPromisesSmokeTests
{
[Fact]
public void Smoke_ShouldCoverDiscordLandingPromisesWithoutExternalDiscordApi()
{
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc);
Assert.True(parseResult.IsValid);
Assert.Equal(3, parseResult.ScheduledTimes.Count);
Assert.Equal(2, parseResult.MaxPlayers);
Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]);
var scenario = DiscordLandingSmokeScenario.Publish(parseResult, SessionNotificationMode.GroupAndDirect);
var publishedCallbacks = CallbackData(scenario.LastMessage.ActionRows);
Assert.Contains(scenario.LastMessage.Embeds, e => e.Title!.Contains("Landing Promise Smoke"));
Assert.Equal(6, publishedCallbacks.Count);
Assert.All(scenario.Sessions, session =>
{
Assert.Contains($"join_session:{session.Id}", publishedCallbacks);
Assert.Contains($"leave_session:{session.Id}", publishedCallbacks);
Assert.Contains(scenario.LastMessage.Embeds, embed => embed.Title!.Contains(session.ScheduledAt.FormatMoscow()));
});
var firstSessionId = scenario.Sessions[0].Id;
var alice = scenario.Join(firstSessionId, 1001UL, "Alice");
var bob = scenario.Join(firstSessionId, 1002UL, "Bob");
var carol = scenario.Join(firstSessionId, 1003UL, "Carol");
Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, alice));
Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, bob));
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, scenario.RegistrationStatus(firstSessionId, carol));
var firstSessionEmbed = scenario.LastMessage.Embeds.First(e => e.Title!.Contains(scenario.Sessions[0].ScheduledAt.FormatMoscow()));
Assert.Equal("2/2", firstSessionEmbed.Fields!.First().Value);
Assert.Contains("Alice", firstSessionEmbed.Description);
Assert.Contains("Bob", firstSessionEmbed.Description);
Assert.Contains("Carol", firstSessionEmbed.Description);
scenario.Leave(firstSessionId, alice);
Assert.False(scenario.HasParticipant(firstSessionId, alice));
Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, carol));
firstSessionEmbed = scenario.LastMessage.Embeds.First(e => e.Title!.Contains(scenario.Sessions[0].ScheduledAt.FormatMoscow()));
Assert.DoesNotContain("Alice", firstSessionEmbed.Description);
Assert.Contains("Carol", firstSessionEmbed.Description);
scenario.MarkRsvpConfirmed(firstSessionId, bob);
scenario.MarkRsvpConfirmed(firstSessionId, carol);
var option1Id = Guid.NewGuid();
var option2Id = Guid.NewGuid();
var options = new[]
{
new RescheduleOptionDto(option1Id, 1, new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero)),
new RescheduleOptionDto(option2Id, 2, new DateTimeOffset(2026, 5, 30, 15, 0, 0, TimeSpan.Zero))
};
var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero);
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
var voteMessage = HandleRescheduleTimeInputHandler.BuildVotingMessage(
scenario.Title,
scenario.Sessions[0].ScheduledAt,
deadline,
options,
voteParticipants,
[]);
var voteKeyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
Assert.Contains("Landing Promise Smoke", voteMessage);
Assert.Contains("0/2", voteMessage);
Assert.Contains($"reschedule_vote:{option1Id}", CallbackData(voteKeyboard));
Assert.Contains($"reschedule_vote:{option2Id}", CallbackData(voteKeyboard));
var votes = voteParticipants
.Select(participant => new RescheduleOptionVoteDto(
option2Id,
participant.PlayerId,
participant.DisplayName,
participant.TelegramUsername))
.ToList();
var decision = RescheduleVoteRules.SelectWinner(
options.Select(option => new RescheduleOptionVoteCount(
option.OptionId,
votes.Count(vote => vote.OptionId == option.OptionId))).ToList());
Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome);
Assert.Equal(option2Id, decision.SelectedOptionId);
scenario.ApplyReschedule(firstSessionId, options[1].ProposedAt);
Assert.Equal(options[1].ProposedAt.UtcDateTime, scenario.Sessions[0].ScheduledAt);
Assert.Equal(GmRelay.Shared.Domain.RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, bob));
Assert.Equal(GmRelay.Shared.Domain.RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, carol));
Assert.Contains(scenario.LastMessage.Embeds, e => e.Title!.Contains(options[1].ProposedAt.UtcDateTime.FormatMoscow()));
Assert.Collection(
scenario.Messenger.DirectMessages.Select(message => message.DiscordId).Order(),
discordId => Assert.Equal(1002UL, discordId),
discordId => Assert.Equal(1003UL, discordId));
Assert.All(scenario.Messenger.DirectMessages, message =>
{
Assert.Contains("Landing Promise Smoke", message.Text);
Assert.Contains(options[1].ProposedAt.UtcDateTime.FormatMoscow(), message.Text);
});
var editsBeforeDashboardUpdate = scenario.Messenger.Edits.Count;
scenario.UpdateBatchFromDashboard("Landing Promise Smoke - Dashboard Sync");
Assert.True(scenario.Messenger.Edits.Count > editsBeforeDashboardUpdate);
Assert.Contains(scenario.LastMessage.Embeds, e => e.Title!.Contains("Landing Promise Smoke - Dashboard Sync"));
firstSessionEmbed = scenario.LastMessage.Embeds.First(e => e.Title!.Contains(scenario.Sessions[0].ScheduledAt.FormatMoscow()));
Assert.Contains("Bob", firstSessionEmbed.Description);
Assert.Contains("Carol", firstSessionEmbed.Description);
}
private static string BuildRecurringSessionCommand() =>
string.Join(
'\n',
"/newsession",
"Название: Landing Promise Smoke",
"Время: 15.05.2026 19:30",
"Игр: 3",
"Интервал: 7",
"Мест: 2",
"Ссылка: https://example.test/table");
private static IReadOnlyList<string> CallbackData(IReadOnlyList<ActionRowProperties> actionRows) =>
actionRows
.SelectMany(row => row)
.OfType<ButtonProperties>()
.Select(button => button.CustomId)
.OfType<string>()
.ToList();
private static IReadOnlyList<string> CallbackData(InlineKeyboardMarkup markup) =>
markup.InlineKeyboard
.SelectMany(row => row)
.Select(button => button.CallbackData)
.OfType<string>()
.ToList();
private sealed class DiscordLandingSmokeScenario
{
private readonly List<SmokeSession> sessions;
private readonly List<SmokeParticipant> participants = [];
private readonly SessionNotificationMode notificationMode;
private DiscordLandingSmokeScenario(
string title,
IReadOnlyList<DateTimeOffset> scheduledTimes,
int? maxPlayers,
string joinLink,
SessionNotificationMode notificationMode)
{
Title = title;
this.notificationMode = notificationMode;
sessions = scheduledTimes
.Select(scheduledAt => new SmokeSession(
Guid.NewGuid(),
scheduledAt.UtcDateTime,
SessionStatus.Planned,
maxPlayers,
joinLink))
.ToList();
}
public string Title { get; private set; }
public FakeDiscordMessenger Messenger { get; } = new();
public IReadOnlyList<SmokeSession> Sessions => sessions;
public FakeDiscordMessage LastMessage => Messenger.LastMessage;
public static DiscordLandingSmokeScenario Publish(
NewSessionParseResult parseResult,
SessionNotificationMode notificationMode)
{
var scenario = new DiscordLandingSmokeScenario(
parseResult.Title!,
parseResult.ScheduledTimes,
parseResult.MaxPlayers,
parseResult.Link!,
notificationMode);
scenario.RenderBatch();
return scenario;
}
public Guid Join(Guid sessionId, ulong discordId, string displayName)
{
var session = sessions.Single(value => value.Id == sessionId);
var activeParticipants = participants.Count(participant =>
participant.SessionId == sessionId &&
participant.RegistrationStatus == ParticipantRegistrationStatus.Active);
var registrationStatus = SessionCapacityRules.DecideJoinStatus(
session.MaxPlayers,
activeParticipants);
var playerId = Guid.NewGuid();
participants.Add(new SmokeParticipant(
sessionId,
playerId,
discordId,
displayName,
registrationStatus,
GmRelay.Shared.Domain.RsvpStatus.Pending,
participants.Count));
RenderBatch();
return playerId;
}
public void Leave(Guid sessionId, Guid playerId)
{
var participant = participants.Single(value =>
value.SessionId == sessionId &&
value.PlayerId == playerId);
participants.Remove(participant);
var session = sessions.Single(value => value.Id == sessionId);
var activeParticipantsAfterLeave = participants.Count(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Active);
var waitlistedParticipants = participants.Count(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted);
if (SessionCapacityRules.ShouldPromoteAfterParticipantLeaves(
participant.RegistrationStatus,
session.MaxPlayers,
activeParticipantsAfterLeave,
waitlistedParticipants))
{
var promoted = participants
.Where(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
.OrderBy(value => value.JoinOrder)
.First();
promoted.RegistrationStatus = ParticipantRegistrationStatus.Active;
promoted.RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Pending;
}
RenderBatch();
}
public bool HasParticipant(Guid sessionId, Guid playerId) =>
participants.Any(value => value.SessionId == sessionId && value.PlayerId == playerId);
public string RegistrationStatus(Guid sessionId, Guid playerId) =>
participants.Single(value =>
value.SessionId == sessionId &&
value.PlayerId == playerId).RegistrationStatus;
public string RsvpStatus(Guid sessionId, Guid playerId) =>
participants.Single(value =>
value.SessionId == sessionId &&
value.PlayerId == playerId).RsvpStatus;
public void MarkRsvpConfirmed(Guid sessionId, Guid playerId)
{
participants.Single(value =>
value.SessionId == sessionId &&
value.PlayerId == playerId).RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Confirmed;
}
public IReadOnlyList<VoteParticipantDto> ActiveVoteParticipants(Guid sessionId) =>
participants
.Where(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Active)
.OrderBy(value => value.DisplayName)
.Select(value => new VoteParticipantDto(
value.PlayerId,
value.DisplayName,
null,
0))
.ToList();
public void ApplyReschedule(Guid sessionId, DateTimeOffset newScheduledAt)
{
sessions.Single(value => value.Id == sessionId).ScheduledAt = newScheduledAt.UtcDateTime;
foreach (var participant in participants.Where(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Active))
{
participant.RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Pending;
}
RenderBatch();
if (!notificationMode.ShouldSendDirectMessages())
{
return;
}
var notification = $"""
Reschedule approved
{Title}
{newScheduledAt.UtcDateTime.FormatMoscow()}
""";
foreach (var participant in participants.Where(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Active))
{
Messenger.SendDirectMessage(participant.DiscordId, notification);
}
}
public void UpdateBatchFromDashboard(string title)
{
Title = title;
RenderBatch();
}
private void RenderBatch()
{
var view = SessionBatchViewBuilder.Build(
Title,
sessions
.Select(session => new SessionBatchDto(
session.Id,
session.ScheduledAt,
session.Status,
session.MaxPlayers,
session.JoinLink))
.ToList(),
participants
.Select(participant => new ParticipantBatchDto(
participant.SessionId,
participant.DisplayName,
null,
participant.RegistrationStatus))
.ToList());
var renderResult = DiscordSessionBatchRenderer.Render(view);
if (Messenger.HasPublishedMessage)
{
Messenger.EditBatchMessage(renderResult.Embeds, renderResult.ActionRows);
}
else
{
Messenger.PublishBatchMessage(renderResult.Embeds, renderResult.ActionRows);
}
}
}
private sealed class FakeDiscordMessenger
{
private const int BatchMessageId = 7001;
public List<FakeDiscordMessage> Sends { get; } = [];
public List<FakeDiscordMessage> Edits { get; } = [];
public List<FakeDirectMessage> DirectMessages { get; } = [];
public bool HasPublishedMessage => Sends.Count > 0;
public FakeDiscordMessage LastMessage => Edits.Count > 0 ? Edits[^1] : Sends[^1];
public void PublishBatchMessage(IReadOnlyList<EmbedProperties> embeds, IReadOnlyList<ActionRowProperties> actionRows) =>
Sends.Add(new FakeDiscordMessage(BatchMessageId, embeds, actionRows));
public void EditBatchMessage(IReadOnlyList<EmbedProperties> embeds, IReadOnlyList<ActionRowProperties> actionRows) =>
Edits.Add(new FakeDiscordMessage(BatchMessageId, embeds, actionRows));
public void SendDirectMessage(ulong discordId, string text) =>
DirectMessages.Add(new FakeDirectMessage(discordId, text));
}
private sealed record FakeDiscordMessage(
int MessageId,
IReadOnlyList<EmbedProperties> Embeds,
IReadOnlyList<ActionRowProperties> ActionRows);
private sealed record FakeDirectMessage(ulong DiscordId, string Text);
private sealed record SmokeSession(
Guid Id,
DateTime ScheduledAt,
string Status,
int? MaxPlayers,
string JoinLink)
{
public DateTime ScheduledAt { get; set; } = ScheduledAt;
}
private sealed record SmokeParticipant(
Guid SessionId,
Guid PlayerId,
ulong DiscordId,
string DisplayName,
string RegistrationStatus,
string RsvpStatus,
int JoinOrder)
{
public string RegistrationStatus { get; set; } = RegistrationStatus;
public string RsvpStatus { get; set; } = RsvpStatus;
}
}
@@ -1,6 +1,7 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
using GmRelay.Bot.Infrastructure.Telegram;

Some files were not shown because too many files have changed in this diff Show More