Compare commits

..

2 Commits

Author SHA1 Message Date
root 7c737dbf45 fix(tests): update TelegramLandingSmoke to use SessionBatchViewBuilder + TelegramSessionBatchRenderer 2026-05-06 08:25:07 +00:00
root 52b7b3e0fd refactor(#22): разделить SessionBatchRenderer на neutral view и Telegram renderer
- SessionBatchViewBuilder в Shared собирает нейтральную view model
- TelegramSessionBatchRenderer в Bot/Web рендерит HTML + InlineKeyboardMarkup
- DiscordSessionBatchRenderer заглушка подготовлена
- BatchMessageEditor перенесён из Shared в Bot/Web
- Удалён SessionBatchRenderer, убран Telegram.Bot из Shared.csproj
- Все вызовы обновлены на новую цепочку ViewBuilder → TelegramRenderer
2026-05-06 07:57:23 +00:00
330 changed files with 4519 additions and 40876 deletions
-21
View File
@@ -10,29 +10,8 @@ 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
# Локальный порт веб-интерфейса GM-Relay
GMRELAY_WEB_PORT=8080
# === Backup ===
# Сколько дней хранить дампы PostgreSQL (default: 7)
BACKUP_RETENTION_DAYS=7
# Имя Docker volume для резервных копий БД
BACKUP_VOLUME_NAME=game_pgbackups
# Имя Docker volume для обложек портфолио (загружаемых мастерами)
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
+5 -106
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.9.9
VERSION: 1.9.9
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -37,20 +37,6 @@ jobs:
docker push git.codeanddice.ru/toutsu/gmrelay-bot:latest
docker push git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
- name: Build Discord Bot image
run: |
docker build \
--label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
-f src/GmRelay.DiscordBot/Dockerfile \
-t git.codeanddice.ru/toutsu/gmrelay-discord-bot:latest \
-t git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }} \
.
- name: Push Discord Bot image
run: |
docker push git.codeanddice.ru/toutsu/gmrelay-discord-bot:latest
docker push git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}
- name: Build Web image
run: |
docker build \
@@ -65,62 +51,9 @@ jobs:
docker push git.codeanddice.ru/toutsu/gmrelay-web:latest
docker push git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
# ЧАСТЬ 1.5: Сканируем собранные образы на уязвимости
scan-images:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Install Trivy
run: |
# Install Trivy from the official Docker image instead of the
# upstream install.sh. Rationale:
# 1. install.sh resolves the positional tag against the
# GitHub releases API; when a release is unpublished or
# yanked, the script fails with
# `unable to find '<tag>' - use 'latest' or see ...`
# even when the release once existed. We hit this with
# v0.71.0.
# 2. Docker Hub tags are content-addressed and rarely
# removed, so a pinned image tag is much more stable.
# 3. The image is multi-arch (linux/amd64, linux/arm64,
# linux/ppc64le, linux/s390x) so the same tag works on
# the GitHub-hosted runner and on the ARM64 Pi runner.
set -euo pipefail
TRIVY_VERSION="0.70.0"
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
docker rm trivy-tmp >/dev/null
chmod +x /usr/local/bin/trivy
trivy --version
- name: Scan Bot image
run: |
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
--format table \
git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
- name: Scan Discord Bot image
run: |
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
--format table \
git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}
- name: Scan Web image
run: |
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
--format table \
git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
# ЧАСТЬ 2: Запускаем эти образы на самом сервере
deploy:
needs: scan-images
needs: build-and-push
runs-on: ubuntu-latest # Тот же локальный раннер
steps:
- name: Checkout repository
@@ -130,50 +63,16 @@ jobs:
run: |
echo "TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}" > .env
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
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
docker compose pull bot 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
-93
View File
@@ -1,93 +0,0 @@
name: PR Checks
on:
pull_request:
branches:
- main
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Verify Trivy dependency scan inputs
run: |
lock_count="$(find . -name packages.lock.json -not -path "*/bin/*" -not -path "*/obj/*" | tee trivy-targets.txt | wc -l)"
echo "Trivy NuGet lock files: ${lock_count}"
if [ "${lock_count}" -eq 0 ]; then
echo "::error::No packages.lock.json files found. Trivy would scan 0 NuGet dependency files."
exit 1
fi
# ── Linting ──
- name: Lint C# code style
run: dotnet format --verify-no-changes --verbosity diagnostic
# ── Security ──
- name: Check NuGet packages for vulnerabilities
run: |
dotnet list package --vulnerable --include-transitive 2>&1 | tee nuget-audit.txt
if grep -qi "has the following vulnerable packages" nuget-audit.txt; then
echo "::error::Vulnerable NuGet packages found!"
exit 1
fi
echo "No vulnerable packages detected."
- name: Install Trivy
run: |
# Install Trivy from the official Docker image instead of the
# upstream install.sh. Rationale (see deploy.yml for the long
# version): the GitHub release tag we pinned (v0.71.0) was
# unpublished, and install.sh fails hard on missing tags.
# Docker Hub images are content-addressed and rarely removed,
# and the multi-arch manifest covers linux/amd64 + linux/arm64.
set -euo pipefail
TRIVY_VERSION="0.70.0"
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
docker rm trivy-tmp >/dev/null
chmod +x /usr/local/bin/trivy
trivy --version
- name: Trivy filesystem security scan
run: |
set +e
trivy fs --scanners vuln,misconfig,secret --exit-code 1 --severity HIGH,CRITICAL . 2>&1 | tee trivy-scan.log
trivy_exit="${PIPESTATUS[0]}"
if ! grep -Eq "Number of language-specific files[[:space:]]+num=[1-9][0-9]*" trivy-scan.log; then
echo "::error::Trivy did not detect any language-specific dependency files."
exit 1
fi
exit "${trivy_exit}"
# ── Build (includes SAST via SecurityCodeScan Roslyn analyzer) ──
- name: Build Shared
run: dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore
- name: Build Bot (compile check, includes SAST)
run: dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore
- name: Build Discord Bot (compile check, includes SAST)
run: dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore
- name: Build Web (compile check, includes SAST)
run: dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore
# ── Tests ──
- name: Run tests
run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
BIN
View File
Binary file not shown.
+752
View File
@@ -0,0 +1,752 @@
# Issue #23: Добавить platform identity и platform_messages в БД
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Расширить PostgreSQL-схему нейтральными platform-полями и таблицей platform_messages, чтобы Discord-адаптер мог работать без чтения `telegram_*` legacy-полей. Сохранить обратную совместимость с Telegram-сценариями.
**Architecture:**
- Добавляем generic `platform` enum-like поля (`VARCHAR(50)`) + `external_*` ID-поля рядом с legacy `telegram_*`.
- Создаём таблицу `platform_messages` для отслеживания batch-сообщений на любой платформе.
- Миграция обратима: `telegram_*` НЕ удаляются, новые поля nullable/с дефолтами.
- После миграции обновляем SQL-запросы в handlers (Task 8-12) чтобы сначала писать и читать через новые поля.
**Tech Stack:** PostgreSQL 16+, DbUp, Dapper, .NET 10, Npgsql.
**Milestone:** Версия 2.0 — Discord Bot MVP
---
## Task 1: Написать миграцию V011__add_platform_identity.sql
**Objective:** Создать обратимую миграцию, добавляющую platform-поля и таблицу platform_messages.
**Files:**
- Create: `src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql`
**Step 1: Write failing test**
```bash
# Проверяем, что миграция ещё не существует
ls src/GmRelay.Bot/Migrations/V011__*.sql
# Expected: FAIL — file not found
```
**Step 2: Write migration script**
```sql
-- =============================================================
-- V011: Add platform identity fields for multi-platform support
-- Telegram remains default platform; old telegram_* fields kept.
-- =============================================================
-- 1) players: add platform identity columns
ALTER TABLE players
ADD COLUMN IF NOT EXISTS platform VARCHAR(50) NOT NULL DEFAULT 'telegram',
ADD COLUMN IF NOT EXISTS external_user_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS external_username VARCHAR(255);
-- Populate external_user_id/external_username from existing telegram data
UPDATE players
SET external_user_id = telegram_id::VARCHAR(255),
external_username = telegram_username
WHERE external_user_id IS NULL;
CREATE INDEX IF NOT EXISTS ix_players_platform_external_user
ON players (platform, external_user_id);
-- 2) game_groups: add platform identity columns
ALTER TABLE game_groups
ADD COLUMN IF NOT EXISTS platform VARCHAR(50) NOT NULL DEFAULT 'telegram',
ADD COLUMN IF NOT EXISTS external_group_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS external_channel_id VARCHAR(255);
UPDATE game_groups
SET external_group_id = telegram_chat_id::VARCHAR(255)
WHERE external_group_id IS NULL;
CREATE INDEX IF NOT EXISTS ix_game_groups_platform_external_group
ON game_groups (platform, external_group_id);
-- 3) sessions: add external_channel_id for platform-neutral thread/channel tracking
ALTER TABLE sessions
ADD COLUMN IF NOT EXISTS external_channel_id VARCHAR(255);
UPDATE sessions
SET external_channel_id = thread_id::VARCHAR(255)
WHERE external_channel_id IS NULL AND thread_id IS NOT NULL;
-- 4) platform_messages: new table for tracking messages across platforms
CREATE TABLE IF NOT EXISTS platform_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform VARCHAR(50) NOT NULL,
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
batch_id UUID NOT NULL,
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
external_channel_id VARCHAR(255),
external_thread_id VARCHAR(255),
external_message_id VARCHAR(255) NOT NULL,
purpose VARCHAR(50) NOT NULL DEFAULT 'schedule'
CHECK (purpose IN ('schedule','reminder','confirmation','reschedule_vote','direct_notification')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_platform_messages_group_batch
ON platform_messages (group_id, batch_id);
CREATE INDEX IF NOT EXISTS ix_platform_messages_session
ON platform_messages (session_id);
CREATE INDEX IF NOT EXISTS ix_platform_messages_purpose
ON platform_messages (purpose);
-- 5) Backfill platform_messages from existing sessions.batch_message_id data
INSERT INTO platform_messages (platform, group_id, batch_id, session_id, external_channel_id, external_message_id, purpose)
SELECT 'telegram',
s.group_id,
s.batch_id,
s.id,
NULL,
s.batch_message_id::VARCHAR(255),
'schedule'
FROM sessions s
WHERE s.batch_message_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM platform_messages pm
WHERE pm.batch_id = s.batch_id AND pm.external_message_id = s.batch_message_id::VARCHAR(255)
);
```
**Step 3: Verify migration exists**
```bash
ls src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql
# Expected: file exists
```
**Step 4: Commit**
```bash
git add src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql
git commit -m "chore(db): add V011 migration — platform identity and platform_messages"
```
---
## Task 2: Создать shared enum PlatformKind
**Objective:** Ввести типизированное перечисление платформ для использования в коде и БД.
**Files:**
- Create: `src/GmRelay.Shared/Domain/PlatformKind.cs`
- Modify: `src/GmRelay.Shared/GmRelay.Shared.csproj` (если нужен `[JsonStringEnumConverter]` или Dapper mapping)
**Step 1: Write failing test**
```csharp
// tests/GmRelay.Bot.Tests/Domain/PlatformKindTests.cs
using GmRelay.Shared.Domain;
public class PlatformKindTests
{
[Fact]
public void Telegram_HasExpectedValue()
{
Assert.Equal("telegram", PlatformKind.Telegram.Value);
}
[Fact]
public void Discord_HasExpectedValue()
{
Assert.Equal("discord", PlatformKind.Discord.Value);
}
[Fact]
public void Equals_SameValue_ReturnsTrue()
{
Assert.Equal(PlatformKind.Telegram, PlatformKind.Telegram);
}
[Fact]
public void Parse_KnownValue_ReturnsInstance()
{
var kind = PlatformKind.Parse("discord");
Assert.Equal(PlatformKind.Discord, kind);
}
}
```
Run:
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTests"
# Expected: FAIL — PlatformKind not found
```
**Step 2: Implement PlatformKind**
```csharp
// src/GmRelay.Shared/Domain/PlatformKind.cs
namespace GmRelay.Shared.Domain;
public sealed class PlatformKind : IEquatable<PlatformKind>
{
public static readonly PlatformKind Telegram = new("telegram");
public static readonly PlatformKind Discord = new("discord");
public string Value { get; }
private PlatformKind(string value) => Value = value;
public static PlatformKind Parse(string value)
=> value.ToLowerInvariant() switch
{
"telegram" => Telegram,
"discord" => Discord,
_ => throw new ArgumentException($"Unknown platform: {value}", nameof(value))
};
public bool Equals(PlatformKind? other) => other is not null && Value == other.Value;
public override bool Equals(object? obj) => obj is PlatformKind other && Equals(other);
public override int GetHashCode() => Value.GetHashCode(StringComparison.OrdinalIgnoreCase);
public override string ToString() => Value;
}
```
**Step 3: Run test**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTests"
# Expected: PASS
```
**Step 4: Commit**
```bash
git add src/GmRelay.Shared/Domain/PlatformKind.cs tests/GmRelay.Bot.Tests/Domain/PlatformKindTests.cs
git commit -m "feat(shared): add PlatformKind value object"
```
---
## Task 3: Создать Dapper TypeHandler для PlatformKind
**Objective:** Обеспечить автоматическую сериализацию/десериализацию PlatformKind ↔ VARCHAR в PostgreSQL.
**Files:**
- Create: `src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs`
- Modify: `src/GmRelay.Bot/Program.cs` или точку регистрации Dapper handlers
**Step 1: Write failing test**
```csharp
// tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformKindTypeHandlerTests.cs
using Dapper;
using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Shared.Domain;
public class PlatformKindTypeHandlerTests
{
[Fact]
public void PlatformKind_SetValue_ReturnsString()
{
var handler = new PlatformKindTypeHandler();
var param = new Npgsql.NpgsqlParameter();
handler.SetValue(param, PlatformKind.Discord);
Assert.Equal("discord", param.Value);
}
[Fact]
public void PlatformKind_Parse_ReturnsKind()
{
var handler = new PlatformKindTypeHandler();
var result = handler.Parse(typeof(PlatformKind), "telegram");
Assert.Equal(PlatformKind.Telegram, result);
}
}
```
Run:
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTypeHandlerTests"
# Expected: FAIL — handler not found
```
**Step 2: Implement handler**
```csharp
// src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs
using Dapper;
using GmRelay.Shared.Domain;
using System.Data;
namespace GmRelay.Bot.Infrastructure.Database;
public sealed class PlatformKindTypeHandler : SqlMapper.TypeHandler<PlatformKind>
{
public override void SetValue(IDbDataParameter parameter, PlatformKind value)
{
parameter.Value = value.Value;
}
public override PlatformKind Parse(object value)
{
var str = value as string ?? value.ToString() ?? string.Empty;
return PlatformKind.Parse(str);
}
}
```
**Step 3: Register handler**
В `Program.cs` (или в `DbMigrator` / startup extension) добавить:
```csharp
SqlMapper.AddTypeHandler(new PlatformKindTypeHandler());
```
**Step 4: Run test**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PlatformKindTypeHandlerTests"
# Expected: PASS
```
**Step 5: Commit**
```bash
git add src/GmRelay.Bot/Infrastructure/Database/PlatformKindTypeHandler.cs tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformKindTypeHandlerTests.cs
git commit -m "feat(db): add Dapper PlatformKindTypeHandler"
```
---
## Task 4: Создать down-migration V011__add_platform_identity.sql (rollback)
**Objective:** Обеспечить обратимость миграции для отката при ошибке.
**Files:**
- Create: `src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql`
**Step 1: Write rollback script**
```sql
-- Rollback for V011: remove platform identity fields and platform_messages table
-- NOTE: This drops NEW data. Use only in emergency.
DROP TABLE IF EXISTS platform_messages;
ALTER TABLE sessions DROP COLUMN IF EXISTS external_channel_id;
ALTER TABLE game_groups
DROP COLUMN IF EXISTS platform,
DROP COLUMN IF EXISTS external_group_id,
DROP COLUMN IF EXISTS external_channel_id;
ALTER TABLE players
DROP COLUMN IF EXISTS platform,
DROP COLUMN IF EXISTS external_user_id,
DROP COLUMN IF EXISTS external_username;
```
**Step 2: Verify file exists**
```bash
ls src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
# Expected: file exists
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
git commit -m "chore(db): add V011 rollback script"
```
---
## Task 5: Прогнать миграцию на integration-БД и проверить структуру
**Objective:** Убедиться, что миграция применяется без ошибок, backfill работает, индексы созданы.
**Files:**
- Test: скрипт в `tests/integration/verify_v011.sql` или ручная проверка через psql
**Step 1: Run migrations**
```bash
cd src/GmRelay.Bot
dotnet run --migrate-only # или через DbMigrator в Program.cs
```
**Step 2: Verify schema**
```bash
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d players" | grep -E "platform|external"
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d game_groups" | grep -E "platform|external"
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d sessions" | grep external
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d platform_messages"
```
Expected: все новые поля и таблица присутствуют.
**Step 3: Verify backfill**
```bash
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM players WHERE external_user_id IS NOT NULL;"
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM game_groups WHERE external_group_id IS NOT NULL;"
psql "$GMRELAYDB_CONNECTION_STRING" -c "SELECT COUNT(*) FROM platform_messages;"
```
Expected: counts > 0 для существующих данных.
**Step 4: Verify rollback works**
```bash
psql "$GMRELAYDB_CONNECTION_STRING" -f src/GmRelay.Bot/Migrations/V011__add_platform_identity_rollback.sql
psql "$GMRELAYDB_CONNECTION_STRING" -c "\d platform_messages" # should fail (table missing)
psql "$GMRELAYDB_CONNECTION_STRING" -f src/GmRelay.Bot/Migrations/V011__add_platform_identity.sql # re-apply
```
**Step 5: Commit** (если добавили тесты)
```bash
git add tests/integration/verify_v011.sql
git commit -m "test(db): add V011 integration verification"
```
---
## Task 6: Обновить CreateSessionHandler — писать в platform identity при создании
**Objective:** При создании сессии заполнять новые `external_*` поля, не ломая Telegram flow.
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`
**Step 1: Write failing test**
```csharp
// tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/CreateSessionHandlerTests.cs
[Fact]
public async Task HandleAsync_CreatesPlayerWithPlatformIdentity()
{
// Arrange: mock NpgsqlDataSource + ITelegramBotClient
// Act: send /newsession
// Assert: INSERT into players includes external_user_id = telegram_id string
}
```
Run:
```bash
dotnet test --filter "FullyQualifiedName~CreateSessionHandlerTests"
# Expected: FAIL — test or implementation missing
```
**Step 2: Update handler SQL**
В `CreateSessionHandler.cs`, обновить INSERT для players:
```sql
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TgId, @Name, @Username, 'telegram', @TgId::VARCHAR(255), @Username)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username,
external_username = EXCLUDED.external_username;
```
И INSERT для game_groups:
```sql
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
VALUES (@ChatId, @ChatName, @GmId, 'telegram', @ChatId::VARCHAR(255))
RETURNING id;
```
**Step 3: Run handler tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~CreateSession"
# Expected: PASS
```
**Step 4: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs
git commit -m "feat(sessions): write platform identity fields on session creation"
```
---
## Task 7: Обновить JoinSessionHandler — писать в platform identity
**Objective:** При join записывать `platform`, `external_user_id`, `external_username`.
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
**Step 1: Update SQL**
```sql
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TgId, @Name, @Username, 'telegram', @TgId::VARCHAR(255), @Username)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username,
external_username = EXCLUDED.external_username
RETURNING id;
```
**Step 2: Verify no regression**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~JoinSession"
# Expected: PASS or no existing tests (manual smoke)
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs
git commit -m "feat(sessions): write platform identity fields on join"
```
---
## Task 8: Обновить ListSessionsHandler — читать через platform identity
**Objective:** Запрос списка сессий должен работать через `external_group_id`, не ломая Telegram flow.
**Files:**
- Modify: `src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs`
**Step 1: Update SQL**
Заменить `g.telegram_chat_id = @ChatId` на `g.external_group_id = @ChatId::VARCHAR(255)`.
```sql
SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, ...
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.external_group_id = @ChatId::VARCHAR(255)
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY ...
ORDER BY s.scheduled_at ASC
```
Также обновить проверку прав:
```sql
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.external_user_id = @TelegramUserId::VARCHAR(255)
) AS CanManage
```
**Step 2: Run tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~ListSessions"
# Expected: PASS
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs
git commit -m "feat(sessions): read group via external_group_id in list handler"
```
---
## Task 9: Обновить CancelSessionHandler, LeaveSessionHandler, PromoteWaitlistedPlayerHandler
**Objective:** Аналогично Task 7-8: обновить SQL запросы на использование `external_user_id` и `external_group_id`.
**Files:**
- Modify:
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs`
- `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
- `src/GmRelay.Bot/Features/Sessions/CreateSession/PromoteWaitlistedPlayerHandler.cs`
**Step 1: Update SQL in each handler**
Replace patterns:
- `p.telegram_id = @TelegramUserId``p.external_user_id = @TelegramUserId::VARCHAR(255)`
- `g.telegram_chat_id = @ChatId``g.external_group_id = @ChatId::VARCHAR(255)`
- `s.group_id = g.id` stays
**Step 2: Run all session handler tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Session"
# Expected: all PASS
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/CreateSession/
git commit -m "feat(sessions): migrate session handlers to platform identity fields"
```
---
## Task 10: Обновить Reschedule и Reminder handlers
**Objective:** Обновить reschedule handlers и reminder sender на platform identity.
**Files:**
- Modify:
- `src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs`
- `src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs`
- `src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs`
- `src/GmRelay.Bot/Features/Notifications/DirectSessionNotificationSender.cs`
**Step 1: Update SQL**
Replace `telegram_id` / `telegram_chat_id` usages with `external_user_id` / `external_group_id` where это lookup.
**Step 2: Run tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reschedule"
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Reminder"
# Expected: PASS
```
**Step 3: Commit**
```bash
git add src/GmRelay.Bot/Features/Sessions/RescheduleSession/ src/GmRelay.Bot/Features/Reminders/ src/GmRelay.Bot/Features/Notifications/
git commit -m "feat(reschedule,reminders): migrate to platform identity fields"
```
---
## Task 11: Обновить Web Dashboard queries (TelegramAuthService, GroupDetails, MiniApp)
**Objective:** Веб-компоненты, которые читают `telegram_id`, должны также поддерживать `external_user_id`.
**Files:**
- Modify:
- `src/GmRelay.Web/Services/TelegramAuthService.cs`
- `src/GmRelay.Web/Components/Pages/GroupDetails.razor`
**Step 1: Update TelegramAuthService**
```sql
SELECT p.id, p.display_name, p.telegram_username, p.external_user_id, p.external_username
FROM players p
WHERE p.telegram_id = @TelegramId
OR p.external_user_id = @TelegramId::VARCHAR(255)
```
**Step 2: Update GroupDetails.razor queries**
Replace `telegram_chat_id` lookups with `external_group_id`.
**Step 3: Run web tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Web"
# Expected: PASS
```
**Step 4: Commit**
```bash
git add src/GmRelay.Web/
git commit -m "feat(web): migrate web dashboard to platform identity fields"
```
---
## Task 12: Создать документацию по platform identity в schema notes
**Objective:** Зафиксировать, что `telegram_*` deprecated, а новые поля — путь вперёд.
**Files:**
- Create: `docs/db-schema/platform-migration-notes.md`
**Step 1: Write doc**
```markdown
# Platform Identity Migration Notes
## Status
- `telegram_*` fields: DEPRECATED but kept for backward compatibility.
- `platform`, `external_*` fields: ACTIVE, used by all new handlers.
## Tables
| Table | Legacy | New Fields |
|-------|--------|-----------|
| players | telegram_id, telegram_username | platform, external_user_id, external_username |
| game_groups | telegram_chat_id | platform, external_group_id, external_channel_id |
| sessions | thread_id, batch_message_id | external_channel_id |
| platform_messages | — | NEW table tracking all batch messages |
## Backfill
V011 migration backfills `external_*` from existing `telegram_*` data.
## Future
V012+ (or separate migration) will eventually drop `telegram_*` once all code paths use `external_*`.
```
**Step 2: Commit**
```bash
git add docs/db-schema/platform-migration-notes.md
git commit -m "docs(db): add platform identity migration notes"
```
---
## Task 13: Final integration smoke test
**Objective:** Убедиться, что весь бот стартует, миграции проходят, и основные команды работают.
**Step 1: Build**
```bash
dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj -c Release
# Expected: 0 errors, 0 warnings
```
**Step 2: Run all tests**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --no-build
# Expected: all PASS
```
**Step 3: Start bot and test /newsession + /listsessions**
```bash
dotnet run --project src/GmRelay.Bot/GmRelay.Bot.csproj
# Manual smoke in Telegram: /newsession → verify session created, players.external_user_id populated
```
**Step 4: Commit final state**
```bash
git status # should be clean
git log --oneline -5
```
---
## Verification Checklist
- [ ] V011 migration applied successfully with backfill
- [ ] Rollback script tested and works
- [ ] PlatformKind + Dapper TypeHandler present and tested
- [ ] All session handlers use `external_*` fields
- [ ] Telegram commands (`/newsession`, `/listsessions`, join/leave) still work
- [ ] Web dashboard auth and group pages still work
- [ ] All tests pass
- [ ] No compiler warnings
+4
View File
@@ -0,0 +1,4 @@
usage: hermes mcp [-h] [--accept-hooks]
{serve,add,remove,rm,list,ls,test,configure,config,login}
...
hermes mcp: error: argument mcp_action: invalid choice: 'call' (choose from 'serve', 'add', 'remove', 'rm', 'list', 'ls', 'test', 'configure', 'config', 'login')
-144
View File
@@ -1,144 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build, Test, and Development Commands
This is a .NET 10 solution using the modern XML-based `.slnx` format. The global SDK version is `10.0.100` with `rollForward: latestFeature`.
**Build the solution:**
```bash
dotnet build
```
**Build individual projects (the CI does this to include SAST via SecurityCodeScan):**
```bash
dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore
dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore
dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore
```
**Run all tests:**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
```
**Run a single test class or method:**
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~YourTestClassName"
```
**Lint and format:**
```bash
dotnet format --verify-no-changes --verbosity diagnostic # CI enforcement
dotnet format # Apply fixes
```
**Check for vulnerable packages:**
```bash
dotnet list package --vulnerable --include-transitive
```
**Restore with lock file verification:**
The repo enforces `RestorePackagesWithLockFile=true`. After adding or updating packages, commit the updated `packages.lock.json` files or the Trivy scan in CI will fail.
**Run locally with Aspire (dev orchestration):**
```bash
dotnet run --project src/GmRelay.AppHost/GmRelay.AppHost.csproj
```
This automatically starts PostgreSQL in a container, the Bot, and the Web dashboard.
**Run locally with Docker Compose (production-like):**
```bash
cp .env.example .env
# Edit .env with your TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME, POSTGRES_PASSWORD
docker compose up -d
```
## High-Level Architecture
### Project Roles and Runtime Model
| Project | Runtime | Key Trait |
|---|---|---|
| `GmRelay.Bot` | `Microsoft.NET.Sdk.Worker` | **Native AOT** binary. Telegram long polling bot + stateless scheduler. |
| `GmRelay.Web` | `Microsoft.NET.Sdk.Web` | Blazor Server dashboard. Cookie auth via Telegram Login Widget / Mini App `initData`. |
| `GmRelay.Shared` | Plain library | Domain models and platform-neutral view builders. **Must not depend on `Telegram.Bot`**. |
| `GmRelay.ServiceDefaults` | Aspire shared project | OpenTelemetry, health checks, HTTP resilience. Referenced by both Bot and Web. |
| `GmRelay.AppHost` | Aspire orchestrator | Dev-only. Spins up PostgreSQL and wires Bot + Web with service discovery. |
**Important:** `README.md` references `GmRelay.Migrator` and `GmRelay.Worker`, but these projects do not exist. Migrations (`DbUp`) and background workers (`BackgroundService`) live inside `GmRelay.Bot`.
### Vertical Slice Architecture with Explicit DI
Each use case is a self-contained vertical slice: a C# record (Command/Query) + Handler class with all logic (SQL, Telegram API calls, validation). There are no abstract repository interfaces or service layers.
Because the Bot is compiled as Native AOT (`PublishAot=true`, `EnableTrimAnalyzer=true`), **all DI registrations are explicit** in `src/GmRelay.Bot/Program.cs`. There is no assembly scanning or reflection-based discovery. When adding a new handler, you must register it manually in Program.cs.
### Database Access: Npgsql + Dapper.AOT + DbUp
**No EF Core** — it is incompatible with Native AOT. The stack is:
- **Npgsql** ADO.NET for connections.
- **Dapper 2.1.72** with **Dapper.AOT 1.0.48** for compile-time source-generated mapping (AOT-safe).
- **DbUp 7.0.1** for migrations. SQL scripts are embedded resources in `src/GmRelay.Bot/Migrations/` (V001 through V015).
- `DbMigrator.MigrateUp()` runs on every Bot startup.
Both Bot and Web share the same PostgreSQL database. Web registers `NpgsqlDataSource` via `builder.AddNpgsqlDataSource("gmrelaydb")` (Aspire integration), while Bot registers it manually to avoid reflection-based Aspire configuration at AOT time.
### Platform-Neutral Rendering (ADR-002)
Rendering is split into two stages:
1. **View Builder** (`GmRelay.Shared`) — platform-agnostic view model from domain DTOs.
2. **Platform Renderer**`TelegramSessionBatchRenderer` lives in both `GmRelay.Bot` and `GmRelay.Web` (temporary duplication until a third Telegram consumer justifies extracting `GmRelay.Shared.Telegram`).
This means `GmRelay.Shared` must remain free of `Telegram.Bot` types. If you need to add rendering logic that produces `InlineKeyboardMarkup`, it belongs in the Bot or Web project, not Shared.
### Stateless Scheduling
The session scheduler (`SessionSchedulerService`) is a `BackgroundService` with a `PeriodicTimer(TimeSpan.FromMinutes(1))`. On each tick it queries PostgreSQL for sessions needing action (T-24h confirmation, T-5min join link) and updates their status. There is no in-memory state — the database is the single source of truth. This design was chosen specifically because Quartz.NET is incompatible with Native AOT.
### Health Checks
- **Bot:** Custom `BotHealthCheckHostedService` listens on port 8081. The Docker health check hits `localhost:8081/health`.
- **Web:** Standard ASP.NET Core health checks on `/health` (JSON response with status and timestamp) and `/alive` (liveness probe tag filter). Exposed via `GmRelay.ServiceDefaults`.
### Authentication and Security
- **Telegram Login Widget** and **Mini App `initData`** verification via HMAC-SHA256. Cookie auth is hardened (`HttpOnly`, `SecurePolicy.Always`, `SameSite.Strict`).
- Web Data Protection keys are persisted to `/app/dataprotection-keys` (Docker volume `web_keys`).
- Security headers middleware (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`) is applied globally in Web.
- `SecurityCodeScan.VS2019` (5.6.7) is included in all projects via `Directory.Build.props` for SAST at build time.
- Connection string passwords are redacted in logs via `SecretRedactor`.
### CI/CD Pipeline
`.gitea/workflows/pr-checks.yml` runs on every PR to `main`:
1. `dotnet restore`
2. Verify `packages.lock.json` files exist for Trivy
3. `dotnet format --verify-no-changes`
4. `dotnet list package --vulnerable`
5. Trivy filesystem scan (`vuln,misconfig,secret`, HIGH/CRITICAL)
6. Build Shared → Bot → Web
7. Run tests
`.gitea/workflows/deploy.yml` runs on push to `main`:
1. Build and push `gmrelay-bot` and `gmrelay-web` images to `git.codeanddice.ru/toutsu/...`
2. Trivy image scan on both images (HIGH/CRITICAL, exit-code 1)
3. Create `.env` from secrets and run `docker compose up -d`
### Environment Configuration
Key environment variables (see `.env.example`):
- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_USERNAME`, `TELEGRAM_MINI_APP_URL`
- `POSTGRES_PASSWORD`
- `GMRELAY_WEB_PORT` (default 8080)
- `ConnectionStrings__gmrelaydb` — used by both Bot and Web
The Bot reads config as `Telegram:BotToken` (colon) which maps from `Telegram__BotToken` (double underscore) via environment variables.
### Docker Images
- **Bot:** Multi-stage Dockerfile. Build stage uses `sdk:10.0-noble` with `clang` and `zlib1g-dev` for AOT compilation. Final stage uses `runtime-deps:10.0-noble`. Exposes 8081.
- **Web:** Multi-stage Dockerfile. Build stage uses `sdk:10.0-noble`. Final stage uses `aspnet:10.0-noble` with `libgssapi-krb5-2` and `wget`. Exposes 8080.
Both images are built for multi-arch (`linux/amd64`, `linux/arm64`) to support Raspberry Pi 5 (ARM64) deployment.
+1 -6
View File
@@ -1,15 +1,10 @@
<Project>
<PropertyGroup>
<Version>3.9.9</Version>
<Version>1.10.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SecurityCodeScan.VS2019" Version="5.6.7" PrivateAssets="all" />
</ItemGroup>
</Project>
-1
View File
@@ -2,7 +2,6 @@
<Folder Name="/src/">
<Project Path="src/GmRelay.AppHost/GmRelay.AppHost.csproj" />
<Project Path="src/GmRelay.Bot/GmRelay.Bot.csproj" />
<Project Path="src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj" />
<Project Path="src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj" />
<Project Path="src/GmRelay.Web/GmRelay.Web.csproj" />
</Folder>
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Toutsu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
-23
View File
@@ -1,23 +0,0 @@
# 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
+171 -174
View File
@@ -1,231 +1,228 @@
# 🎲 GM-Relay: TTRPG Session Scheduling Bot & Web Dashboard
**GM-Relay** — это комплексное решение для Мастеров Подземелий (ГМов), состоящее из высокопроизводительного Telegram-бота, Discord worker и удобного веб-интерфейса. Предназначено для автоматизации записи игроков на сессии, управления расписанием и проведения игр.
**GM-Relay** — это комплексное решение для Мастеров Подземелий (ГМов), состоящее из высокопроизводительного Telegram-бота и удобного веб-интерфейса. Предназначено для автоматизации записи игроков на сессии, управления расписанием и проведения игр.
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v3.6.0`.
**Текущая версия:** `v1.9.9`.
---
## ✨ Key Features
## ✨ Ключевые возможности
### 🤖 Telegram Bot
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением изменения (на недельный месяц в перед).
- **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
### 🤖 Telegram Бот
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
- **🖼 Обложки расписаний**: К batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
- **📁 Поддержка Форумов (Telegram Topics)**: Если `/newsession` запущен в теме форума Telegram, расписание и групповые уведомления остаются в этой теме; при запуске из корня форума бот создает отдельную тему и сообщает о необходимости прав admin/Manage Topics, если их не хватает.
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры из Telegram через `/listsessions`; публичный пост записи показывает только кнопки игроков.
- **🔄 Голосование за перенос**: Быстрый поиск свободного места с через свободное недель и кнопками новых времени и дедлайном.
- **🔔 Уведомления**: Игрок получают за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
- **🕐 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в подключенных 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, а при выходе участника первый ожидающий автоматически продвигается в основной состав.
- **🔄 Голосование за перенос**: При переносе сессии GM предлагает 2-3 новых времени и дедлайн, игроки голосуют кнопками, а бот показывает текущие результаты и применяет победивший вариант.
- **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
- **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**.
### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через Telegram**: Telegram Login Widget с HMAC-SHA256 валидацией.
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
- **🧑‍🏫 Публичные профили мастеров**: мастер управляет профилем из `/profile`, публикует описание на `/gm/{slug}`, а публичные клубы, игры и каталог ссылаются на профиль без раскрытия platform identifiers.
- **📚 Портфолио завершённых приключений**: Owner и co-GM собирают завершённые сессии в портфолио-игры на странице `/group/{id}/portfolio`, привязывают ссылки на прошедшие сессии и публикуют публичную страницу `/portfolio/{slug}` с обложкой, описанием, системой/форматом и составом мастеров.
- **⭐ Модерируемые отзывы игроков**: участники прошедших сессий могут оставить отзыв на `/portfolio/{slug}/review` с явным согласием на публикацию; мастера модерируют отзывы (`Approved`/`Rejected`/`Hidden`) в редакторе портфолио, и только одобренные отзывы видны публичной странице.
- **🖼 Обложки портфолио**: мастера загружают JPG/PNG/WEBP-обложки в редакторе портфолио; файлы сохраняются в Docker volume `portfolio_covers` и обслуживаются по пути `/portfolio-covers/{storageKey}`; конфигурация пути — `PortfolioCovers__StoragePath` в `compose.yaml`.
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
- **📦 Bulk-операции для Batch Sessions**:
- обновить общий `title`/`link` у всей пачки;
- перенести пачку на фиксированный шаг в днях;
- клонировать batch на следующую неделю или месяц.
- **⬆️ Управление очередью**: Заполненность, лист ожидания и ручное повышение игрока из очереди.
- **📜 История изменений сессий**: Страница `/session/{id}/history` показывает аудит-лог всех значимых изменений (время, ссылка, название, участники, статус) с указанием акторов и дат.
- **📊 Статистика посещаемости**: Страница `/group/{id}/stats` показывает долю присутствия, количество пропусков и среднюю явку по каждому игроку группы.
- **🔄 Автосинхронизация**: Изменения в вебе мгновенно перерисовывают platform message расписания через `IPlatformMessenger`.
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
- **📱 Telegram Mini App Dashboard**: Мобильная версия dashboard открывается прямо из Telegram, проверяет WebApp `initData` на сервере и использует те же права owner/co-GM, что и обычный Web Dashboard. Если Mini App попадает в fallback-вход, Telegram Login Widget авторизует пользователя callback-запросом внутри текущего WebView, а интерфейс учитывает safe-area телефона и верхнюю панель Telegram.
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
- **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard.
- **📋 Шаблоны кампаний**: Owner и co-GM управляют типовыми параметрами кампаний в отдельной вкладке `Шаблоны`, а на странице группы запускают новый повторяющийся batch из выбранного шаблона.
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
---
## 🛠 Технологический стек
| Компонент | Технология |
|---|---|
| Язык | C# 14 (.NET 10) |
| Архитектура | Vertical Slice + общая библиотека `GmRelay.Shared` |
| Боты | Telegram.Bot (**Native AOT**), NetCord Gateway (Discord worker внутри `GmRelay.Bot`) |
| Веб | Blazor Server |
| Оркестрация | .NET Aspire (`GmRelay.AppHost`) |
| БД | PostgreSQL |
| ORM | Dapper + **Dapper.AOT** (source generators) |
| Миграции | DbUp |
| Развёртывание | Docker Compose, Multi-arch (**AMD64/ARM64**) |
> [!NOTE]
> При использовании Dapper в режиме Native AOT все SQL-запросы используют строго типизированные DTO; динамические типы (`dynamic`) не поддерживаются.
- **Язык**: C# 14 (.NET 10)
- **Архитектура**: Vertical Slice Architecture, общая библиотека (`GmRelay.Shared`) для доменной логики.
- **Бот**: Telegram.Bot, Native AOT.
- **Веб-интерфейс**: Blazor Server.
- **Оркестрация**: .NET Aspire (`GmRelay.AppHost`).
- **База данных**: PostgreSQL
- **ORM**: Dapper (с использованием Dapper.AOT для source generators).
- **Миграции**: DbUp.
- **Развертывание**: Docker Compose + Multi-arch (AMD64/ARM64).
---
## 🚀 Быстрый старт (Docker Compose)
**Требования:** Docker и Docker Compose.
Проект использует Docker Compose для одновременного запуска базы данных, бота и веб-интерфейса.
### 1. Подготовка
Убедитесь, что у вас установлены **Docker** и **Docker Compose**.
### 2. Настройка окружения
Скопируйте файл-шаблон и заполните его значениями:
### 1. Настройка окружения
```bash
cp .env.example .env
```
**Ключевые переменные `.env`:**
Отредактируйте `.env`:
```env
# Токен от @BotFather (используется ботом и как секретный ключ веб-авторизации)
# Токен вашего бота от @BotFather (используется и для бота, и как секретный ключ для веб-авторизации)
TELEGRAM_BOT_TOKEN=ваш_токен_здесь
# Токен Discord application bot
DISCORD_BOT_TOKEN=ваш_discord_токен_здесь
# Discord OAuth (для Web Dashboard)
DISCORD_CLIENT_ID=ваш_discord_client_id_здесь
DISCORD_CLIENT_SECRET=ваш_discord_client_secret_здесь
DISCORD_REDIRECT_URI=https://your-domain.example/auth/discord/callback
# Имя бота без @ (для Telegram Login Widget)
# Имя вашего бота в Telegram (без @), например: GmRelayBot.
# Найти его можно в информации о боте у @BotFather.
# Используется для работы виджета авторизации (Telegram Login Widget).
TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
# HTTPS URL Mini App, например https://your-domain.example/miniapp
# HTTPS URL Mini App dashboard, например: https://your-domain.example/miniapp.
# Используется кнопкой меню Telegram и кнопкой /start.
TELEGRAM_MINI_APP_URL=https://your-domain.example/miniapp
# Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=ваш_надежный_пароль
# Локальный порт веб-интерфейса GM-Relay
GMRELAY_WEB_PORT=8080
```
**Настройка в @BotFather:**
- Команда `/setdomain` для работы виджета авторизации на вашем домене.
- Для Mini App настройте домен Web Dashboard и menu button на URL из `TELEGRAM_MINI_APP_URL`.
- Начиная с **v1.9.3** дополнительных действий для фикса входа не требуется: fallback выполняется внутри активного Telegram WebView по тому же HTTPS-адресу `/miniapp`.
*(Опционально)* Настройте домен Telegram бота в @BotFather командой `/setdomain` для работы виджета авторизации на вашем сайте.
### 2. Запуск
Для Telegram Mini App настройте в @BotFather домен Web Dashboard и menu button на URL из `TELEGRAM_MINI_APP_URL`. Бот также показывает кнопку `Открыть dashboard` в ответе на `/start`, если переменная задана. Начиная с v1.9.3 дополнительных действий в BotFather для фикса входа не требуется: URL остаётся тем же HTTPS-адресом `/miniapp`, а fallback-вход выполняется внутри активного Telegram WebView.
### 3. Запуск
Выполните команду:
```bash
docker compose up -d
```
**Автоматически выполняется:**
- создание Docker-сети и volume PostgreSQL;
- подъём PostgreSQL (`db:5432`);
- запуск бота с плавной миграцией (DbUp);
- запуск 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` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord.
5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
## 📚 Портфолио завершённых приключений
Начиная с **v3.6.0** ГМы могут публиковать завершённые кампании в виде постоянных портфолио-страниц с обложкой, описанием, системой/форматом, составом мастеров и модерируемыми отзывами игроков.
### Возможности
- **Управление портфолио** — в `/group/{id}/portfolio` владелец и co-GM создают портфолио-игры из прошедших сессий, выбирают мастеров, заполняют описание, загружают обложку и публикуют по `public_slug`.
- **Публичная страница `/portfolio/{slug}`** — read-only карточка приключения с обложкой, описанием, составом мастеров (только публичные профили) и одобренными отзывами.
- **Отзывы участников** — на `/portfolio/{slug}/review` аутентифицированные игроки, чьи идентификаторы участвовали в одной из привязанных сессий без пометки GM, отправляют отзыв с явным согласием на публикацию; один отзыв на игрока, повторная отправка запрещена.
- **Модерация отзывов** — на странице редактора портфолио владелец/co-GM видит очередь `Pending` и переводит отзывы в `Approved`, `Rejected` или `Hidden`; только `Approved` отзывы попадают в публичную выдачу.
- **Публикация под требования** — портфолио-игра публикуется только при заполненном slug, описании, обложке, минимум одной завершённой сессии и хотя бы одном мастере группы.
### Хранение обложек
Загруженные обложки хранятся в Docker volume `portfolio_covers` (по умолчанию имя `gmrelay_portfolio_covers`), обслуживаются веб-приложением по пути `/portfolio-covers/{storageKey}` с кешированием `Cache-Control: public, max-age=31536000, immutable`.
В `.env` можно переопределить имя volume:
```env
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
```
В `compose.yaml` это значение пробрасывается в сервис `web` через `volumes.portfolio_covers.name`; путь к каталогу внутри контейнера — `/app/portfolio-covers` (настраивается через `PortfolioCovers__StoragePath`).
Хранилище инкапсулировано интерфейсом `IPortfolioCoverStorage` с реализацией `LocalPortfolioCoverStorage` (файловая система), что оставляет границу для замены на S3-совместимое хранилище без изменения кода портфолио-сервисов.
## 💾 Backup и восстановление
Проект включает автоматический ежедневный backup PostgreSQL через сервис `db-backup` в Docker Compose.
### Как это работает
- **Каждый день в 03:00** выполняется `pg_dump` базы `gmrelay_db`.
- Дампы сжимаются (`gzip`) и сохраняются в volume `pgbackups` (`/backups`).
- Формат имени: `gmrelay_db_YYYYMMDD_HHMMSS.sql.gz`.
- Ротация: по умолчанию хранятся последние **7 дней** (настраивается через `BACKUP_RETENTION_DAYS`).
### Проверка бэкапов
```bash
docker compose exec db-backup ls -la /backups
```
### Ручное создание дампа
```bash
docker compose exec db-backup sh -c "pg_dump -h db -U gmrelay -d gmrelay_db | gzip > /backups/gmrelay_db_manual.sql.gz"
```
### Восстановление из бэкапа
```bash
# Использовать последний автоматический бэкап
./scripts/restore.sh
# Или указать конкретный файл
./scripts/restore.sh backups/gmrelay_db_20260512_030000.sql.gz
```
> [!WARNING]
> Восстановление **перезаписывает текущую базу данных**. Убедитесь, что вы понимаете последствия, прежде чем запускать `restore.sh`.
### Переменные окружения (опциональные)
```env
BACKUP_RETENTION_DAYS=7
BACKUP_VOLUME_NAME=game_pgbackups
```
Инфраструктура автоматически:
- Создаст локальную Docker-сеть и volume PostgreSQL, если их ещё нет.
- Поднимет PostgreSQL, доступный для контейнеров как `db:5432`.
- Запустит бота (применив миграции БД).
- Запустит веб-интерфейс на `http://localhost:8080` или другом порту из `GMRELAY_WEB_PORT`.
---
## 🗂 Структура репозитория
## ⚙️ Настройка бота в Telegram
```
├── src/
│ ├── GmRelay.AppHost/ # .NET Aspire orchestrator
│ ├── GmRelay.Bot/ # Telegram- и Discord-бот (Native AOT + NetCord Gateway worker)
│ ├── GmRelay.ServiceDefaults/ # Aspire service defaults
│ ├── GmRelay.Shared/ # Общие доменные модели
│ └── GmRelay.Web/ # Blazor Server dashboard
├── tests/
│ └── GmRelay.Bot.Tests/ # xUnit + NSubstitute
├── compose.yaml # Docker Compose (AMD64 + ARM64)
└── .env.example # Шаблон переменных окружения
```
Чтобы бот работал корректно:
1. **Добавьте бота в группу** (или Супергруппу/Форум).
2. **Назначьте бота Администратором**.
3. **Необходимые права**:
* `Выбор тем` (Managed Topics) — **обязательно** для Форумов.
* `Отправка сообщений`.
* `Закрепление сообщений` — рекомендуется.
> [!TIP]
> Owner группы определяется по первому человеку, который создал сессию в этой группе. Owner может назначать co-GM в Web Dashboard; owner и co-GM могут управлять сессиями через кнопки бота и веб-интерфейс.
---
## 👨‍💻 Для разработчиков
## 📝 Инструкция для Мастера
- **Архитектура**: проект следует 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`
### Создание расписания игр
Используйте команду `/newsession` с описанием в следующем формате:
```text
/newsession
Название: Легенды Берега Мечей (D&D 5e)
Время: 15.05.2024 19:30
Время: 22.05.2024 19:00
Мест: 4
Ссылка: https://discord.gg/invite-link
Картинка: https://example.com/adventure-cover.jpg
```
Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard.
Строка `Картинка:` тоже необязательна. Вместо ссылки можно прикрепить фото к сообщению `/newsession` и поместить команду в подпись к фото; бот возьмёт прикреплённое изображение и отправит его перед расписанием.
Для регулярной кампании можно не перечислять все даты вручную. Укажите одну строку `Время:`, количество игр и интервал в днях:
```text
/newsession
Название: Kingmaker
Время: 30.04.2026 19:30
Игр: 6
Интервал: 7
Мест: 5
Ссылка: https://discord.gg/invite-link
```
Бот создаст 6 игр с недельным шагом. Вместо `Игр:` также принимается `Сессий:` или `Повторов:`, вместо `Интервал:``Шаг:`.
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
### Делегирование управления
На странице группы Web Dashboard показывает owner и список co-GM. Owner может добавить помощника по Telegram ID, имени и username, а также снять роль co-GM. Назначенный co-GM видит группу в панели управления и может редактировать сессии, управлять batch-операциями, очередью, переносами и удалением игр, но не может назначать других co-GM.
### Перенос сессии голосованием
Owner или co-GM вызывает `/listsessions`, нажимает кнопку `⏰` у нужной сессии и отправляет в чат 2-3 варианта нового времени вместе с дедлайном:
```text
25.04.2026 19:30
26.04.2026 18:00
Дедлайн: 25.04.2026 12:00
```
Дедлайн должен быть в будущем и раньше первого предложенного времени. Участники выбирают один вариант кнопкой в Telegram, могут изменить голос до дедлайна и видят текущие результаты в сообщении голосования. По дедлайну бот выбирает вариант с наибольшим числом голосов, переносит сессию, сбрасывает RSVP и обновляет batch-сообщение. Если голосов нет или есть ничья, перенос отклоняется, а время сессии остаётся прежним.
### Шаблоны и bulk-операции в Web Dashboard
Вкладка `Шаблоны` в левом меню вынесена отдельно от страницы группы. Owner и co-GM выбирают группу, сохраняют шаблон кампании с названием, ссылкой, количеством игр, интервалом, лимитом мест и режимом уведомлений, а также удаляют устаревшие шаблоны.
На странице группы Web Dashboard показывает только применение сохранённых шаблонов и отдельный блок для каждой пачки игр. Owner и co-GM могут:
- создать новый batch из шаблона, выбрав только первую дату расписания;
- обновить общий `title` и `link` сразу у всех сессий batch;
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
- клонировать batch на следующую неделю или следующий календарный месяц.
После создания из шаблона или клонирования появляется новая пачка с новым Telegram-сообщением и пустым составом игроков. После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается.
Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам.
### Telegram Mini App Dashboard
Owner и co-GM могут открыть мобильный dashboard прямо из Telegram: через кнопку меню бота или кнопку `Открыть dashboard` после `/start`. Дополнительный пароль вводить не нужно: GM-Relay сам проверит вход через Telegram.
Внутри открывается та же панель управления, только удобная для телефона. Можно смотреть свои группы, редактировать игры, управлять листом ожидания, запускать шаблоны и выполнять массовые действия с пачками игр. Чужие группы не появятся: dashboard показывает только те группы, где вы owner или co-GM.
Если автоматический вход не сработал, Mini App покажет понятное сообщение и кнопку входа через Telegram. Нажмите её, подтвердите вход, и dashboard откроется в том же окне Telegram.
### Другие команды
- `/listsessions` — Показать список всех актуальных игр в этой группе. Для owner/co-GM команда также показывает кнопки отмены, переноса, повышения из листа ожидания и удаления.
- `⏰` в панели `/listsessions` — Запустить голосование по 2-3 вариантам нового времени.
- `/deletesession` — Удалить сессию.
- `/exportcalendar` — Получить `.ics` файл с играми.
- `/help` — Справка по формату.
---
## 🏗 Разработка и запуск локально (.NET Aspire)
Для локальной разработки проще всего использовать .NET Aspire:
1. Установите [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) и workload Aspire.
2. Откройте решение `GM-Relay.slnx`.
3. Установите переменные окружения (или user secrets) для `GmRelay.AppHost`.
4. Запустите проект `GmRelay.AppHost`. Aspire Dashboard запустится автоматически, предоставляя удобный мониторинг БД, бота и веб-интерфейса.
> [!NOTE]
> При использовании **Dapper** в режиме Native AOT, все SQL-запросы используют строго типизированные DTO. Динамические типы (`dynamic`) не поддерживаются.
---
## 🧪 Тестирование
Основной набор проверок запускается командой:
```bash
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --collect:"XPlat Code Coverage"
```
Начиная с `v1.9.9`, тестовый набор включает функциональный smoke-сценарий обещаний лендинга для Telegram: batch-сессии на несколько дат, inline-кнопки записи/выхода, лимиты мест, waitlist, автоповышение игрока, голосование за перенос, direct-notification mode и перерисовку Telegram batch-поста после dashboard-изменений. Smoke работает через fake Telegram messenger и не требует внешнего Telegram API.
---
## 📜 Лицензия
MIT License. См. [LICENSE](./LICENSE).
---
*Построено с ❤️ для TTRPG-сообщества.*
Проект распространяется под лицензией MIT. Использование в некоммерческих целях приветствуется.
-99
View File
@@ -1,99 +0,0 @@
## 🐞 Patch 3.9.2 — Hotfix: club-picker молча падал на шаге «Видимость» (3.9.1 неполный)
В 3.9.1 был починен только `WizardDraftRepository` (самый частый путь). Тот же баг с `(CommandDefinition)`-оверлоадом Dapper остался в 4 клуб-пикерах / permission-локапах — Wizard доходил до шага «Видимость», и при выборе «Публичная в витрине клуба» / «Только для членов клуба» `PersistAndRenderAsync` дёргал `_messenger.GetOwnerClubsAsync``PlatformNotSupportedException``GameCreationWizard` глотал исключение → кнопка `ack` отправлялась с тостом «⚠️ Ошибка», но нового шага пользователь не видел. Privacy «не цеплялась».
### 🩹 Что починено
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs::GetOwnerClubsAsync``new CommandDefinition(...)` → прямой `QueryAsync<WizardClubOption>(sql, params)`.
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs::GetOwnerClubsAsync` — то же.
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs::WizardClubLookup.LoadClubsAsync` — то же.
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs::LoadManagerUserIdsAsync` — то же.
- `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` — добавлен `<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>` (раньше был только в Shared и Bot). Без этого `Dapper.AOT`-генератор не сканировал DiscordBot, и `new CommandDefinition`-вызовы в DiscordBot падали бы в рантайме даже после фикса сигнатур.
- `src/GmRelay.DiscordBot/Program.cs` — добавлен `[module: Dapper.DapperAot]` (раньше только в Bot и Shared).
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.1 → 3.9.2.
- `tests/.../WizardDraftRepositoryAotShapeTests.cs` — расширены `ClubPickerAndPermissionLookups_ShouldNotUseCommandDefinition` на 4 inline-cases + опциональный `containingClass` для дизамбигуации одинаковых имён методов в DiscordWizardInteractionModule.
### ⚠️ Известные ограничения
- Web-проект не под NativeAOT (Blazor Server), там `Dapper.AOT` не подключён и используется обычный Dapper; регрессия его не касается.
### 🧪 Тесты
- 592/594 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит интерсепторы для всех 4 клуб-пикеров + `WizardDraftRepository` (всего 5 файлов: 4 в Bot/DiscordBot/DiscordBot + 1 в Shared).
## 🐞 Patch 3.9.1 — Hotfix: Telegram-визард мёртв после 3.9.0
Регрессия в `WizardDraftRepository` (NativeAOT). В Telegram **не реагировали кнопки** и **не создавались игры**, потому что Dapper.AOT 1.0.48 не генерирует интерсепторы для оверлоада `(CommandDefinition)` — рантайм падал в `CreateParamInfoGenerator``PlatformNotSupportedException` на каждом апдейте, `TelegramBotService` глотал исключение и апдейт терялся.
### 🩹 Что починено
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs` — все 4 метода переписаны с `new CommandDefinition(sql, params, cancellationToken: ct)` на прямой оверлоад `connection.QuerySingleOrDefaultAsync<WizardDraft>(sql, params)` (паттерн `JoinSessionHandler`). Dapper.AOT генерирует интерсепторы только для прямого оверлоада.
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs``CreatedAt` / `UpdatedAt` / `ExpiresAt` переведены с `DateTimeOffset` на `DateTime` (UTC). AOT RowFactory вызывает `reader.GetDateTime()` напрямую и не делает `DateTime → DateTimeOffset` конверсию.
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`, `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs``DateTimeOffset.UtcNow``DateTime.UtcNow` в новых драфтах.
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.0 → 3.9.1.
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryAotShapeTests.cs` — 5 source-grep регрессионных тестов: ни один метод `WizardDraftRepository` не должен использовать `new CommandDefinition`, и три timestamp-свойства `WizardDraft` должны быть `DateTime` (не `DateTimeOffset`).
### ⚠️ Известные ограничения
- В `TelegramWizardMessenger.GetOwnerClubsAsync`, `DiscordWizardMessenger.GetOwnerClubsAsync`, `DiscordPermissionLookup.LoadManagerUserIdsAsync`, `DiscordWizardInteractionModule.GetOwnerClubsAsync` остаётся `new CommandDefinition`. Эти вызовы **падают на AOT так же**, как падал `WizardDraftRepository` в 3.9.0. Пользователь натыкается на это только когда выбирает «видимость = клуб/мемберы» и доходит до шага выбора клуба. Будет исправлено в 3.9.2 вместе с переводом `DiscordWizardInteractionModule` на прямые Dapper-оверлоады.
### 🧪 Тесты
- 588/590 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит 4 интерсептора + `RowFactory17<WizardDraft>` + `CommandFactory30<WizardDraft>`.
## 🎯 Minor 3.9.0 — Discord-визард создания игры/пула (issue #112)
Пошаговый сценарий создания одиночной игры или пула игр в Discord-чате, по аналогии с Telegram-визардом из 3.8.0. Платформо-нейтральная стейт-машина `GameCreationWizard` и контракт `IWizardMessenger` перенесены в `GmRelay.Shared`, чтобы обе платформы (Telegram/Discord) использовали один и тот же движок визарда.
### 🧩 Что вошло в релиз
**Платформо-нейтральный рефакторинг (GmRelay.Shared)**
- `Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs` — стейт-машина визарда (один источник правды для обеих платформ)
- `Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs` — контракт мессенджера (edit/send/answer/getOwnerClubs)
- `Features/Sessions/CreateSession/Wizard/WizardInteraction.cs` — запись взаимодействия (OwnerId, Text, CallbackPayload, PhotoFileId, PhotoUrl, InteractionId)
- `Features/Sessions/CreateSession/Wizard/WizardAction.cs`, `WizardKeyboard.cs`, `WizardStepLimits.cs` — модель кнопок и лимитов
- `Features/Sessions/CreateSession/Wizard/WizardDraft.cs` — добавлено поле `Platform`
- `Migrations/V032__add_wizard_drafts_platform.sql``ALTER TABLE wizard_drafts ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram'`
**Discord-адаптер (GmRelay.DiscordBot)**
- `Features/Sessions/Wizard/DiscordWizardCommand.cs` — slash-команда `/newsession-wizard` с проверкой owner/co-GM через `DiscordPermissionLookup`
- `Features/Sessions/Wizard/DiscordWizardStep.cs` — рендер 15 шагов в NetCord embed + buttons/StringSelectMenu/modals
- `Features/Sessions/Wizard/DiscordWizardMessenger.cs` — реализация `IWizardMessenger` через NetCord REST (edit с fallback на re-send при 401/403/404)
- `Features/Sessions/Wizard/DiscordWizardSubmitter.cs` — финализация с 3-retry циклом
- `Features/Sessions/Wizard/DiscordWizardContextStore.cs` — in-memory кэш контекста (guild/channel/messageId) для 15-минутного interaction token
- `Features/Sessions/Wizard/DiscordWizardInteractionModule.cs` — inbound handlers: 3 NetCord `ComponentInteractionModule<TContext>` (button/StringMenu/Modal) + `WizardInteractionDispatcher`
- `Features/Sessions/Wizard/DiscordPermissionLookup.cs` — DB-хелпер для `group_managers`
- `Program.cs` — DI-регистрации + 3 `AddComponentInteractions<TInteraction, TContext>`
**Тесты**
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/*` — обновлены под новый контракт
- `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs` — 12 source-level smoke-тестов на структуру interaction module
### 🗺 Что это даёт
- Мастера (GM) могут пошагово создавать игры и пулы слотов прямо в Discord через slash-команду, кнопки, выпадающие меню и модальные окна.
- UX адаптирован под Discord (нативные components), а не скопирован из Telegram.
- Общая стейт-машина и валидация: Telegram и Discord визарды развиваются синхронно, баги фиксятся в одном месте.
- PickClub-шаг использует реальный SQL-запрос к `club_memberships` с фильтром по роли Owner/CoGm.
### 📦 Версия и деплой
- Версия обновлена до 3.9.0 (`NavMenu.razor`, `.gitea/workflows/deploy.yml`)
- Docker-образы будут тегированы `3.9.0` при пуше в `main`
- Миграция V032 применяется автоматически на старте Bot
## 🛠 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
+2 -74
View File
@@ -16,40 +16,8 @@ services:
timeout: 3s
retries: 10
db-backup:
image: postgres:17-alpine
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
POSTGRES_USER: gmrelay
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: gmrelay_db
PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7}
volumes:
- pgbackups:/backups
networks:
- gmrelay
entrypoint: ["sh", "-c"]
command:
- |
cat > /usr/local/bin/backup.sh << 'EOF'
#!/bin/sh
set -e
TMPFILE="/tmp/backup_$$.sql"
pg_dump -h db -U gmrelay -d gmrelay_db > "$TMPFILE"
gzip "$TMPFILE"
mv "$TMPFILE.gz" "/backups/gmrelay_db_$(date +%Y%m%d_%H%M%S).sql.gz"
find /backups -name 'gmrelay_db_*.sql.gz' -type f -mtime +${BACKUP_RETENTION_DAYS} -delete
EOF
chmod +x /usr/local/bin/backup.sh
echo "0 3 * * * /usr/local/bin/backup.sh" | crontab -
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.9
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.10.0
restart: always
depends_on:
db:
@@ -60,70 +28,30 @@ services:
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
networks:
- gmrelay
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.9
restart: always
depends_on:
db:
condition: service_healthy
bot:
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
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:3.9.9
image: git.codeanddice.ru/toutsu/gmrelay-web:1.10.0
restart: always
depends_on:
db:
condition: service_healthy
bot:
condition: service_healthy
environment:
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "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:-}"
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
ports:
- "${GMRELAY_WEB_PORT:-8080}:8080"
volumes:
- web_keys:/app/dataprotection-keys
- portfolio_covers:/app/portfolio-covers
networks:
- gmrelay
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
volumes:
pgdata:
name: ${POSTGRES_VOLUME_NAME:-game_pgdata}
web_keys:
name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
pgbackups:
name: ${BACKUP_VOLUME_NAME:-game_pgbackups}
portfolio_covers:
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
networks:
gmrelay:
-153
View File
@@ -1,153 +0,0 @@
# Discord wizard adapter — issue #112
## Summary
Implemented the Discord side of the platform-neutral game/pool creation
wizard (`feat/issue-112-wizard-refactor`). Six adapter files in
`src/GmRelay.DiscordBot/Features/Sessions/Wizard/` plus one inbound
handler module turn the `/newsession-wizard` slash command into a
fully clickable step-by-step wizard with button, StringSelectMenu, and
modal handlers. The shared `GameCreationWizard` state machine in
`GmRelay.Shared` is the single source of truth — the Discord adapter
only translates between NetCord's interaction types and the
platform-neutral `WizardInteraction` record.
## Files
Adapter (all under `src/GmRelay.DiscordBot/Features/Sessions/Wizard/`):
- `DiscordWizardContextStore.cs``IWizardContextStore` interface +
thread-safe in-memory store. Keyed by `draft.Id`. Holds the
`(GuildId, ChannelId, MessageId, ThreadId?)` snapshot the messenger
needs to re-send a draft after a 15-minute interaction token
expires.
- `DiscordWizardStep.cs` — renderer for all 15 wizard steps. Returns
an embed + `IReadOnlyList<IMessageComponentProperties>` (mixes
`ActionRow` buttons with `StringMenu` select menus) and exposes
`BuildModal` for the 8 modal-collecting steps.
- `DiscordWizardMessenger.cs``IWizardMessenger` impl. Uses
`RestClient.SendMessageAsync` / `ModifyMessageAsync` (edit falls
back to re-send on 401/403/404). Toast replies are stashed in the
existing `DiscordInteractionReplyCache`.
- `DiscordWizardSubmitter.cs` — 3-retry finalize loop. Builds the
shared `CreateSessionCommand` and calls `CreateSessionHandler`;
on success edits the draft to "✅ Создано: N сессий", on failure
shows retry/cancel buttons.
- `DiscordWizardCommand.cs``/newsession-wizard` slash command.
Owner/co-GM check via `group_managers` (DiscordPermissionLookup).
- `DiscordPermissionLookup.cs` — small DB helper that loads
`group_managers` rows for a guild.
- `DiscordWizardInteractionModule.cs`**inbound handlers** (this
commit). Three NetCord `ComponentInteractionModule<TContext>`
shells (button / StringSelectMenu / modal) share one
`WizardInteractionDispatcher` that:
1. parses the custom-id tail (`btn:choice:<step>:<value>`,
`btn:cancel`, `btn:back`, `btn:create`, `btn:resume:continue`,
`btn:resume:restart`, `select:<step>`, `modal:<step>`);
2. loads the active draft via
`IWizardDraftRepository.GetActiveAsync("Discord", ownerId, ct)`;
3. routes the callback through the shared
`GameCreationWizard.HandleInteractionAsync` (or
`DiscordWizardSubmitter.SubmitAsync` for the `create` button);
4. opens a modal popup when the new step needs text input,
otherwise acks with a deferred message so Discord doesn't show
"Application did not respond".
DI / tests:
- `src/GmRelay.DiscordBot/Program.cs` — 7 singleton registrations
(`IWizardDraftRepository`, `IWizardContextStore`, `IWizardMessenger`,
`GameCreationWizard`, `DiscordWizardSubmitter`,
`WizardInteractionDispatcher`, `DiscordWizardButtonModule`,
`DiscordWizardStringMenuModule`, `DiscordWizardModalModule`) plus
3 `AddComponentInteractions<TInteraction, TContext>` calls
(Button, StringMenu, Modal).
- `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs`
— 12 source-level structural smoke tests: handler classes exist,
all 3 derive from `ComponentInteractionModule<TContext>`, all 3
register `[ComponentInteraction("wizard")]`, the dispatcher
exposes `HandleButtonAsync` / `HandleStringMenuAsync` /
`HandleModalAsync`, all 5 callback kinds (`choice` / `back` /
`cancel` / `create` / `resume`) are routed, the dispatcher
invokes `GameCreationWizard.HandleInteractionAsync` and
`DiscordWizardSubmitter.SubmitAsync` on `create`, Program.cs
registers all 3 `AddComponentInteractions` and all 4 module
classes, draft lookup is by `GetActiveAsync("Discord", …)`, modal
walks `Components[0] → TextInput → .Value`, string menu reads
`SelectedValues[0]`.
## Custom-id wire format
| Interaction | Custom-id | Handler |
|------------------------|------------------------------------------|-------------------------------------------------|
| Choice button | `wizard:btn:choice:<step>:<value>` | Wizard's `ApplyChoice` |
| Back button | `wizard:btn:back` | Wizard's `ApplyBack` |
| Cancel button | `wizard:btn:cancel` | Wizard deletes draft + edits "❌ Мастер отменён" |
| Create button | `wizard:btn:create` | `DiscordWizardSubmitter.SubmitAsync` (3 retries) |
| Resume: continue | `wizard:btn:resume:continue` | Re-render current step via messenger |
| Resume: restart | `wizard:btn:resume:restart` | Delete draft, prompt to re-run |
| StringSelectMenu | `wizard:select:<step>` | Wizard's `ApplyChoice` (step, SelectedValues[0]) |
| Modal submit | `wizard:modal:<step>` | Wizard's `ApplyText` (Text = Component[0].Value) |
The wizard renderer (`DiscordWizardStep`) owns the prefix generation:
`wizard:btn:<step>:<value>`, `wizard:select:<step>`,
`wizard:modal:<step>`. The handlers match by the `wizard` prefix and
parse the rest.
## Acceptance
-`dotnet build` решения — 0 warnings, 0 errors
-`dotnet test` — 190/190 Discord+Wizard tests pass (2 pre-existing
skipped). 12 source-level smoke tests cover the interaction module.
-`dotnet format --verify-no-changes` — clean
- ✅ Pushed to `feat/issue-112-wizard-refactor` (commits `b81d865`,
`f095209`).
PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor
## Open questions
- ~~**Club lookup at the PickClub step**~~ — **FIXED in commit `7cfb196`.**
`WizardClubLookup.LoadClubsAsync` now queries `group_managers`
directly via injected `NpgsqlDataSource` with the same
`Owner | CoGm` role filter the messenger uses. The dispatcher
reads the owner's real club list and renders them in the
StringSelectMenu. Build green, 12 source-level smoke tests still
pass.
- ~~**MaybeOpenModalAsync was a no-op in the previous commit**~~ — **FIXED in commit `f095209`.**
The dispatcher now runs the wizard first (which edits the draft
embed), then sends the response as either
`InteractionCallback.Modal(modalProperties)` (when the new step
needs text input) or `InteractionCallback.DeferredMessage()`
(otherwise). NetCord locks the response type after the first
`SendResponseAsync` call, so the fix is NOT to call
`DeferredMessage` upfront.
- **Modal handler's free-text mapping is a hack.** Modal steps like
`SystemFreeText`, `DurationFreeText`, `PoolSystemDurationFreeText`
are mapped to the canonical wizard step (`System`, `Duration`,
`PoolSystemDuration`) in `MapModalStepToWizardStep`. This works
because the wizard's `ApplyText` dispatches on the canonical step
name, but a future refactor of `ApplyText` to know about the
free-text step names would break this. The clean fix is to add
dedicated "free text" steps to `WizardStepNames`.
- **Resume:continue is a re-render, not a true resume.** The wizard
has no special resume case; clicking "▶️ Продолжить" just re-emits
the current step's embed. This is fine for the user (the embed is
identical to the last one they saw before the click), but the
underlying state isn't really "continued" — if the wizard's cleanup
service expired the draft between the slash command and the
click, the user gets a re-render of an empty step.
- **One-draft-per-owner invariant.** The wizard's "one active draft
per owner" rule means a single Discord user can't run two wizard
sessions in parallel. Acceptable for now, but the wizard's state
machine doesn't enforce this — only the dispatcher does, via
`GetActiveAsync("Discord", ownerId)`.
## Final commit history on `feat/issue-112-wizard-refactor`
- `8f0f2ef` — Task 1: platform-neutral wizard refactor (core + Shared types)
- `b81d865` — Task 2: Discord adapter scaffolding (messenger, step, submitter, command)
- `f095209` — Task 2: interaction module + modal popup fix
- `7cfb196` — Task 2: select/modal parser off-by-one + real club lookup
PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor
@@ -56,18 +56,8 @@ Aspire обеспечивает:
- Service discovery и передачу connection strings.
- OpenTelemetry (traces, metrics, logs) из коробки.
- Aspire Dashboard для мониторинга.
- **Три сервиса:** Bot (Telegram long polling + Discord Gateway), Web, PostgreSQL.
### 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
### 5. Telegram.Bot 22.x + Long Polling
- Long Polling — единственный вариант для Pi за NAT.
- Telegram.Bot поддерживает `System.Text.Json` source generators для AOT.
@@ -1,69 +0,0 @@
# ADR 002: Platform-Neutral Batch Rendering
## Status
**Accepted** — implemented in v1.10.0 (PR #42).
## Context
`SessionBatchRenderer` жил в `GmRelay.Shared` и напрямую зависел от `Telegram.Bot` (`InlineKeyboardMarkup`, `ParseMode.Html`). Это создавало проблемы:
1. **Shared не был platform-neutral.** Любой платформенный проект (Discord, Slack, WebSocket) тащил Telegram-зависимость.
2. **Дублирование логики.** `GmRelay.Web` использовал тот же рендерер через прямую зависимость от `Shared`, но Web — это не Telegram-клиент.
3. **Невозможно написать unit-тесты без Telegram-объектов.** Smoke-тесты создавали InlineKeyboardMarkup даже для проверки чисто доменной логики.
## Decision
Разделить рендеринг на две стадии:
1. **View Builder (platform-neutral)** — собирает view model из доменных DTO.
2. **Platform Renderer (platform-specific)** — превращает view model в платформенное представление.
```
Domain DTOs
SessionBatchViewBuilder (Shared)
SessionBatchViewModel (platform-neutral)
├──► TelegramSessionBatchRenderer ──► HTML + InlineKeyboardMarkup
└──► DiscordSessionBatchRenderer ──► Discord embeds + buttons
```
### Изменённые компоненты
| Компонент | Было | Стало |
|---|---|---|
| `SessionBatchRenderer` | `GmRelay.Shared.Rendering` | Удалён |
| `SessionBatchViewBuilder` | — | `GmRelay.Shared.Rendering` |
| `SessionBatchViewModel` | — | `GmRelay.Shared.Rendering` |
| `TelegramSessionBatchRenderer` | — | `GmRelay.Bot` + `GmRelay.Web` |
| `DiscordSessionBatchRenderer` | — | `GmRelay.DiscordBot.Rendering` |
| `BatchMessageEditor` | `GmRelay.Shared.Rendering` | `GmRelay.Bot` + `GmRelay.Web` |
## Consequences
### Positive
- `GmRelay.Shared` больше не зависит от `Telegram.Bot`. Чистый platform-agnostic проект.
- Discord renderer lives in `GmRelay.DiscordBot`, so NetCord stays out of `Shared`.
- Unit-тесты ViewBuilder не создают `InlineKeyboardMarkup`.
- Логика подсчёта игроков, сортировки сессий и генерации действий — в одном месте (ViewBuilder).
### Negative
- **Временное дублирование.** `TelegramSessionBatchRenderer` и `BatchMessageEditor` скопированы в `Bot` и `Web`. Планируется вынести в `GmRelay.Shared.Telegram` при появлении третьего Telegram-потребителя.
- **Дополнительная стадия.** Теперь два вызова вместо одного: `Build` + `Render`. Этоtrade-off за чистоту абстракции.
## Related
- 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`).
@@ -1,57 +0,0 @@
# 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.
+36 -143
View File
@@ -1,4 +1,4 @@
# GM-Relay - C4 Model
# GM-Relay C4 Model
## Level 1: System Context
@@ -6,27 +6,19 @@
C4Context
title GM-Relay System Context
Person(gm, "Game Master", "Creates sessions and manages schedules")
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
Person(visitor, "Public visitor", "Views published club schedules, sessions, GM profiles, and completed-adventure portfolio pages without private player data")
Person(gm, "Game Master", "Создаёт сессии, управляет расписанием игр")
Person(player, "Player", "Подтверждает участие через inline-кнопки")
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club/session/GM profile/portfolio pages, and shared scheduling logic")
System(gmrelay, "GM-Relay Bot", "Telegram Worker Service на Raspberry Pi. Управляет подтверждениями, рассылает напоминания и ссылки.")
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, master_profiles, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, cover_storage_keys")
System_Ext(telegram, "Telegram Bot API", "Long Polling. Сообщения, inline keyboards, callback queries.")
SystemDb_Ext(postgres, "PostgreSQL", "Сессии, игроки, RSVP-статусы")
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(player, gmrelay, "Submits moderated reviews for completed-adventure portfolios")
Rel(visitor, gmrelay, "Views public club, session, GM profile, and portfolio pages")
Rel(telegram, gmrelay, "Updates via long polling")
Rel(discord, gmrelay, "Gateway events and component interactions")
Rel(gm, telegram, "Команды бота (/newsession)")
Rel(player, telegram, "Нажимает кнопки (✅ Буду / ❌ Не смогу)")
Rel(telegram, gmrelay, "Updates (Long Polling)")
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
Rel(gmrelay, discord, "Send/edit schedule, RSVP, reminder, and reschedule messages")
Rel(gmrelay, postgres, "SQL via Npgsql and Dapper")
Rel(gmrelay, postgres, "SQL (Npgsql + Dapper)")
```
## Level 2: Container
@@ -37,149 +29,50 @@ C4Container
Person(gm, "Game Master")
Person(player, "Player")
Person(visitor, "Public visitor")
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, public club/session/GM profile/portfolio pages, portfolio review submission and moderation, 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, publication settings, master_profiles, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, platform identities")
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_Ext(telegram, "Telegram Bot API")
System_Ext(discord, "Discord Gateway and REST API")
SystemDb_Ext(covers, "Portfolio covers volume", "Persistent file store for portfolio cover uploads (LocalPortfolioCoverStorage; S3-compatible replacement boundary)")
Rel(gm, telegram, "Commands")
Rel(gm, discord, "Slash commands")
Rel(player, telegram, "Callback queries")
Rel(player, discord, "Button interactions")
Rel(player, web, "Submits moderated reviews on completed-adventure portfolio pages")
Rel(visitor, web, "Read-only public schedule, sanitized GM profile, and completed-adventure portfolio pages")
Rel(telegram, bot, "GetUpdates")
Rel(discord, discordBot, "Gateway events")
Rel(player, telegram, "Callback Queries")
Rel(telegram, bot, "GetUpdates (Long Polling)")
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")
Rel(web, covers, "Saves, reads, and deletes cover files via IPortfolioCoverStorage")
```
## Level 3: Component - Session Interactions
## Level 3: Component (GmRelay.Bot)
```mermaid
C4Component
title Platform-Neutral Session Interactions
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")
}
Component(healthCheck, "DiscordHealthCheckHostedService", ":8082", "Healthcheck для Docker Compose")
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")
}
title GmRelay.Bot Components
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")
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 мин")
}
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")
```
## Level 3: Component - Completed-Adventure Portfolios
The portfolio subsystem lets GMs curate completed adventures from past sessions, publish a public detail page, and collect moderated player reviews. The cover files live in a persistent volume via the `IPortfolioCoverStorage` boundary; the public schema and contracts are isolated inside `GmRelay.Web.Services.Portfolio` so a future S3-compatible storage adapter can replace `LocalPortfolioCoverStorage` without touching the data layer.
```mermaid
C4Component
title Completed-Adventure Portfolio Subsystem
Person(gm, "Game Master", "Curates completed adventures and moderates reviews")
Person(player, "Player", "Submits one moderated review per completed adventure")
Person(visitor, "Public visitor", "Reads public portfolio pages and approved reviews")
Container_Boundary(web, "GmRelay.Web") {
Component(authorized, "AuthorizedPortfolioService", "Feature service", "Manager authorization, review submission authorization, identity resolution, cover cleanup orchestration")
Component(store, "PortfolioService", "Feature service", "Portfolio CRUD, public reads, review submission, moderation; SQL via Dapper.AOT and advisory locks")
Component(covers, "IPortfolioCoverStorage", "Storage boundary", "LocalPortfolioCoverStorage saves/reads/deletes cover files; S3-compatible replacement boundary")
Component(pages, "PublicPortfolio.razor", "Blazor page", "Renders /portfolio/{slug} and review form for participants")
Component(editor, "PortfolioEditor.razor", "Blazor page", "Renders /group/{id}/portfolio editor, cover upload, and review moderation queue")
}
ContainerDb(db, "PostgreSQL")
ContainerDb_Ext(coversVolume, "portfolio_covers volume", "Persistent file store for cover uploads")
Rel(gm, editor, "Creates, edits, publishes, moderates reviews")
Rel(player, pages, "Submits review")
Rel(visitor, pages, "Reads public portfolio and approved reviews")
Rel(pages, authorized, "GetReviewSubmissionStateForCurrentUserAsync, SubmitReviewForCurrentUserAsync")
Rel(pages, store, "GetPublicPortfolioGamesForClubAsync, GetPublicPortfolioGamesForMasterAsync, GetPublicPortfolioGameBySlugAsync")
Rel(editor, authorized, "GetPortfolioGamesForCurrentUserAsync, CreateDraftForCurrentUserAsync, UpdateDraftForCurrentUserAsync, ReplaceCoverForCurrentUserAsync, SetPublicationForCurrentUserAsync, ModerateReviewForCurrentUserAsync")
Rel(authorized, store, "All manager-gated reads/writes; identity and group authorization")
Rel(authorized, covers, "Save, read, delete cover files")
Rel(authorized, sessionStore, "ISessionStore.IsGroupManagerAsync / ResolveEffectivePlayerIdAsync")
Rel(store, db, "INSERT/UPDATE/SELECT on portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews")
Rel(covers, coversVolume, "Filesystem reads/writes")
Rel(editor, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath")
Rel(pages, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath")
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")
```
### Portfolio tables (PostgreSQL)
| Table | Purpose |
|---|---|
| `portfolio_games` | Adventure header: `title`, `description`, `system`, `format`, `public_slug`, `cover_storage_key`, `completed_at`, `is_public`, `published_at` |
| `portfolio_game_sessions` | Many-to-many link from `portfolio_games` to past `sessions` used to assemble the adventure |
| `portfolio_game_masters` | Many-to-many link from `portfolio_games` to `players` who are managers of the source group |
| `portfolio_game_reviews` | Player reviews: `author_player_id`, `author_display_name`, `body`, `publication_consent_at`, `moderation_status` (`Pending` / `Approved` / `Rejected` / `Hidden`), `moderated_by_player_id`, `moderated_at` |
### Cover storage boundary
- `IPortfolioCoverStorage` is registered as a DI singleton in `GmRelay.Web`.
- The current implementation `LocalPortfolioCoverStorage` writes under `PortfolioCovers:StoragePath` (default `/app/portfolio-covers`) and is mounted as the Docker volume `portfolio_covers` (configurable via `PORTFOLIO_COVERS_VOLUME_NAME` in `.env`).
- Static files are served by the web container at `/portfolio-covers/{storageKey}` with `Cache-Control: public, max-age=31536000, immutable`.
- Replacing the local filesystem with S3-compatible object storage is a contract-only change: implement `IPortfolioCoverStorage` with the same `SaveAsync` / `GetPublicPath` / `DeleteIfExistsAsync` surface and swap the DI registration in `PortfolioCoverStorageExtensions.AddPortfolioCoverStorage`.
@@ -0,0 +1,438 @@
# 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.
---
-54
View File
@@ -1,54 +0,0 @@
"""Detailed code review plan for the Discord wizard feature branch.
Read this file FIRST. It has the full review scope. The original prompt
in the spawn was truncated due to Windows CLI limits; this file is the
canonical spec.
"""
# Branch to review
BRANCH = "feat/issue-112-wizard-refactor"
BASE = "origin/main"
# Files of interest
SHARED = "src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/*"
BOT_WIZARD = "src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/*"
BOT_CREATE = "src/GmRelay.Bot/Features/Sessions/CreateSession/*"
MIGRATION = "src/GmRelay.Bot/Migrations/V032__add_wizard_drafts_platform.sql"
DISCORD = "src/GmRelay.DiscordBot/Features/Sessions/Wizard/*"
PROG_CS = "src/GmRelay.DiscordBot/Program.cs"
TESTS_WIZ = "tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/*"
TESTS_SMOKE = "tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs"
DELIVERABLE = "deliverable.md"
REVIEW_FOCUS = [
"Architecture: Shared/Bot/DiscordBot separation; no Telegram.Bot in Shared; no NetCord in Shared; single state machine source.",
"Security: owner/co-GM checks everywhere; NRE on null Context.User; SQL injection; connection strings with passwords.",
"Correctness: AOT-safety (no reflection, no dynamic); off-by-one in customId parsers; CancellationToken/Services.",
"Style: naming consistent; Async/await by convention; logging at right levels.",
"Tests: smoke tests are string-matching — where would real tests be useful?",
"Migration safety: V032 DEFAULT value, will it fail on existing rows?",
"Documentation: deliverable.md updated, open questions listed?",
]
OUTPUT_FORMAT = """\
## VERDICT: APPROVE / REQUEST_CHANGES / COMMENT
## Critical findings
(file:line — what's wrong — how to fix)
## Important findings
(file:line — what's wrong)
## Nits
(quick observations)
## Summary
(1-2 sentences)
"""
COMMANDS_HINT = """\
git fetch origin
git diff origin/main..feat/issue-112-wizard-refactor --stat
git diff origin/main..feat/issue-112-wizard-refactor
dotnet build && dotnet test
"""
-362
View File
@@ -1,362 +0,0 @@
# Code review — feat/issue-112-wizard-refactor (issue #112)
**Reviewer:** Verifier (mvs_86868b01387b492aae27ce6f77aca4cb)
**Branch:** `feat/issue-112-wizard-refactor` (base `origin/main`)
**Commits reviewed:** `8f0f2ef`, `b81d865`, `f095209`, `7cfb196`, `c4a77d3`
**Build:**`dotnet build GM-Relay.slnx` — 0 warnings, 0 errors
**Tests:** 580 passed / 2 skipped / 1 failed. 1 failure is the pre-existing
`DiscordProjectStructureTests.Version_ShouldBeSynchronizedForDiscordFeatureRelease`
(uncommitted release work in working tree, not part of this branch).
## VERDICT: REQUEST_CHANGES
The branch is **NOT shippable in its current state.** Every choice button
and every "Другое…" button in the wizard is silently broken at runtime
due to a wire-format mismatch between the renderer and the dispatcher.
A user who clicks "D&D 5e", "Pathfinder 2e", "Waitlist вкл", "Опубликовать",
or any "Другое… ✏️" button will see "⚠️ Неизвестная кнопка" instead of
the wizard advancing. The 12 source-level smoke tests don't catch this
because they only check string presence in source code, not the actual
button-click → dispatch flow.
The architecture is otherwise sound: no Telegram.Bot/NetCord leak into
Shared, single state-machine source, all DI wired, AOT-safe, parameterized
SQL, owner/co-GM permission check with null-safety, SecretRedactor on the
connection string. The fix is small and surgical.
---
## Critical findings
### C-1. Choice-button custom-id is missing the `choice:` segment — wizard is unusable end-to-end
**Files:**
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:79-80`
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs:174-226`
**What's wrong.** The dispatcher's button handler matches `parts[1]`
against `"choice"`, `"back"`, `"cancel"`, `"create"`, `"resume"`, and
falls through to "default → Неизвестная кнопка" for anything else. The
dispatcher's own documentation and the deliverable's wire-format table
both agree the canonical choice-button format is
`wizard:btn:choice:<step>:<value>`. But `ButtonCustomId` emits
`$"wizard:btn:{step}:{value}"` — **the literal `choice:` segment is
missing**. So clicking "D&D 5e" on the System step produces
`wizard:btn:System:Dnd5e`, which NetCord strips the `[ComponentInteraction("wizard")]`
prefix from, arriving at the dispatcher as `args = "btn:System:Dnd5e"`
`parts = ["btn", "System", "Dnd5e"]``parts[1] = "System"` → default
branch → "⚠️ Неизвестная кнопка".
The same bug hits:
- `RenderType` — "Одну игру" / "Пул игр" buttons (emits
`wizard:btn:Type:single`, `wizard:btn:Type:pool`)
- `RenderSystem` — D&D/Pathfinder/CoC/GURPS/Fate ("wizard:btn:System:Dnd5e" etc.)
**and** the "⏭ Пропустить" button (emits `wizard:btn:System:_skip`)
- `RenderDuration` — "3 часа" / "4 часа" / "5 часов" / "6 часов" and
"⏭ Пропустить"
- `RenderCapacity` / `RenderPoolSlotCapacity` — "Waitlist вкл" / "Без waitlist"
(emits `wizard:btn:Capacity:waitlist:on` etc.)
- `RenderPublish` — "Опубликовать" / "Только в чате"
- `RenderPoolAddSlots` — "Добавить слот" / "Готово, к превью"
- `RenderPickClub` — back/cancel still work (parts[1] = "back"/"cancel")
Only back, cancel, create, resume, and the "Другое… ✏️" → modal buttons
are unaffected by *this specific* bug (see C-2 for modal buttons).
The smoke test `Dispatcher_ShouldParseAllWizardActionKinds`
(`tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs:85-97`)
checks that the strings `"choice"`, `"back"`, `"cancel"`, `"create"`,
`"resume"` appear in `DiscordWizardInteractionModule.cs`. It doesn't
check the renderer's output, so the bug is invisible to the test suite.
The same file's comment at line 69 documents the *expected* format as
`"btn:choice:Type:single"` — which would be the correct fix.
**How to fix.** Change `ButtonCustomId` in `DiscordWizardStep.cs`:
```csharp
public static string ButtonCustomId(string step, string value) =>
$"wizard:btn:choice:{step}:{value}";
```
This brings the renderer into alignment with the dispatcher's switch
case `"choice"` (line 209) and with the deliverable's table at line 83.
Re-verify with a manual click-through of every button on every step, or
add a parser-side test (see I-3 below).
### C-2. "Другое… ✏️" modal trigger buttons route to "default" instead of opening a modal
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:190, 206, 314`
**Read against:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs:207-226`
**What's wrong.** The renderer emits modal triggers as
`wizard:btn:modal:SystemFreeText` (the "Другое… ✏️" button on the System
step), `wizard:btn:modal:DurationFreeText` (Duration step), and
`wizard:btn:modal:PoolSystemDurationFreeText` (PoolSystemDuration step).
The dispatcher's button switch handles `"choice"`, `"back"`, `"cancel"`
but not `"modal"`. The user's click on "Другое…" hits the default branch
and returns "⚠️ Неизвестная кнопка" — no modal pops up, the wizard
doesn't advance. The open question in `deliverable.md:125-132` ("Modal
handler's free-text mapping is a hack") implicitly assumes these buttons
*work* in production, so the design intent is clear but the implementation
didn't deliver it.
**How to fix.** Add a `"modal"` case in the dispatcher's switch (between
`"create"` and the existing branches, mirroring the "create" / "resume"
special-case pattern):
```csharp
if (parts[1] == "modal" && parts.Length >= 3)
{
var modal = DiscordWizardStep.BuildModal(parts[2], draft.ChatId);
if (modal is not null)
{
await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal));
}
else
{
await AckWithErrorAsync(context.Interaction, "Модал недоступен");
}
return;
}
```
This bypasses the wizard's state machine entirely (the user's intent is
"open a modal for free-text input", not "advance the wizard"). When the
user submits the modal, `HandleModalAsync` will run, which already knows
how to map `SystemFreeText``WizardStepNames.System` (line 453-462).
Add a click-through test for at least one of the three steps.
### C-3. `ex.Message` from `CreateSessionHandler` is shipped to the user's Discord
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs:104`
**What's wrong.** On submit failure the submitter edits the draft
message with `$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}."`
and ships it to the user-visible draft embed. The exception originates
in `CreateSessionHandler` which talks to PostgreSQL via Dapper. Postgres
exception messages routinely include the constraint name, the conflicting
key value, and sometimes the full SQL text. Even the connection-string
DSN could leak if an `NpgsqlException` wraps a connection failure. A
malicious user who can submit many sessions can probe DB schema and
state by reading the error strings.
**How to fix.** Log the full `ex` to the server-side log (already done
on line 86) but show the user a generic error:
```csharp
await EditDraftMessageAsync(
draft,
$"💥 Ошибка при создании сессии. Попытка {payload.RetryCount}/{MaxRetries}. "
+ "Попробуйте повторить или обратитесь к администратору.",
RetryCancelActions(),
ct);
```
If you want to preserve a per-error recovery hint (e.g. "Duplicate
title — pick a different name"), map known exception types to localized
strings; never embed the raw `ex.Message`.
---
## Important findings
### I-1. `Owner`/`CoGm` permission lookup runs on every wizard invocation, no cache
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs:85-87`
The slash command issues an `await DiscordPermissionLookup.LoadManagerUserIdsAsync(...)`
on every `/newsession-wizard` invocation. This is a 3-table join that
scales linearly with the number of clubs the user manages. With a 24-hour
draft lifetime and a single draft per owner, the same query repeats
frequently. Not critical for the v3.8.0 release, but a 30-second in-memory
cache would cut DB load noticeably during heavy wizard use. Same query
shape lives in `DiscordNewSessionHandler` already (per the file comment),
so a shared cache would benefit both.
### I-2. `_skip` sentinel bypasses the wizard's own validation
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:191, 207`
**Read against:** `src/GmRelay.Shared/Features/Sessions\CreateSession\Wizard\GameCreationWizard.cs:287-290`
`GameCreationWizard.ApplySystemChoice` matches `"_skip"` and accepts it
without further checks. The renderer emits `wizard:btn:System:_skip`.
This is correct *if* the choice-button wire format gets fixed (C-1); the
"`_skip`" string is hard-coded in the wizard and the renderer uses the
same constant. But there's no central constant — both files have their
own copies of the magic string. A future refactor that renames the
sentinel in one place will silently break the other. Suggest
`public const string SkipSentinel = "_skip"` on a shared class.
### I-3. Smoke tests are string-matching only — no behaviour coverage of the adapter
**File:** `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs`
All 12 smoke tests in this file are `Assert.Contains` against the
source text. They would all pass against a file full of `// choice`
comments and dead code. The test class header at line 8-17 acknowledges
this ("smoke gate"), and the broader Wizard test suite
(`tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/`) does
exercise the platform-neutral state machine — but the *adapter* (the
mapping from `ButtonInteractionContext``_wizard.HandleInteractionAsync`)
has zero behavioural coverage. C-1 and C-2 both bypass the entire
smoke-test surface.
Minimum bar to add: a parser-roundtrip test that takes the renderer's
output for each `RenderX()` step and feeds it through the dispatcher's
button handler to verify it doesn't fall into the default branch. Even
a hand-rolled `ButtonInteractionContext` fake (or a helper that mimics
the dispatcher's `args.Split(':', 4)` parser) would catch both bugs.
Cost: ~50 lines; payoff: catches the entire class of "renderer and
dispatcher disagree on the wire format" regressions.
### I-4. `AddComponentInteractions<TInteraction, TContext>` is called for Modal but the renderer relies on `Label → TextInput` layout
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:436-444`
**Read against:** `DiscordWizardInteractionModule.cs:440-451`
`BuildModal` always wraps a single `TextInput` in a `Label` (Discord's
`IModalComponentProperties` API requires labels). The dispatcher reads
`Components[0]` and assumes it is a `Label` (line 446). This is
consistent *today*, but if a future step needs two inputs in one
modal, the extraction logic needs to walk all components, not just
`[0]`. Document the constraint on the dispatcher's
`ExtractModalText` method ("current contract: exactly one Label,
one TextInput") and the renderer's `BuildModal` ("emits one
Label+TextInput, no exceptions").
### I-5. The 3-retry counter is bound to the in-memory `WizardPayload`, not the DB row
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs:86-89`
`payload.RetryCount += 1; SavePayload(draft, payload);` is called *before*
the `if (payload.RetryCount >= MaxRetries)` check. The counter is
serialized into `draft.PayloadJson` and re-loaded on the next click, so
the bound is correct across bot restarts. However, the in-memory
`draft` object is shared with the dispatcher's `_wizard` after
`HandleInteractionAsync` returns, and a future refactor that pulls the
payload from the DB instead of the in-memory copy could see a stale
count. Document the invariant: "RetryCount is read from the in-memory
payload after this line; do not re-load from DB before the comparison."
### I-6. `BuildResumeRow` re-uses the same customId suffix scheme as the in-wizard buttons
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs:193-209`
`BuildResumeRow` emits three buttons:
- "▶️ Продолжить" → `wizard:btn:resume:continue` ✓ (works)
- "🔄 Заново" → `wizard:btn:resume:restart` ✓ (works)
- "❌ Отмена" → `wizard:btn:cancel:1` (uses `DiscordWizardStep.ButtonCustomId("cancel", "1")`)
The cancel button relies on the dispatcher's `parts[1] == "cancel"`
match. With C-1 fixed, this still works because cancel is in the
switch, not the new "choice" path. But the `ButtonCustomId` signature
will change semantics after C-1: it will become
`$"wizard:btn:choice:{step}:{value}"`. `BuildResumeRow` passing
`"cancel"` as the step will then produce `wizard:btn:choice:cancel:1`,
which the dispatcher's switch will not match (no `"choice"` in the
parts). Fix C-1 must update `BuildResumeRow` to emit
`wizard:btn:cancel:1` directly (not via `ButtonCustomId`).
---
## Nits
- `DiscordWizardInteractionModule.cs:308, 357` — the select and modal
handlers split args with `Split(':', 2)` (max 2 parts), while the
button handler uses `Split(':', 4)` (max 4 parts). Inconsistent. The
net effect is correct (select has 2 segments, modal has 2, button has
24), but a comment explaining the max-count rationale would help.
- `DiscordWizardStep.cs:74``throw new InvalidOperationException` on
unknown step is fine for now, but a future maintainer adding a step
to `WizardStepNames` will get a runtime exception. A `switch` exhaustiveness
check (e.g. a private static assert in tests) would catch this at
build time.
- `DiscordPermissionLookup.cs:28-30``g.platform = 'Discord'` is
hard-coded in the SQL. A `g.platform = @Platform` parameter would
mirror the dispatcher's parameterized style and make the helper
reusable for any future platform. Not blocking.
- `DiscordWizardCommand.cs:72-81` — fetching the guild via REST inside
the slash command costs an extra round-trip. The
`resolvedPermissions` from the interaction already includes
`Administrator`; only the "guild owner" case needs the REST call.
Consider short-circuiting when `(resolvedPermissions & Administrator)
!= 0`.
- `DiscordWizardMessenger.cs:188-192` — hard-coded
`new Color(0x5865F2)` (Discord blurple). Extracting to a const
`WizardEmbedColor` would let the Web/Telegram versions use the same
brand color if they ever render wizards.
---
## Migration V032 sanity check
**File:** `src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql`
- Line 8-9 `ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram'`
— DEFAULT literal makes this O(1) on PostgreSQL ≥ 11. Safe on
existing rows.
- Lines 13-30 — `ALTER COLUMN ... TYPE TEXT USING ...::TEXT` on the
`chat_id`, `message_thread_id`, `draft_message_id`, and renamed
`owner_id` columns. All conversions are lossless
(BIGINT → decimal-string, INT → decimal-string). Safe.
- Line 26-27 `RENAME COLUMN owner_telegram_id TO owner_id` — the
rename happens mid-migration. Any DML hitting the table
concurrently that uses the old name will fail. For a bot that
processes both Telegram and Discord traffic, this is a brief
exclusivity lock. Consider splitting into two migrations
(rename + new index, then type change) so each lock is shorter.
Not blocking for v3.8.0, but document the brief lock window.
No DEFAULT-cascade issue, no NOT NULL on existing-row failure. The
deliverable's "Will this fail on existing rows?" question gets a
"no, but plan a maintenance window" answer.
---
## Architecture sanity (re-confirmed)
- `src/GmRelay.Shared/` — only references to Telegram.Bot/NetCord are
in doc comments warning the developer not to add them. csproj has
no Telegram.Bot or NetCord package references. `GameCreationWizard`,
`IWizardMessenger`, `WizardCallbackData`, `WizardStepLimits`,
`WizardStepNames`, `WizardPayload`, `WizardDraft` are all in exactly
one place (Shared). ✓
- `src/GmRelay.DiscordBot/Program.cs:87-102` — all 7 wizard services
registered as singleton. All 3 `AddComponentInteractions<...>`
calls present (Button, StringMenu, Modal). All 4 module/dispatcher
classes (`WizardInteractionDispatcher`, `DiscordWizardButtonModule`,
`DiscordWizardStringMenuModule`, `DiscordWizardModalModule`)
registered. ✓
- AOT-safety: no `System.Reflection`, no `dynamic`, no
`Activator.CreateInstance`, no `Type.GetType` in the new Discord
or Shared code. ✓
- `DiscordPermissionLookup.cs:23-31` and
`DiscordWizardMessenger.cs:154-165` and the inline
`WizardClubLookup` in `DiscordWizardInteractionModule.cs:508-519`
all use parameterized queries (`@GuildId`, `@Platform`,
`@ExternalId`, `@OwnerId`). No SQL string interpolation. ✓
- `Program.cs:54``SecretRedactor.RedactConnectionString` on the
startup log. ✓
- `DiscordWizardCommand.cs:51-94` — DM invocations rejected
(`GuildId` null check), channel null-checked, member type-checked
via `as GuildInteractionUser`, owner/admin/DB-manager permission
check via `DiscordPermissionChecker.CanManageSchedule`. No NRE
on `Context.User`. ✓
---
## Summary
Strong foundation: the platform-neutral refactor is well-executed, the
state machine has solid test coverage, the Discord adapter's DI graph
is clean, and security primitives (parameterized SQL, permission check,
secret redaction) are in place. But the Discord adapter's runtime path
is untested, and a single oversight in the renderer's button custom-id
format (missing the `choice:` segment) breaks every choice button in
the wizard at click time. The "Другое… ✏️" modal triggers are also
unrouted in the dispatcher, leaving the free-text input path
unreachable. The 3-attempt finalize loop works but leaks `ex.Message`
to the user. After fixing C-1 / C-2 / C-3, adding I-3 (behavioural
test of the adapter), and re-running the manual click-through checklist
(System → Duration → DateTime → Capacity → Visibility → Publish →
Confirm for Single; full pool flow), this branch is ready to merge.
@@ -0,0 +1,69 @@
# 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`.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
# 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,234 +0,0 @@
# Game Catalog and One-Shot Showcase — Design Spec
> Issue #39: feat: добавить каталог игр и витрину ваншотов
---
## Goal
Build a public `/showcase` page that aggregates published sessions from all clubs into a filterable catalog. Users can browse games by system, format, date, and availability. GM controls whether direct registration from the catalog is allowed. The catalog respects existing seat limits and waitlist logic.
---
## Architecture
Extend the existing public-pages infrastructure (V026) with new session metadata fields, a cross-group query layer in `ISessionStore`, and new Razor pages in `GmRelay.Web`. Bot flows (Telegram + Discord) are updated to collect the new fields during session creation. Fuzzy matching on game system names is performed client-side in the bot UI.
---
## Tech Stack
- .NET 10, Blazor Server, Dapper.AOT, Npgsql
- Existing: `PublicLayout`, `ISessionStore`, `SessionService`, `SessionCapacityRules`
- New: `GameSystem` enum, `ShowcaseFilter` record, `ShowcaseSessionDto`
---
## Data Model
### New Fields on `sessions` (Migration V027)
| Column | Type | Constraints | Description |
|---|---|---|---|
| `is_one_shot` | `BOOLEAN` | `NOT NULL DEFAULT false` | One-shot or campaign |
| `system` | `VARCHAR(50)` | nullable | Game system name (enum value or custom) |
| `description` | `TEXT` | nullable | Short description for card |
| `cover_image_url` | `TEXT` | nullable | Cover image URL |
| `duration_minutes` | `INTEGER` | nullable | Duration in minutes |
| `format` | `VARCHAR(20)` | `CHECK (format IN ('Online','Offline','Hybrid'))`, nullable | Session format |
| `allow_direct_registration` | `BOOLEAN` | `NOT NULL DEFAULT false` | Allow direct registration from showcase |
### `GameSystem` Enum
```csharp
public enum GameSystem
{
Dnd5e, Pathfinder2e, CallOfCthulhu7e, Shadowdark,
OldSchoolEssentials, Dragonbane, BladesInTheDark,
Daggerheart, CyberpunkRed, Mothership, AlienRpg,
WarhammerFantasy, VampireMasquerade5e, StarWarsFfg,
Genesys, SavageWorlds, GURPS, Fate, DungeonWorld,
Ironsworn, Other
}
```
Stored as `VARCHAR(50)` in DB (not native enum) to allow future extension without migration.
### DTOs
```csharp
public sealed record ShowcaseSessionDto(
Guid Id,
Guid GroupId,
string GroupName,
string? GroupSlug,
string Title,
DateTime ScheduledAt,
string Status,
string? System,
bool IsOneShot,
string? Format,
int? DurationMinutes,
string? CoverImageUrl,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
bool AllowDirectRegistration);
public sealed record ShowcaseFilter(
DateFilter Date = DateFilter.All,
SeatFilter Seats = SeatFilter.Any,
GameSystem? System = null,
bool? IsOneShot = null,
string? Format = null);
public enum DateFilter { Today, Tomorrow, ThisWeek, All }
public enum SeatFilter { Available, Waitlist, Any }
```
---
## UI Design
### `/showcase` — Catalog Page
**Layout:**
- Hero with title "Каталог игр"
- Sticky filter bar (horizontal on desktop, collapsible on mobile)
- Responsive grid of session cards (1 col mobile, 2 col tablet, 3 col desktop)
- Pagination (page + pageSize = 12)
**Filters:**
- Date: "Сегодня" | "Завтра" | "На неделю" | "Все"
- Seats: "Есть места" | "Waitlist" | "Любое"
- System: dropdown with all `GameSystem` values
- Type: "Ваншот" | "Кампания" | "Любое"
- Format: "Онлайн" | "Офлайн" | "Гибрид" | "Любое"
**Card Design:**
- Cover image (fallback: colored placeholder with initials)
- Title
- System badge
- Date + time (MSK)
- Duration (e.g. "3 часа")
- Format badge
- Seats indicator: "5/6 мест" | "Waitlist (3)" | "Мест нет"
- Club name (link to `/club/{slug}`)
- Buttons: "Подробнее" → `/s/{id}`, "Записаться" (if `AllowDirectRegistration`)
### `/s/{id}` — Public Session Detail (Updated)
New fields added to existing page:
- Cover image (full-width hero)
- System badge
- Description block
- Duration + format
- GM contact (always visible: Telegram username or Discord tag)
- If `allow_direct_registration`:
- "Записаться" button → Telegram Mini App deeplink or Discord OAuth
- Direct registration into `session_participants` via `SessionCapacityRules`
---
## Backend
### ISessionStore Methods
```csharp
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(
ShowcaseFilter filter, int page, int pageSize);
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, PlatformUser user);
```
`GetShowcaseSessionsAsync` query:
- Cross-group (all clubs with `public_schedule_enabled = true`)
- Only `is_public = true` sessions
- `scheduled_at > now() - interval '4 hours'`
- `status <> 'Cancelled'`
- Apply filters in SQL WHERE clause
- Order by `scheduled_at ASC`
- Offset/limit pagination
`RegisterFromShowcaseAsync`:
- Check `allow_direct_registration = true`
- Load session with `FOR UPDATE`
- Count active + waitlisted participants
- Use `SessionCapacityRules.DecideJoinStatus`
- Insert participant with appropriate `registration_status`
- Return true on success, false if full and no waitlist allowed
---
## Bot Integration
### Telegram Bot
During `CreateSessionCommand` flow, after title/link/time input:
1. "Выберите систему:" inline keyboard with `GameSystem` values + "Другое"
2. If text input instead of button: fuzzy match against display names (Levenshtein/Contains/StartsWith)
3. "Описание игры (краткое):" — text input, optional (skip button)
4. "Формат:" inline keyboard — "Онлайн" | "Офлайн" | "Гибрид"
5. "Продолжительность (в часах):" — int input, optional
6. "Обложка (URL или пропустить):" — text input, optional
During `/publish` flow:
- "Разрешить прямую запись из каталога?" — yes/no toggle (default: no)
### Discord Bot
Same flow adapted for Discord interactions:
- Slash command options or button menus for system/format
- Modal input for description, duration, cover URL
- Fuzzy matching on free-text system input
---
## Migration V027
```sql
ALTER TABLE sessions
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN system VARCHAR(50),
ADD COLUMN description TEXT,
ADD COLUMN cover_image_url TEXT,
ADD COLUMN duration_minutes INTEGER,
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online','Offline','Hybrid')),
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX ix_sessions_showcase
ON sessions (scheduled_at, system, is_one_shot, format)
WHERE is_public = true AND status <> 'Cancelled';
```
---
## Testing Strategy
1. **Unit tests:** `SessionCapacityRules` with showcase registration scenarios
2. **Integration tests:** `GetShowcaseSessionsAsync` with each filter combination
3. **UI tests:** `Showcase.razor` rendering with/without cover images, filters applied
4. **Bot tests:** Fuzzy matching algorithm for `GameSystem` resolution
---
## Version Bump
Issue label: `type:feature`**minor bump**
Current: `3.3.0` → Next: `3.4.0`
Files to sync:
- `Directory.Build.props`
- `compose.yaml` (bot, discord, web image tags)
- `.gitea/workflows/deploy.yml` (`VERSION` env)
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
---
## Acceptance Criteria (from Issue #39)
- [ ] User can find a published game without accessing a private dashboard
- [ ] Registration does not bypass existing seat/waitlist limits
- [ ] Owner/co-GM controls what appears in the showcase via `is_public` + `allow_direct_registration`
- [ ] Filters work: date, seats, system, type, format
- [ ] GM contact is always visible on public session detail
- [ ] Direct registration respects `SessionCapacityRules`
@@ -1,424 +0,0 @@
# Completed Game Portfolio - Design Spec
> Issue #108: feat: добавить портфолио прошедших игр в витрину мастера
---
## Goal
Add a public portfolio of completed tabletop adventures. A club owner or co-GM can group one or more completed sessions into an adventure card, publish it in selected GM profiles, optionally show it on a public club page, upload a cover image, and moderate player reviews. The existing `/showcase` catalog remains focused on recruitment for upcoming games.
---
## Product Decisions
- A portfolio item is an independent adventure entity, not a flag on one session.
- One adventure can reference multiple completed sessions from the same club.
- Reviews are submitted by authenticated players, not entered manually by a GM.
- A player can review an adventure after being actively registered as a non-GM participant for at least one linked completed session. Waitlisted players are not eligible.
- Each player can submit one review per adventure.
- A review is public only after the player explicitly consents to publication and a club owner or co-GM approves it.
- Public reviews show a display-name snapshot captured at submission time. They never expose platform IDs or account links.
- Adventure visibility in a public GM profile does not depend on club-page visibility.
- The public club page shows its portfolio block only when that club page is enabled.
- Club owners and co-GMs create, edit, publish, and moderate portfolio items. They select one or more GMs whose public profiles display the adventure.
- Creation is available from the club page and through a quick action from a completed session.
- Every published adventure has a dedicated public page at `/portfolio/{slug}`.
- Cover images are uploaded to application-managed storage. The first implementation uses a persistent Docker volume behind a replaceable storage interface so an S3-compatible implementation can be added later without changing pages or database tables.
---
## Architecture
Add a bounded portfolio vertical slice to `GmRelay.Web` and a schema migration in `GmRelay.Bot`. The portfolio tables reference the existing `game_groups`, `players`, and `sessions` tables but do not change the recruitment catalog query or its future-session filters.
Keep portfolio persistence separate from the already large scheduling store. `IPortfolioStore` and `PortfolioService` own portfolio reads, writes, and review submission. `AuthorizedPortfolioService` wraps protected management operations and reuses `ISessionStore.IsGroupManagerAsync` plus the existing current-user identity model for owner/co-GM authorization. Public Razor pages inject `IPortfolioStore` directly for sanitized reads.
Cover storage is isolated behind `IPortfolioCoverStorage`. Pages and services work with generated storage keys and public paths rather than physical file locations. The local implementation stores files in a persistent mounted directory and serves them through a dedicated request path. A future S3 implementation can generate equivalent public paths or signed delivery URLs while preserving the same service contract and database fields.
---
## Data Model
### Migration V029
Create `src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql`.
### `portfolio_games`
| Column | Type | Constraints | Description |
|---|---|---|---|
| `id` | `UUID` | primary key, generated | Adventure identifier |
| `group_id` | `UUID` | not null, FK to `game_groups(id)` with cascade delete | Owning club |
| `public_slug` | `VARCHAR(160)` | unique case-insensitive when non-null | Public route segment |
| `title` | `VARCHAR(255)` | not null | Adventure title |
| `description` | `TEXT` | nullable for drafts | Public description |
| `cover_storage_key` | `TEXT` | nullable for drafts | Storage-provider-neutral cover key |
| `system` | `VARCHAR(50)` | nullable | Game system |
| `format` | `VARCHAR(20)` | nullable, checked against `Online`, `Offline`, `Hybrid` | Play format |
| `completed_at` | `TIMESTAMPTZ` | not null | Portfolio ordering date |
| `is_public` | `BOOLEAN` | not null, default false | Public visibility |
| `published_at` | `TIMESTAMPTZ` | nullable | First publication timestamp |
| `created_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
| `updated_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
Constraints and indexes:
```sql
CHECK (NOT is_public OR (
public_slug IS NOT NULL
AND description IS NOT NULL
AND cover_storage_key IS NOT NULL
AND published_at IS NOT NULL
))
```
- Unique index on `lower(public_slug)` when `public_slug IS NOT NULL`.
- Index on `(group_id, completed_at DESC)`.
- Partial public index on `(completed_at DESC)` where `is_public = true`.
Application validation additionally requires at least one linked session, every linked session to be completed with `scheduled_at < now()`, and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links.
Immediate statement triggers acquire one transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`, before any invariant-affecting rows are changed: publication transitions and deletes, required-link edits, session deletes and scheduled-date changes, and parent deletes that can cascade into required links. Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Portfolio and schedule mutations are low volume, so this intentionally global lock establishes one advisory-lock then row-lock protocol, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card, card/advisory, and session/advisory deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations.
A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. It rejects final-future reschedules outside `READ COMMITTED` with `0A000`, because the unpublish pass requires fresh statement snapshots. Under `READ COMMITTED`, it takes row locks for all cards linked to any final-future session in `portfolio_games.id` order, including committed drafts. It then re-acquires the publication advisory lock and unpublishes matching public cards in a guarded update with a fresh statement snapshot. Including drafts prevents a concurrent draft-to-public publication from validating against the pre-reschedule session snapshot and committing afterward. Session mutation paths use advisory-lock then `sessions` then linked `portfolio_games`; normal session-deletion handlers explicitly acquire the mutation lock, lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless.
### `portfolio_game_sessions`
| Column | Type | Constraints | Description |
|---|---|---|---|
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
| `session_id` | `UUID` | not null, unique, FK to `sessions(id)` with cascade delete | Linked completed session |
Primary key: `(portfolio_game_id, session_id)`.
The application accepts only sessions from the adventure's club with `scheduled_at < now()` and rejects cross-club links. The deferred database guard enforces the completed-session condition for every linked session before a public card can commit. A session belongs to at most one portfolio adventure.
### `portfolio_game_masters`
| Column | Type | Constraints | Description |
|---|---|---|---|
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
| `player_id` | `UUID` | not null, FK to `players(id)` with cascade delete | Displayed GM |
Primary key: `(portfolio_game_id, player_id)`.
Add an index on `(player_id, portfolio_game_id)` for public GM profile reads.
### `portfolio_game_reviews`
| Column | Type | Constraints | Description |
|---|---|---|---|
| `id` | `UUID` | primary key, generated | Review identifier |
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
| `author_player_id` | `UUID` | not null, FK to `players(id)` with cascade delete | Private author reference |
| `author_display_name` | `VARCHAR(255)` | not null | Public snapshot |
| `body` | `TEXT` | not null | Review text |
| `publication_consent_at` | `TIMESTAMPTZ` | not null | Player consent timestamp |
| `moderation_status` | `VARCHAR(20)` | not null, default `Pending`, checked | Moderation state |
| `moderated_by_player_id` | `UUID` | nullable, FK to `players(id)` with set null on delete | Private moderator reference |
| `moderated_at` | `TIMESTAMPTZ` | nullable | Moderation timestamp |
| `created_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
| `updated_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
Constraints and indexes:
```sql
CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden'))
UNIQUE (portfolio_game_id, author_player_id)
```
- Author lookup index `ix_portfolio_game_reviews_author` on `(author_player_id)`.
- Partial moderator lookup index `ix_portfolio_game_reviews_moderator` on `(moderated_by_player_id)` where `moderated_by_player_id IS NOT NULL`.
- Partial public index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Approved'` and `publication_consent_at IS NOT NULL`.
- Partial moderation index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Pending'`.
---
## Cover Storage
### Contract
Add a small storage abstraction:
```csharp
public interface IPortfolioCoverStorage
{
Task<PortfolioCoverUploadResult> SaveAsync(
Stream content,
string contentType,
CancellationToken cancellationToken = default);
Task DeleteIfExistsAsync(
string storageKey,
CancellationToken cancellationToken = default);
string GetPublicPath(string storageKey);
}
```
`PortfolioCoverUploadResult` carries the generated storage key and normalized content type.
### Local Implementation
- Store covers below a configured `PortfolioCovers:StoragePath`.
- Mount that path from a dedicated Docker volume, `portfolio_covers`.
- Serve files through a dedicated `/portfolio-covers/{storageKey}` route.
- Generate random names. Never use the uploaded filename as the storage key.
- Accept `image/jpeg`, `image/png`, and `image/webp`.
- Limit uploads to 5 MiB.
- Validate file signatures server-side before writing the final file.
- Write to a temporary file, validate, then atomically move into place.
- On successful replacement, delete the old file.
- On database failure after upload, delete the newly uploaded file.
- Deleting an adventure deletes its current cover after successful database deletion.
The storage key remains provider-neutral. A future S3-compatible implementation can replace the local service registration and use the same stored key.
---
## Service Contracts
Add sanitized DTOs to `IPortfolioStore`. Public DTOs must not expose player IDs, group IDs, session IDs, platform identifiers, moderator IDs, physical storage paths, or join links.
Representative contracts:
```csharp
public sealed record PublicPortfolioGame(
string Slug,
string Title,
string Description,
string CoverPath,
string? System,
string? Format,
DateTime CompletedAt,
string? ClubName,
string? ClubSlug,
IReadOnlyList<PublicPortfolioMaster> Masters,
IReadOnlyList<PublicPortfolioReview> Reviews);
public sealed record PublicPortfolioMaster(string Slug, string DisplayName);
public sealed record PublicPortfolioReview(
string AuthorDisplayName,
string Body,
DateTime CreatedAt);
```
Protected DTOs may carry IDs needed for editing and moderation.
### Public Reads
- Load one public adventure by slug for `/portfolio/{slug}`.
- Load public adventures for a public GM profile regardless of club-page visibility.
- Load public adventures for a public club page only when the club page is enabled.
- Return only reviews with explicit consent and `Approved` moderation state.
### Protected Management
Through `AuthorizedPortfolioService`:
- Load draft and published adventure cards for a managed club.
- Load eligible completed sessions for a managed club.
- Create a draft, optionally preselecting one completed session from the quick action.
- Update title, slug, description, system, format, linked sessions, and displayed GMs.
- Upload and replace the cover.
- Publish or unpublish a card.
- Load pending and historical reviews for moderation.
- Approve, reject, or hide a review.
All management operations require the current user to be an owner or co-GM of the owning club.
### Review Submission
An authenticated user can submit a review from `/portfolio/{slug}` only when:
- The adventure is public.
- The user explicitly checks publication consent.
- The user is registered in `session_participants` as a non-GM participant with `registration_status = 'Active'` for at least one linked session.
- The linked session is in the past.
- The user has not submitted a review for this adventure before.
The created review starts in `Pending`. The public page does not display it until moderation changes the status to `Approved`.
---
## User Interface
### Protected Club Page
Extend `GroupDetails.razor` with a completed-adventures section:
- List draft and published portfolio cards.
- Show title, publication state, linked-session count, displayed-GM count, and review moderation count.
- Provide a create action, edit links, and a link to the club's completed-session list.
### Completed Session Quick Action
Add a protected `/group/{groupId}/completed` page that lists past sessions for a managed club. Extend that page and session history with an "Добавить в портфолио" action for a completed session that is not already linked. The action opens the adventure editor with that session preselected.
### Adventure Editor
Add a protected editor page:
- Title and public slug.
- Description.
- System and format.
- Multi-select of completed sessions from the same club.
- Multi-select of displayed GMs.
- Cover upload and replacement.
- Draft save and publish/unpublish actions.
- Review moderation list with approve, reject, and hide actions.
The editor surfaces validation errors without publishing partial data.
### Public GM Profile
Extend `/gm/{slug}` with a "Проведённые приключения" portfolio section. Cards show cover, title, completion date, system, format, and a link to `/portfolio/{slug}`. This list is independent of club-page visibility.
### Public Club Page
Extend `/club/{slug}` with the same compact cards when the public club page is enabled.
### Public Adventure Page
Add `/portfolio/{slug}`:
- Cover hero.
- Title, description, completion date, system, and format.
- Optional public club link.
- Public links to selected GM profiles.
- Approved reviews with display-name snapshots.
- For an eligible authenticated player without an existing review: review form with text area and required publication-consent checkbox.
- For an authenticated ineligible player or a player who already submitted: a short non-sensitive status message.
- For an anonymous visitor: a sign-in prompt instead of the form.
---
## Privacy And Security
- Public DTOs and rendered HTML never expose platform identifiers, player IDs, moderator IDs, linked session IDs, join links, or physical storage paths.
- Cover upload validation uses content signatures, not only the browser-provided MIME type or filename.
- Random storage keys prevent filename guessing and path traversal.
- Review text is rendered as encoded text through normal Razor rendering.
- Authorization is checked in the service layer for every management operation.
- Eligibility is checked in the database-backed service when submitting a review; hiding the form is not treated as authorization.
- The `/showcase` query keeps its current future-session condition and does not include completed adventures.
---
## Docker And Configuration
Add:
```yaml
services:
discord:
depends_on:
bot:
condition: service_healthy
web:
depends_on:
bot:
condition: service_healthy
environment:
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
volumes:
- portfolio_covers:/app/portfolio-covers
volumes:
portfolio_covers:
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
```
Development configuration uses a local directory under the application content root or an explicitly configured path.
The Web Docker image creates `/app/portfolio-covers` and assigns it to `$APP_UID` before switching to the non-root runtime user.
The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. The Aspire AppHost mirrors this readiness gate with database resource name `gmrelaydb`, matching application `ConnectionStrings:gmrelaydb`; it explicitly exposes the bot project resource's non-proxied port `8081` endpoint, attaches `.WithHttpHealthCheck("/health", endpointName: "health")`, and makes its `discord` and `web` project resources wait for both PostgreSQL and the healthy `bot` resource.
---
## Documentation
Update:
- `README.md` with public portfolio capability and local cover-storage configuration.
- `docs/c4-system-context.md` with the portfolio slice and persistent cover volume.
---
## Testing Strategy
Follow TDD for production changes.
### Schema And Contracts
- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, immediate statement-level mutation locks, completed-session validator, deferred future-reschedule unpublish trigger, advisory-lock then session-row deletion locks, and the AppHost HTTP health gate.
- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit mutation-lock then session-lock then unpublish then session deletion, delete/reschedule mutation-gate ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, `past -> future -> past` final-state handling, required-link insertion and final-future reschedule mutation locks before rows, opposing-order batch future reschedules serialized before session rows, existing-link and new-link draft publication/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders and stale-snapshot final-future reschedules, and parent/card cascade deletion.
- Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent.
- Existing showcase tests continue to assert the future-session catalog boundary.
### Authorization And Eligibility
- Owner and co-GM can manage a club adventure.
- A manager of another club cannot manage it.
- Only registered players from linked past sessions can submit.
- A registered player can submit only once.
- Consent is required.
- A new review is pending and not public.
- Only approved reviews are returned publicly.
### Cover Storage
- Accept valid JPEG, PNG, and WebP signatures.
- Reject unsupported types, mismatched signatures, oversized files, and unsafe names.
- Replacement deletes the old file only after the new file is stored.
- Cleanup removes a newly uploaded file when persistence fails.
### UI Source Contracts
- Protected club and session-history pages expose management entry points.
- Public GM and club pages render compact portfolio sections.
- The public adventure page renders approved reviews and the conditional review form.
- CSS defines responsive portfolio cards, cover hero, editor layout, and review states.
### Regression
- Run the full test suite.
- Run `dotnet build`.
- Run `dotnet format --verify-no-changes`.
- Visually inspect the protected editor and public portfolio pages in the browser.
---
## Version Bump
Issue label: `type:feature` -> minor bump.
Current: `3.5.1` -> Next: `3.6.0`.
Synchronize:
- `Directory.Build.props`
- `compose.yaml` (`bot`, `discord`, and `web` image tags)
- `.gitea/workflows/deploy.yml` (`VERSION`)
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
---
## Acceptance Criteria Mapping
- [ ] A club owner or co-GM can publish a completed adventure with uploaded cover and description.
- [ ] A portfolio adventure can group one or more completed sessions from the same club.
- [ ] A public portfolio adventure automatically becomes private if any linked completed session is rescheduled into the future, preserving its first-publication timestamp.
- [ ] Selected public GM profiles show portfolio cards independently of club-page visibility.
- [ ] A public club page shows portfolio cards when enabled.
- [ ] `/portfolio/{slug}` shows cover, description, metadata, selected GMs, and approved player reviews.
- [ ] A registered participant of a linked completed session can submit one review with explicit publication consent.
- [ ] Reviews remain non-public until owner/co-GM moderation approves them.
- [ ] Public DTOs and HTML do not expose private identifiers.
- [ ] Uploaded covers survive container replacement through a persistent Docker volume.
- [ ] Storage is isolated behind a replaceable interface for a later S3-compatible implementation.
- [ ] The existing `/showcase` catalog remains focused on upcoming recruitment games.
-77
View File
@@ -1,77 +0,0 @@
#!/usr/bin/env bash
# GM-Relay PostgreSQL Backup Restore Script
# Usage: ./scripts/restore.sh [backup_file]
# If no file is provided, uses the most recent backup.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Check required env
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
if [ -f "${PROJECT_ROOT}/.env" ]; then
# shellcheck source=/dev/null
set -a
. "${PROJECT_ROOT}/.env"
set +a
fi
fi
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
echo "ERROR: POSTGRES_PASSWORD is not set. Please set it in your environment or .env file."
exit 1
fi
BACKUP_DIR="${PROJECT_ROOT}/backups"
# Determine backup file
if [ $# -ge 1 ]; then
BACKUP_FILE="$1"
else
BACKUP_FILE=$(find "${BACKUP_DIR}" -name 'gmrelay_db_*.sql.gz' -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n1 | cut -d' ' -f2-)
if [ -z "${BACKUP_FILE}" ]; then
echo "ERROR: No backup files found in ${BACKUP_DIR}."
exit 1
fi
fi
if [ ! -f "${BACKUP_FILE}" ]; then
echo "ERROR: Backup file not found: ${BACKUP_FILE}"
exit 1
fi
echo "=================================================="
echo " GM-Relay PostgreSQL Restore"
echo "=================================================="
echo ""
echo "Backup file: ${BACKUP_FILE}"
echo "Database: gmrelay_db"
echo "User: gmrelay"
echo ""
read -p "This will OVERWRITE the current database. Are you sure? [y/N] " CONFIRM
if [[ ! "${CONFIRM}" =~ ^[Yy]$ ]]; then
echo "Restore cancelled."
exit 0
fi
echo ""
echo "Restoring database from ${BACKUP_FILE}..."
# Restore using docker compose exec to leverage the running postgres container
COMPOSE_ARGS="-f ${PROJECT_ROOT}/compose.yaml"
docker compose ${COMPOSE_ARGS} exec -T -e PGPASSWORD="${POSTGRES_PASSWORD}" db psql \
-U gmrelay \
-d gmrelay_db \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" 2>/dev/null || true
gunzip -c "${BACKUP_FILE}" | docker compose ${COMPOSE_ARGS} exec -T -e PGPASSWORD="${POSTGRES_PASSWORD}" db psql \
-U gmrelay \
-d gmrelay_db
echo ""
echo "=================================================="
echo " Restore completed successfully!"
echo "=================================================="
@@ -2,7 +2,6 @@
<ItemGroup>
<ProjectReference Include="..\GmRelay.Bot\GmRelay.Bot.csproj" />
<ProjectReference Include="..\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
<ProjectReference Include="..\GmRelay.Web\GmRelay.Web.csproj" />
</ItemGroup>
+4 -12
View File
@@ -2,22 +2,14 @@ var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
.WithPgAdmin()
.AddDatabase("gmrelaydb");
.AddDatabase("gmrelay-db");
var bot = builder.AddProject<Projects.GmRelay_Bot>("bot")
builder.AddProject<Projects.GmRelay_Bot>("bot")
.WithReference(postgres)
.WaitFor(postgres)
.WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health", isProxied: false)
.WithHttpHealthCheck("/health", endpointName: "health");
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
.WithReference(postgres)
.WaitFor(postgres)
.WaitFor(bot);
.WaitFor(postgres);
builder.AddProject<Projects.GmRelay_Web>("web")
.WithReference(postgres)
.WaitFor(postgres)
.WaitFor(bot);
.WaitFor(postgres);
builder.Build().Run();
-687
View File
@@ -1,687 +0,0 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Aspire.Dashboard.Sdk.win-x64": {
"type": "Direct",
"requested": "[13.2.1, )",
"resolved": "13.2.1",
"contentHash": "KLB9rXwY8kg2taWwxsJFoK0cAuupSZurcv1zTyYMqLyNuwvYYjs65Yz3g/cgh22QlUfOT3tOh+Jzk5MdJhy5+w=="
},
"Aspire.Hosting.AppHost": {
"type": "Direct",
"requested": "[13.2.1, )",
"resolved": "13.2.1",
"contentHash": "4B/eoZPwOobxpMpvYnqe/EcXabjPhZJhfxlHXv5gdKd16duoWbHnvvAZJsVI3WUpakCwmsCiTrT4sNGfW8H+IQ==",
"dependencies": {
"AspNetCore.HealthChecks.Uris": "9.0.0",
"Aspire.Hosting": "13.2.1",
"Google.Protobuf": "3.33.5",
"Grpc.AspNetCore": "2.76.0",
"Grpc.Net.ClientFactory": "2.76.0",
"Grpc.Tools": "2.78.0",
"Humanizer.Core": "2.14.1",
"JsonPatch.Net": "3.3.0",
"KubernetesClient": "18.0.13",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.HealthChecks": "10.0.5",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Hosting": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Http": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5",
"ModelContextProtocol": "1.0.0",
"Newtonsoft.Json": "13.0.4",
"Polly.Core": "8.6.5",
"Semver": "3.0.0",
"StreamJsonRpc": "2.22.23",
"System.IO.Hashing": "10.0.3"
}
},
"Aspire.Hosting.Orchestration.win-x64": {
"type": "Direct",
"requested": "[13.2.1, )",
"resolved": "13.2.1",
"contentHash": "39lRUH4WuCsBaYB7fZH1/r81SSJIXrA8WphBlAdP1QT95+1sKQHzXJuXU4nzKpBLv4oZmjcWzvA+FDMGZbWmkw=="
},
"Aspire.Hosting.PostgreSQL": {
"type": "Direct",
"requested": "[13.2.1, )",
"resolved": "13.2.1",
"contentHash": "7F/nmeplR9cYE/B/E1haRjnkoBRQ/voMXpnK/SNJoXSFs4Vb/g00CDDvI/xfH3SAV7Xq8ekWa9ZbX56JuQ+YiA==",
"dependencies": {
"AspNetCore.HealthChecks.NpgSql": "9.0.0",
"AspNetCore.HealthChecks.Uris": "9.0.0",
"Aspire.Hosting": "13.2.1",
"Google.Protobuf": "3.33.5",
"Grpc.AspNetCore": "2.76.0",
"Grpc.Net.ClientFactory": "2.76.0",
"Grpc.Tools": "2.78.0",
"Humanizer.Core": "2.14.1",
"JsonPatch.Net": "3.3.0",
"KubernetesClient": "18.0.13",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.25",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Hosting": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Http": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5",
"ModelContextProtocol": "1.0.0",
"Newtonsoft.Json": "13.0.4",
"Polly.Core": "8.6.5",
"Semver": "3.0.0",
"StreamJsonRpc": "2.22.23",
"System.IO.Hashing": "10.0.3"
}
},
"SecurityCodeScan.VS2019": {
"type": "Direct",
"requested": "[5.6.7, )",
"resolved": "5.6.7",
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
},
"Aspire.Hosting": {
"type": "Transitive",
"resolved": "13.2.1",
"contentHash": "GY/T5iK2F4K3Sk60VUeVnTX1MhCjSaX48+qPUjA/rI1x1ONHevHzFj+Gc3fNlGEaZGY8L87hSxwGrV+Bjd5EJw==",
"dependencies": {
"AspNetCore.HealthChecks.Uris": "9.0.0",
"Google.Protobuf": "3.33.5",
"Grpc.AspNetCore": "2.76.0",
"Grpc.Net.ClientFactory": "2.76.0",
"Grpc.Tools": "2.78.0",
"Humanizer.Core": "2.14.1",
"JsonPatch.Net": "3.3.0",
"KubernetesClient": "18.0.13",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.25",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Hosting": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Http": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5",
"ModelContextProtocol": "1.0.0",
"Newtonsoft.Json": "13.0.4",
"Polly.Core": "8.6.5",
"Semver": "3.0.0",
"StreamJsonRpc": "2.22.23",
"System.IO.Hashing": "10.0.3"
}
},
"AspNetCore.HealthChecks.NpgSql": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11",
"Npgsql": "8.0.3"
}
},
"AspNetCore.HealthChecks.Uris": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "XYdNlA437KeF8p9qOpZFyNqAN+c0FXt/JjTvzH/Qans0q0O3pPE8KPnn39ucQQjR/Roum1vLTP3kXiUs8VHyuA==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11",
"Microsoft.Extensions.Http": "8.0.0"
}
},
"Fractions": {
"type": "Transitive",
"resolved": "7.3.0",
"contentHash": "2bETFWLBc8b7Ut2SVi+bxhGVwiSpknHYGBh2PADyGWONLkTxT7bKyDRhF8ao+XUv90tq8Fl7GTPxSI5bacIRJw=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.33.5",
"contentHash": "XEzLpCTosZb5I6eGSPn7rAES0VfkJkn3Cqydh0W39POdZwkdhPhOmAROTFJF9g0ardst4ulNXRm/q/iXwNu+Qw=="
},
"Grpc.AspNetCore": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "LyXMmpN2Ba0TE35SOLSKbGqIYtJuhc1UgiaGfoW1X8KJERV70QI5KGW+ckEY7MrXoFWN/uWo4B70siVhbDmCgQ==",
"dependencies": {
"Google.Protobuf": "3.31.1",
"Grpc.AspNetCore.Server.ClientFactory": "2.76.0",
"Grpc.Tools": "2.76.0"
}
},
"Grpc.AspNetCore.Server": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "diSC/ZeNdSdxHdYSOpYwuSBBDYpuNVtJQFJfiBB0WrYOQ4lVMmdxuUZJcViahQyo8pCvS3Mueo5lqFxwwMF/iw==",
"dependencies": {
"Grpc.Net.Common": "2.76.0"
}
},
"Grpc.AspNetCore.Server.ClientFactory": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "y5KGO1GO0N2L/hCCMR05mmoK8j+v8rKvZ+9nothAxKx2Tf2CwV8f4TM5K0GkKfDsp4vrc4lm90MU6E+DeN7YIw==",
"dependencies": {
"Grpc.AspNetCore.Server": "2.76.0",
"Grpc.Net.ClientFactory": "2.76.0"
}
},
"Grpc.Core.Api": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "cSxC2tdnFdXXuBgIn1pjc4YBx7LXTCp4M0qn+SMBS35VWZY+cEQYLWTBDDhdBH1HzU7BV+ncVZlniGQHMpRJKQ=="
},
"Grpc.Net.Client": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==",
"dependencies": {
"Grpc.Net.Common": "2.76.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
}
},
"Grpc.Net.ClientFactory": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "XI+kO69L9AV8B9N0UQOmH911r6MOEp9huHiavEsY56DJYuzJ9KAxNGy37dpV6CLbgCaN2uKmpOsZ9Pao6bmpVQ==",
"dependencies": {
"Grpc.Net.Client": "2.76.0",
"Microsoft.Extensions.Http": "8.0.0"
}
},
"Grpc.Net.Common": {
"type": "Transitive",
"resolved": "2.76.0",
"contentHash": "bZpiMVYgvpB44/wBh1RotrkqC7bg2FOasLri2GhR3hMKyzsiTxCoDE49YjPrJeFc4RW0wS8u+EInI09sjxVFRA==",
"dependencies": {
"Grpc.Core.Api": "2.76.0"
}
},
"Grpc.Tools": {
"type": "Transitive",
"resolved": "2.78.0",
"contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg=="
},
"Humanizer.Core": {
"type": "Transitive",
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"Json.More.Net": {
"type": "Transitive",
"resolved": "2.1.0",
"contentHash": "qtwsyAsL55y2vB2/sK4Pjg3ZyVzD5KKSpV3lOAMHlnjFfsjQ/86eHJfQT9aV1YysVXzF4+xyHOZbh7Iu3YQ7Lg=="
},
"JsonPatch.Net": {
"type": "Transitive",
"resolved": "3.3.0",
"contentHash": "GIcMMDtzfzVfIpQgey8w7dhzcw6jG5nD4DDAdQCTmHfblkCvN7mI8K03to8YyUhKMl4PTR6D6nLSvWmyOGFNTg==",
"dependencies": {
"JsonPointer.Net": "5.2.0"
}
},
"JsonPointer.Net": {
"type": "Transitive",
"resolved": "5.2.0",
"contentHash": "qe1F7Tr/p4mgwLPU9P60MbYkp+xnL2uCPnWXGgzfR/AZCunAZIC0RZ32dLGJJEhSuLEfm0YF/1R3u5C7mEVq+w==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Json.More.Net": "2.1.0"
}
},
"KubernetesClient": {
"type": "Transitive",
"resolved": "18.0.13",
"contentHash": "X5IuxmydftB148XeULtc7rD5/RvqLuW5SzkIjFovPgJpvV4RAoRqNPruVB7GEFu1Xg+zHVIk88WqdV8JjbgHbA==",
"dependencies": {
"Fractions": "7.3.0",
"YamlDotNet": "16.3.0"
}
},
"MessagePack": {
"type": "Transitive",
"resolved": "2.5.192",
"contentHash": "Jtle5MaFeIFkdXtxQeL9Tu2Y3HsAQGoSntOzrn6Br/jrl6c8QmG22GEioT5HBtZJR0zw0s46OnKU8ei2M3QifA==",
"dependencies": {
"MessagePack.Annotations": "2.5.192",
"Microsoft.NET.StringTools": "17.6.3"
}
},
"MessagePack.Annotations": {
"type": "Transitive",
"resolved": "2.5.192",
"contentHash": "jaJuwcgovWIZ8Zysdyf3b7b34/BrADw4v82GaEZymUhDd3ScMPrYd/cttekeDteJJPXseJxp04yTIcxiVUjTWg=="
},
"Microsoft.Extensions.AI.Abstractions": {
"type": "Transitive",
"resolved": "10.3.0",
"contentHash": "hDjDvUERvUH3HBMs2MDusOcGJBjAHOG5pJIU2x/HZEa4e1UthNKt89cwMi3B+ogJo6skki1XFjfgGN3ksnVqvQ=="
},
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "10.0.3",
"contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.3"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"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.Configuration.Binder": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.CommandLine": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "or9fOLopMUTJOQVJ3bou4aD6PwvsiKf4kZC4EE5sRRKSkmh+wfk/LekJXRjAX88X+1JA9zHjDo+5fiQ7z3MY/A==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.EnvironmentVariables": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "tchMGQ+zVTO40np/Zzg2Li/TIR8bksQgg4UVXZa0OzeFCKWnIYtxE2FVs+eSmjPGCjMS2voZbwN/mUcYfpSTuA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "OhTr0O79dP49734lLTqVveivVX9sDXxbI/8vjELAZTHXqoN90mdpgTAgwicJED42iaHMCcZcK6Bj+8wNyBikaw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "brBM/WP0YAUYh2+QqSYVdK8eQHYQTtTEUJXJ+84Zkdo2buGLja9VSrMIhgoeBUU7JBmcskAib8Lb/N83bvxgYQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.UserSecrets": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "fhdG6UV9lIp70QhNkVyaHciUVq25IPFkczheVJL9bIFvmnJ+Zghaie6dWkDbbVmxZlHl9gj3zTDxMxJs5zNhIA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Json": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA=="
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vAJHd4yOpmKoK+jBuYV7a3y+Ab9U4ARCc29b6qvMy276RgJFw9LFs0DdsPqOL3ahwzyrX7tM+i4cCxU/RX0qAg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
}
},
"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.Diagnostics.HealthChecks": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "REdt95QXHscGdtw/UUgyCW2lF9DJcAOJxmebKW2IkgUjuCAdMODIi2HNOWg5utW98nm8ekgV0Gjqs/sljwwqMw==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "NrIMTy7dpqxAvA6kHAYH8cXID/YgeNOy0OqFKpLtkPu5X4WS/basX91UszANzVrMNRAICJ2GOnGiRxJtsRyEQw=="
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "dMu5kUPSfol1Rqhmr6nWPSmbFjDe9w6bkoKithG17bWTZA0UyKirTatM5mqYUN3mGpNA0MorlusIoVTh6J7o5g==",
"dependencies": {
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "mOE3ARusNQR0a5x8YOcnUbfyyXGqoAWQtEc7qFOfNJgruDWQLo39Re+3/Lzj5pLPFuFYj8hN4dgKzaSQDKiOCw=="
},
"Microsoft.Extensions.Hosting": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "8i7e5IBdiKLNqt/+ciWrS8U95Rv5DClaaj7ulkZbimnCi4uREWd+lXzkp3joofFuIPOlAzV4AckxLTIELv2jdg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.Configuration.CommandLine": "10.0.5",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.5",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
"Microsoft.Extensions.Configuration.Json": "10.0.5",
"Microsoft.Extensions.Configuration.UserSecrets": "10.0.5",
"Microsoft.Extensions.DependencyInjection": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
"Microsoft.Extensions.Logging.Console": "10.0.5",
"Microsoft.Extensions.Logging.Debug": "10.0.5",
"Microsoft.Extensions.Logging.EventLog": "10.0.5",
"Microsoft.Extensions.Logging.EventSource": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
"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.Http": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "AiFvHYM8nP0wPC7bGPI3NHQlSYSLqjjT7DMJUuuxhd+7pz3O89iu2gdQfgACy5DxsXENiok5i1bMacJL7KR8jA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Configuration": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "cSgxsDgfP0+gmVRPVoNHI/KIDavIZxh+CxE6tSLPlYTogqccDnjBFI9CgEsiNuMP6+fiuXUwhhlTz36uUEpwbQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Console": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "PMs2gha2v24hvH5o5KQem5aNK4mN0BhhCWlMqsg9tzifWKzjeQi2tyPOP/RaWMVvalOhVLcrmoMYPqbnia/epg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Debug": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/VacEkBQ02A8PBXSa6YpbIXCuisYy6JJr62/+ANJDZE+RMBfZMcXJXLfr/LpyLE6pgdp17Wxlt7e7R9zvkwZ3Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "0ezhWYJS4/6KrqQel9JL+Tr4n+4EX2TF5EYiaysBWNNEM2c3Gtj1moD39esfgk8OHblSX+UFjtZ3z0c4i9tRvw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"System.Diagnostics.EventLog": "10.0.5"
}
},
"Microsoft.Extensions.Logging.EventSource": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vN+aq1hBFXyYvY5Ow9WyeR66drKQxRZmas4lAjh6QWfryPkjTn1uLtX5AFIxyDaZj78v5TG2sELUyvrXpAPQQw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"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.Options.ConfigurationExtensions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "BB9uUW3+6Rxu1R97OB1H/13lUF8P2+H1+eDhpZlK30kDh/6E4EKHBUqTp+ilXQmZLzsRErxON8aBSR6WpUKJdg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g=="
},
"Microsoft.NET.StringTools": {
"type": "Transitive",
"resolved": "17.6.3",
"contentHash": "N0ZIanl1QCgvUumEL1laasU0a7sOE5ZwLZVTn0pAePnfhq8P7SvTjF8Axq+CnavuQkmdQpGNXQ1efZtu5kDFbA=="
},
"Microsoft.VisualStudio.Threading.Only": {
"type": "Transitive",
"resolved": "17.13.61",
"contentHash": "vl5a2URJYCO5m+aZZtNlAXAMz28e2pUotRuoHD7RnCWOCeoyd8hWp5ZBaLNYq4iEj2oeJx5ZxiSboAjVmB20Qg==",
"dependencies": {
"Microsoft.VisualStudio.Validation": "17.8.8"
}
},
"Microsoft.VisualStudio.Validation": {
"type": "Transitive",
"resolved": "17.8.8",
"contentHash": "rWXThIpyQd4YIXghNkiv2+VLvzS+MCMKVRDR0GAMlflsdo+YcAN2g2r5U1Ah98OFjQMRexTFtXQQ2LkajxZi3g=="
},
"ModelContextProtocol": {
"type": "Transitive",
"resolved": "1.0.0",
"contentHash": "W7UX8AQ1qMjXyCDcpP25u/L1W2vIIgfhLX/B2ZtTU1VUyILXdmVbdRjkQesKVPT/wPMpYXIHUcZJTPdsGfKSfQ==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "10.0.3",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.3",
"ModelContextProtocol.Core": "1.0.0"
}
},
"ModelContextProtocol.Core": {
"type": "Transitive",
"resolved": "1.0.0",
"contentHash": "QKboiQEq2MJMGeQ029Gy6xqge88abm0Px9lnG7hueOyf+EDCxi5SUATV+Df7GwT+NwWzkEsYG271bUQD+LGhEg==",
"dependencies": {
"Microsoft.Extensions.AI.Abstractions": "10.3.0",
"Microsoft.Extensions.Logging.Abstractions": "10.0.3"
}
},
"Nerdbank.Streams": {
"type": "Transitive",
"resolved": "2.12.87",
"contentHash": "oDKOeKZ865I5X8qmU3IXMyrAnssYEiYWTobPGdrqubN3RtTzEHIv+D6fwhdcfrdhPJzHjCkK/ORztR/IsnmA6g==",
"dependencies": {
"Microsoft.VisualStudio.Threading.Only": "17.13.61",
"Microsoft.VisualStudio.Validation": "17.8.8"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.4",
"contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A=="
},
"Npgsql": {
"type": "Transitive",
"resolved": "8.0.3",
"contentHash": "6WEmzsQJCZAlUG1pThKg/RmeF6V+I0DmBBBE/8YzpRtEzhyZzKcK7ulMANDm5CkxrALBEC8H+5plxHWtIL7xnA==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
}
},
"Polly.Core": {
"type": "Transitive",
"resolved": "8.6.5",
"contentHash": "t+sUVrIwvo7UmsgHGgOG9F0GDZSRIm47u2ylH17Gvcv1q5hNEwgD5GoBlFyc0kh/pebmPyrAgvGsR/65ZBaXlg=="
},
"Semver": {
"type": "Transitive",
"resolved": "3.0.0",
"contentHash": "9jZCicsVgTebqkAujRWtC9J1A5EQVlu0TVKHcgoCuv345ve5DYf4D1MjhKEnQjdRZo6x/vdv6QQrYFs7ilGzLA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "5.0.1"
}
},
"StreamJsonRpc": {
"type": "Transitive",
"resolved": "2.22.23",
"contentHash": "Ahq6uUFPnU9alny5h4agyX74th3PRq3NQCRNaDOqWcx20WT06mH/wENSk5IbHDc8BmfreQVEIBx5IXLBbsLFIA==",
"dependencies": {
"MessagePack": "2.5.192",
"Microsoft.VisualStudio.Threading.Only": "17.13.61",
"Microsoft.VisualStudio.Validation": "17.8.8",
"Nerdbank.Streams": "2.12.87",
"Newtonsoft.Json": "13.0.3"
}
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "wugvy+pBVzjQEnRs9wMTWwoaeNFX3hsaHeVHFDIvJSWXp7wfmNWu3mxAwBIE6pyW+g6+rHa1Of5fTzb0QVqUTA=="
},
"System.IO.Hashing": {
"type": "Transitive",
"resolved": "10.0.3",
"contentHash": "La6ICwsdTKhVX+LKN+pvFjQRR3LhLwq3uKdi2knjLzRyPYBSydF4cjXidYxIiTcDD6XVYdsBWQEI8ZxiZ/OdIg=="
},
"YamlDotNet": {
"type": "Transitive",
"resolved": "16.3.0",
"contentHash": "SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA=="
}
}
}
}
-10
View File
@@ -30,18 +30,8 @@ RUN dotnet publish "GmRelay.Bot.csproj" -c Release -a $TARGETARCH -o /app/publis
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
WORKDIR /app
# Устанавливаем wget для healthcheck и libgssapi-krb5-2 для Npgsql GSS/SSPI
# и HTTPS-handshake Telegram.Bot (без неё long-polling падает на первом запросе).
RUN apt-get update && apt-get install -y --no-install-recommends \
wget libgssapi-krb5-2 \
&& rm -rf /var/lib/apt/lists/*
# Копируем только AOT-результаты из билда
COPY --from=build /app/publish .
EXPOSE 8081
USER $APP_UID
# Запуск скомпилированного AOT бинарного файла напрямую
ENTRYPOINT ["./GmRelay.Bot"]
@@ -0,0 +1,315 @@
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);
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
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,
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,8 +1,8 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Shared.Features.Confirmation.HandleRsvp;
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
public sealed record RsvpFlowDecision(
internal sealed record RsvpFlowDecision(
string CallbackText,
bool ShouldAlertGm,
bool ShouldRevertSessionToConfirmationSent,
@@ -10,7 +10,7 @@ public sealed record RsvpFlowDecision(
bool ShouldNotifyGroup,
bool ShouldNotifyGm);
public static class RsvpFlowRules
internal static class RsvpFlowRules
{
public static RsvpFlowDecision Evaluate(
string requestedStatus,
@@ -21,7 +21,7 @@ public static class RsvpFlowRules
if (requestedStatus == RsvpStatus.Declined)
{
return new RsvpFlowDecision(
CallbackText: "Вы отказались от участия.",
CallbackText: "\u0412\u044b \u043e\u0442\u043a\u0430\u0437\u0430\u043b\u0438\u0441\u044c \u043e\u0442 \u0443\u0447\u0430\u0441\u0442\u0438\u044f.",
ShouldAlertGm: true,
ShouldRevertSessionToConfirmationSent: currentSessionStatus == SessionStatus.Confirmed,
ShouldMarkSessionConfirmed: false,
@@ -32,7 +32,7 @@ public static class RsvpFlowRules
var everyoneConfirmed = confirmedParticipants == totalParticipants;
return new RsvpFlowDecision(
CallbackText: "Вы подтвердили участие!",
CallbackText: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u0443\u0447\u0430\u0441\u0442\u0438\u0435!",
ShouldAlertGm: false,
ShouldRevertSessionToConfirmationSent: false,
ShouldMarkSessionConfirmed: everyoneConfirmed,
@@ -0,0 +1,149 @@
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,
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)
{
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.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,
text: text,
replyMarkup: keyboard,
cancellationToken: ct);
// 5. Update session status and store message ID
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @Status,
confirmation_message_id = @MessageId,
updated_at = now()
WHERE id = @SessionId
""",
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,12 +1,12 @@
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Platform;
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
namespace GmRelay.Bot.Features.Notifications;
public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName);
public sealed class DirectSessionNotificationSender(
IPlatformMessenger messenger,
ITelegramBotClient bot,
ILogger<DirectSessionNotificationSender> logger)
{
public async Task SendAsync(
@@ -20,11 +20,11 @@ public sealed class DirectSessionNotificationSender(
{
try
{
await messenger.SendPrivateMessageAsync(
new PlatformPrivateMessage(
TelegramPlatformIds.User(recipient.TelegramId, recipient.DisplayName),
htmlText),
ct);
await bot.SendMessage(
chatId: recipient.TelegramId,
text: htmlText,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
@@ -0,0 +1,131 @@
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,
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)
{
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.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,
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,35 +1,27 @@
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.Shared.Features.Reminders.SendOneHourReminder;
namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder;
internal sealed record OneHourReminderSessionRow(
internal sealed record OneHourReminderSession(
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,
PlatformDirectNotificationSender directSender,
ILogger<SendOneHourReminderHandler> logger) : ISendOneHourReminderHandler
DirectSessionNotificationSender directSender,
ILogger<SendOneHourReminderHandler> logger)
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var session = await connection.QuerySingleOrDefaultAsync<OneHourReminderSessionRow>(
var session = await connection.QuerySingleOrDefaultAsync<OneHourReminderSession>(
"""
SELECT id,
title,
@@ -54,12 +46,10 @@ public sealed class SendOneHourReminderHandler(
return;
}
var recipients = (await connection.QueryAsync<OneHourReminderRecipientRow>(
var recipients = (await connection.QueryAsync<DirectNotificationRecipient>(
"""
SELECT p.platform AS Platform,
p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName,
p.external_username AS ExternalUsername
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
@@ -72,27 +62,20 @@ public sealed class SendOneHourReminderHandler(
SessionId = sessionId,
Active = ParticipantRegistrationStatus.Active,
Declined = RsvpStatus.Declined
}))
.Select(row => new PlatformUser(
ParsePlatform(row.Platform),
row.ExternalUserId,
row.DisplayName,
row.ExternalUsername))
.ToList();
})).ToList();
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages() && recipients.Count > 0)
{
await directSender.SendAsync(
PlatformDirectSessionNotificationKind.OneHourReminder,
recipients,
session.Id,
session.Title,
session.ScheduledAt,
session.JoinLink,
actorDisplayName: null,
reason: null,
ct);
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 connection.ExecuteAsync(
@@ -111,7 +94,4 @@ public sealed class SendOneHourReminderHandler(
session.Title,
session.NotificationMode);
}
private static PlatformKind ParsePlatform(string platform) =>
Enum.Parse<PlatformKind>(platform, ignoreCase: true);
}
@@ -1,9 +1,10 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -13,7 +14,6 @@ public sealed record CancelSessionCommand(
long TelegramUserId,
string CallbackQueryId,
long ChatId,
int? MessageThreadId,
int MessageId);
// DTOs for AOT compilation
@@ -21,7 +21,7 @@ internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, int? Bat
public sealed class CancelSessionHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<CancelSessionHandler> logger)
{
@@ -29,7 +29,7 @@ public sealed class CancelSessionHandler(
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Проверяем, что запрос делает управляющий данной группы.
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
"""
@@ -42,23 +42,22 @@ public sealed class CancelSessionHandler(
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
AND p.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
""",
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() }, transaction);
new { command.SessionId, command.TelegramUserId }, transaction);
if (session == null)
{
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
return;
}
if (!session.CanManage)
{
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", ct, showAlert: true);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", showAlert: true, cancellationToken: ct);
return;
}
@@ -70,7 +69,7 @@ public sealed class CancelSessionHandler(
// 3. Загружаем весь батч для перерисовки
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
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at",
@@ -90,7 +89,7 @@ public sealed class CancelSessionHandler(
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
"""
SELECT p.external_user_id::BIGINT AS TelegramId,
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -105,24 +104,22 @@ public sealed class CancelSessionHandler(
// 4. Перерисовываем сообщение
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList());
var renderResult = TelegramSessionBatchRenderer.Render(view);
try
{
var messageId = session.BatchMessageId ?? command.MessageId;
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
view,
TelegramPlatformIds.Message(command.ChatId, command.MessageThreadId, messageId)),
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId,
messageId: session.BatchMessageId ?? command.MessageId,
text: renderResult.Text,
replyMarkup: renderResult.Markup,
ct);
await AnswerAsync(command.CallbackQueryId, "Сессия отменена!", ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct);
// Опционально: написать отдельное сообщение в чат
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
$"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.",
ct);
await bot.SendMessage(command.ChatId, $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
@@ -138,10 +135,7 @@ public sealed class CancelSessionHandler(
catch (Exception ex)
{
logger.LogError(ex, "Failed to update batch message after cancelling session {SessionId}", command.SessionId);
await AnswerAsync(command.CallbackQueryId, "Ошибка при обновлении сообщения.", ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Ошибка при обновлении сообщения.", cancellationToken: ct);
}
}
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
}
@@ -1,354 +1,287 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
/// <summary>
/// Telegram-side entry point for the wizard-driven session creation
/// flow. Talks to the shared wizard through <see cref="IWizardMessenger"/>
/// and the platform-neutral <see cref="WizardDraft"/>. Keeps the
/// platform glue (mapping <c>Message</c> to draft fields, rendering
/// error keyboards, etc.) local to <c>GmRelay.Bot</c>.
/// </summary>
public sealed class CreateSessionHandler
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
public sealed class CreateSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient,
ILogger<CreateSessionHandler> logger)
{
private const int MaxRetries = 3;
private const string PlatformName = "Telegram";
private readonly IWizardDraftRepository _drafts;
private readonly SharedCreateSessionHandler _shared;
private readonly IWizardMessenger _messenger;
private readonly ILogger<CreateSessionHandler> _log;
private readonly IPlatformMessenger? _platformMessenger;
private readonly NpgsqlDataSource? _dataSource;
public CreateSessionHandler(
IWizardDraftRepository drafts,
SharedCreateSessionHandler shared,
IWizardMessenger messenger,
ILogger<CreateSessionHandler> log,
IPlatformMessenger? platformMessenger = null,
NpgsqlDataSource? dataSource = null)
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{
_drafts = drafts;
_shared = shared;
_messenger = messenger;
_log = log;
_platformMessenger = platformMessenger;
_dataSource = dataSource;
}
var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow);
/// <summary>
/// Entry point for <c>/newsession</c>. If a non-expired draft
/// already exists for this owner, returns <c>null</c> so the caller
/// can render a "Continue / Start over / Cancel" menu.
/// </summary>
public async Task<WizardDraft?> StartWizardAsync(Message message, CancellationToken ct)
{
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
var existing = await _drafts.GetActiveAsync(PlatformName, ownerId, ct);
if (existing is not null)
foreach (var timeInput in parseResult.PastTimeInputs)
{
return null;
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
cancellationToken: cancellationToken);
}
var draft = new WizardDraft
foreach (var timeInput in parseResult.InvalidTimeInputs)
{
Id = Guid.NewGuid(),
ChatId = message.Chat.Id.ToString(CultureInfo.InvariantCulture),
MessageThreadId = message.MessageThreadId?.ToString(CultureInfo.InvariantCulture),
OwnerId = ownerId,
Platform = PlatformName,
Step = WizardStepNames.Type,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddHours(24),
};
await _drafts.UpsertAsync(draft, ct);
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
cancellationToken: cancellationToken);
}
var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload());
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
draft.DraftMessageId = msgId;
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
return draft;
}
/// <summary>
/// Resume an existing draft — returns the draft row so the caller
/// can re-render the resume/reset menu.
/// </summary>
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct)
{
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
return _drafts.GetActiveAsync(PlatformName, ownerId, ct);
}
/// <summary>
/// Finalize: build shared command(s), call the shared handler, edit
/// the wizard message. On failure, retry up to <see cref="MaxRetries"/>
/// times before deleting the draft.
/// </summary>
public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct)
{
var payload = LoadPayload(draft);
if (!IsComplete(payload, out var missing))
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
{
await _messenger.EditDraftMessageAsync(
draft, $"❌ Не заполнены поля: {missing}", Array.Empty<WizardAction>(), ct);
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
cancellationToken: cancellationToken);
}
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
{
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
cancellationToken: cancellationToken);
}
if (!parseResult.IsValid)
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link\nКартинка: https://cover\n\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7",
cancellationToken: cancellationToken);
return;
}
var commands = BuildCommands(draft, payload);
var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>();
var title = parseResult.Title!;
var link = parseResult.Link!;
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
var gmId = message.From!.Id;
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
var gmUsername = message.From.Username;
var chatId = message.Chat.Id;
var chatTitle = message.Chat.Title ?? "Private Chat";
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
foreach (var cmd in commands)
await connection.ExecuteAsync(
"""
INSERT INTO players (telegram_id, display_name, telegram_username)
VALUES (@TgId, @Name, @Username)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username;
""",
new { TgId = gmId, Name = gmName, Username = gmUsername },
transaction);
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
"""
SELECT g.id AS GroupId,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = g.id
AND p.telegram_id = @GmId
) AS CanManage
FROM game_groups g
WHERE g.telegram_chat_id = @ChatId
""",
new { ChatId = chatId, GmId = gmId },
transaction);
Guid groupId;
if (existingGroup is null)
{
var result = await _shared.HandleAsync(cmd, ct);
if (!result.Success)
groupId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
VALUES (@ChatId, @ChatName, @GmId)
RETURNING id;
""",
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
transaction);
await connection.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE p.telegram_id = @GmId
ON CONFLICT (group_id, player_id) DO NOTHING
""",
new { GroupId = groupId, GmId = gmId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
}
else
{
if (!existingGroup.CanManage)
{
await _messenger.EditDraftMessageAsync(
draft,
result.ErrorMessage ?? "❌ Не удалось создать сессию.",
Array.Empty<WizardAction>(),
ct);
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(
chatId,
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
cancellationToken: cancellationToken);
return;
}
created.Add((cmd, result));
groupId = existingGroup.GroupId;
await connection.ExecuteAsync(
"UPDATE game_groups SET name = @ChatName WHERE id = @GroupId",
new { ChatName = chatTitle, GroupId = groupId },
transaction);
}
int? messageThreadId = null;
if (message.Chat.IsForum)
{
var topic = await botClient.CreateForumTopic(
chatId: chatId,
name: $"🎲 Игры: {title}",
cancellationToken: cancellationToken);
messageThreadId = topic.MessageThreadId;
}
var batchId = Guid.NewGuid();
var sessions = new List<SessionBatchDto>();
foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value))
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @MaxPlayers)
RETURNING id;
""",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = link,
ScheduledAt = scheduledAt,
ThreadId = messageThreadId,
MaxPlayers = parseResult.MaxPlayers,
Status = SessionStatus.Planned
},
transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers));
}
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
var renderResult = TelegramSessionBatchRenderer.Render(view);
Message batchMessage;
if (imageReference is not null && renderResult.Text.Length <= 1024)
{
// Картинка + расписание умещаются в одном Telegram-фото с подписью
try
{
batchMessage = await botClient.SendPhoto(
chatId: chatId,
messageThreadId: messageThreadId,
photo: InputFile.FromString(imageReference),
caption: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}, отправляем текстом", batchId);
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
}
else
{
// Текст слишком длинный для caption — fallback на два сообщения
if (imageReference is not null)
{
try
{
await botClient.SendPhoto(
chatId: chatId,
messageThreadId: messageThreadId,
photo: InputFile.FromString(imageReference),
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}", batchId);
}
}
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessage.MessageId, BatchId = batchId });
try
{
await botClient.DeleteMessage(
chatId: chatId,
messageId: message.MessageId,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, chatId);
}
}
catch (Exception ex)
{
_log.LogError(ex, "SubmitDraftAsync failed for draft {DraftId}", draft.Id);
payload.RetryCount += 1;
SavePayload(draft, payload);
if (payload.RetryCount >= MaxRetries)
{
await _messenger.EditDraftMessageAsync(
draft,
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
return;
}
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
await _messenger.EditDraftMessageAsync(
draft,
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
RetryCancelActions(),
ct);
return;
logger.LogError(ex, "Ошибка при создании сессии");
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken);
}
var totalSessions = created.Sum(c => c.Command.ScheduledTimes.Count);
try
{
foreach (var item in created)
{
await PublishCreatedSessionAsync(item.Command, item.Result, ct);
}
}
catch (Exception ex)
{
_log.LogError(ex, "SubmitDraftAsync created draft {DraftId} but failed to publish schedule", draft.Id);
await _messenger.EditDraftMessageAsync(
draft,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}, но не удалось опубликовать сообщение для записи: {ex.Message}",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
return;
}
await _messenger.EditDraftMessageAsync(
draft,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
Array.Empty<WizardAction>(),
ct);
await _drafts.DeleteAsync(draft.Id, ct);
}
private async Task PublishCreatedSessionAsync(CreateSessionCommand command, CreateSessionResult result, CancellationToken ct)
internal static string? GetBatchImageReference(Message message, string? parsedImageUrl)
{
if (_platformMessenger is null || _dataSource is null)
var attachedPhotoFileId = message.Photo?
.OrderByDescending(photo => photo.FileSize ?? 0)
.ThenByDescending(photo => photo.Width * photo.Height)
.FirstOrDefault()
?.FileId;
if (!string.IsNullOrWhiteSpace(attachedPhotoFileId))
{
throw new InvalidOperationException("Session publication dependencies are not configured.");
return attachedPhotoFileId;
}
if (result.View is null || result.BatchId is null)
{
throw new InvalidOperationException("Created session result does not contain publication data.");
}
var group = command.Group;
var topicCreatedByBot = false;
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
{
var thread = await _platformMessenger.CreateThreadAsync(group, command.Title, ct);
group = group with { ExternalThreadId = thread.ExternalThreadId };
topicCreatedByBot = true;
}
var scheduleMessage = await _platformMessenger.SendScheduleAsync(
new PlatformScheduleMessage(group, result.View, ExistingMessage: null, command.ImageReference),
ct);
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
"""
UPDATE sessions
SET thread_id = @ThreadId,
batch_message_id = @BatchMessageId,
topic_created_by_bot = @TopicCreatedByBot,
updated_at = now()
WHERE batch_id = @BatchId
""",
new
{
result.BatchId,
ThreadId = ParseNullableInt(group.ExternalThreadId),
BatchMessageId = ParseInt(scheduleMessage.ExternalMessageId),
TopicCreatedByBot = topicCreatedByBot
});
return string.IsNullOrWhiteSpace(parsedImageUrl) ? null : parsedImageUrl.Trim();
}
private static int ParseInt(string value) =>
int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
private static int? ParseNullableInt(string? value) =>
string.IsNullOrWhiteSpace(value)
? null
: int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
// ── Build shared commands ────────────────────────────────────────
// The shared handler creates one session per scheduled time in a
// single transaction and assigns the same batch_id to all of them.
// A wizard pool therefore produces ONE command with N times; a
// single-game wizard produces ONE command with one time.
private static List<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
{
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
{
return new List<CreateSessionCommand>
{
BuildCommand(
draft,
p,
pool.Slots.Select(s => s.ScheduledAt).ToList(),
MaxPlayersForPool(pool),
isOneShot: false),
};
}
return new List<CreateSessionCommand>
{
BuildCommand(
draft,
p,
new[] { p.Single?.ScheduledAt ?? default },
p.Single?.MaxPlayers,
isOneShot: true),
};
}
private static int MaxPlayersForPool(WizardPoolInput pool) =>
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
internal static CreateSessionCommand BuildCommand(
WizardDraft draft,
WizardPayload p,
IReadOnlyList<DateTimeOffset> scheduledTimes,
int? maxPlayers,
bool isOneShot)
{
var user = new PlatformUser(
PlatformKind.Telegram,
draft.OwnerId,
DisplayName: string.Empty,
ExternalUsername: null);
var group = new PlatformGroup(
PlatformKind.Telegram,
draft.ChatId,
DisplayName: string.Empty,
ExternalChannelId: null,
ExternalThreadId: draft.MessageThreadId);
return new CreateSessionCommand(
User: user,
Group: group,
Title: p.Title ?? string.Empty,
Link: string.Empty,
ScheduledTimes: scheduledTimes,
MaxPlayers: maxPlayers,
ImageReference: p.ImageFileId ?? p.ImageUrl,
System: ParseSystem(p.System),
Description: p.Description,
Format: null,
DurationMinutes: p.DurationMinutes,
IsOneShot: isOneShot);
}
private static GameSystem? ParseSystem(string? code)
{
if (string.IsNullOrWhiteSpace(code)) return null;
return Enum.TryParse<GameSystem>(code, ignoreCase: true, out var sys) ? sys : null;
}
// ── Validation ───────────────────────────────────────────────────
private static bool IsComplete(WizardPayload p, out string missing)
{
var missingFields = new List<string>();
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
if (p.Visibility is null) missingFields.Add("видимость");
if (p.Type == WizardCreationType.Single)
{
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
// MaxPlayers = null is a valid "♾ Без лимита" choice
// (see GameCreationWizard.ApplyCapacityChoice "no_limit").
}
else
{
if (p.Pool is null || p.Pool.Slots.Count == 0) missingFields.Add("слоты");
}
missing = string.Join(", ", missingFields);
return missingFields.Count == 0;
}
// ── Payload I/O ──────────────────────────────────────────────────
private static WizardPayload LoadPayload(WizardDraft draft)
{
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
return JsonSerializer.Deserialize(draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
}
private static void SavePayload(WizardDraft draft, WizardPayload p)
{
draft.PayloadJson = JsonSerializer.Serialize(p, WizardPayloadJsonContext.Default.WizardPayload);
}
// ── Keyboards ────────────────────────────────────────────────────
private static IReadOnlyList<WizardAction> RetryCancelActions() => new[]
{
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
};
}
@@ -1,37 +1,32 @@
using System.Globalization;
using Dapper;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
public sealed record JoinSessionCommand(
Guid SessionId,
PlatformUser User,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage,
bool DeferScheduleUpdate = false);
public sealed record SessionInteractionResult(
string ReplyText,
SessionBatchViewModel? UpdatedView = null);
long TelegramUserId,
string DisplayName,
string? TelegramUsername,
string CallbackQueryId,
long ChatId,
int MessageId);
// DTOs for AOT compilation
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxPlayers);
public sealed class JoinSessionHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
IScheduleMessageUpdateLock scheduleUpdateLock,
ITelegramBotClient bot,
ILogger<JoinSessionHandler> logger)
{
public async Task<SessionInteractionResult> HandleAsync(JoinSessionCommand command, CancellationToken ct)
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;
@@ -39,29 +34,17 @@ public sealed class JoinSessionHandler(
try
{
// 1. Убеждаемся, что игрок есть в базе
var platform = command.User.Platform.ToString();
var playerId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, @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,
external_username = EXCLUDED.external_username
@"INSERT INTO players (telegram_id, display_name, telegram_username)
VALUES (@TgId, @Name, @Username)
ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = EXCLUDED.telegram_username
RETURNING id;",
new
{
Name = command.User.DisplayName,
Platform = platform,
command.User.ExternalUserId,
command.User.ExternalUsername
},
new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername },
transaction);
// 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав.
var batchInfo = await connection.QuerySingleOrDefaultAsync<JoinSessionBatchDto>(
@"SELECT batch_id as BatchId, title as Title, status as Status, max_players as MaxPlayers
@"SELECT batch_id as BatchId, title as Title, max_players as MaxPlayers
FROM sessions
WHERE id = @SessionId
FOR UPDATE",
@@ -71,13 +54,8 @@ public sealed class JoinSessionHandler(
if (batchInfo is null)
{
await transaction.RollbackAsync(ct);
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
}
if (SessionStatus.IsCancelled(batchInfo.Status))
{
await transaction.RollbackAsync(ct);
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
return;
}
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
@@ -97,7 +75,8 @@ public sealed class JoinSessionHandler(
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы уже в листе ожидания!"
: "Вы уже записаны!";
return await AnswerAsync(command.InteractionId, alreadyText, ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, alreadyText, cancellationToken: ct);
return;
}
var activeParticipants = await connection.ExecuteScalarAsync<int>(
@@ -130,12 +109,13 @@ public sealed class JoinSessionHandler(
if (inserted == 0)
{
await transaction.RollbackAsync(ct);
return await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы уже записаны!", cancellationToken: ct);
return;
}
// Загружаем весь батч для перерисовки
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
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at",
@@ -144,7 +124,7 @@ public sealed class JoinSessionHandler(
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId,
p.display_name as DisplayName,
p.external_username as TelegramUsername,
p.telegram_username as TelegramUsername,
sp.registration_status as RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -158,20 +138,20 @@ public sealed class JoinSessionHandler(
// 4. Перерисовываем сообщение
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
if (!command.DeferScheduleUpdate)
{
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
}
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId,
messageId: command.MessageId,
text: renderResult.Text,
replyMarkup: renderResult.Markup,
ct);
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Основной состав заполнен. Вы добавлены в лист ожидания."
: "Вы успешно записаны!";
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct);
}
catch (Exception ex)
{
@@ -184,17 +164,7 @@ public sealed class JoinSessionHandler(
var errorText = transactionCommitted
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
: "Произошла ошибка при регистрации.";
return await AnswerAsync(command.InteractionId, errorText, ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
}
}
private async Task<SessionInteractionResult> AnswerAsync(
string interactionId,
string text,
CancellationToken ct,
SessionBatchViewModel? updatedView = null)
{
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
return new SessionInteractionResult(text, updatedView);
}
}
@@ -1,19 +1,18 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Microsoft.Extensions.Logging;
using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
public sealed record LeaveSessionCommand(
Guid SessionId,
PlatformUser User,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage,
bool DeferScheduleUpdate = false);
long TelegramUserId,
string CallbackQueryId,
long ChatId,
int MessageId);
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
@@ -21,13 +20,11 @@ internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string Di
public sealed class LeaveSessionHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
IScheduleMessageUpdateLock scheduleUpdateLock,
ITelegramBotClient bot,
ILogger<LeaveSessionHandler> logger)
{
public async Task<SessionInteractionResult> HandleAsync(LeaveSessionCommand command, CancellationToken ct)
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;
@@ -50,17 +47,17 @@ public sealed class LeaveSessionHandler(
if (session is null)
{
await transaction.RollbackAsync(ct);
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
return;
}
if (SessionStatus.IsCancelled(session.Status))
{
await transaction.RollbackAsync(ct);
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия уже отменена.", cancellationToken: ct);
return;
}
var platform = command.User.Platform.ToString();
var participant = await connection.QuerySingleOrDefaultAsync<LeaveSessionParticipantDto>(
"""
SELECT sp.id AS ParticipantRowId,
@@ -69,18 +66,18 @@ public sealed class LeaveSessionHandler(
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND p.telegram_id = @TelegramUserId
AND sp.is_gm = false
FOR UPDATE OF sp
""",
new { command.SessionId, Platform = platform, command.User.ExternalUserId },
new { command.SessionId, command.TelegramUserId },
transaction);
if (participant is null)
{
await transaction.RollbackAsync(ct);
return await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы не записаны на эту сессию.", cancellationToken: ct);
return;
}
await connection.ExecuteAsync(
@@ -160,8 +157,7 @@ public sealed class LeaveSessionHandler(
SELECT id AS SessionId,
scheduled_at AS ScheduledAt,
status AS Status,
max_players AS MaxPlayers,
join_link AS JoinLink
max_players AS MaxPlayers
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at
@@ -173,7 +169,7 @@ public sealed class LeaveSessionHandler(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
p.telegram_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -188,15 +184,15 @@ public sealed class LeaveSessionHandler(
transactionCommitted = true;
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
if (!command.DeferScheduleUpdate)
{
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
}
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId,
messageId: command.MessageId,
text: renderResult.Text,
replyMarkup: renderResult.Markup,
ct);
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы удалены из листа ожидания."
@@ -204,7 +200,7 @@ public sealed class LeaveSessionHandler(
? "Вы отписались от сессии."
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct);
}
catch (Exception ex)
{
@@ -217,17 +213,7 @@ public sealed class LeaveSessionHandler(
var errorText = transactionCommitted
? "Запись снята, но не удалось обновить сообщение расписания."
: "Произошла ошибка при отмене записи.";
return await AnswerAsync(command.InteractionId, errorText, ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
}
}
private async Task<SessionInteractionResult> AnswerAsync(
string interactionId,
string text,
CancellationToken ct,
SessionBatchViewModel? updatedView = null)
{
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
return new SessionInteractionResult(text, updatedView);
}
}
@@ -0,0 +1,184 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record NewSessionParseResult(
string? Title,
string? Link,
string? ImageUrl,
int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs,
IReadOnlyList<string> InvalidTimeInputs,
IReadOnlyList<string> InvalidSeatLimitInputs,
IReadOnlyList<string> InvalidRecurringInputs)
{
public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.Count > 0 &&
InvalidSeatLimitInputs.Count == 0 &&
InvalidRecurringInputs.Count == 0;
}
internal static class NewSessionCommandParser
{
private const int MaxRecurringSessionCount = 52;
private const int MaxRecurringIntervalDays = 365;
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
private static readonly string[] ImagePrefixes =
[
"\u041a\u0430\u0440\u0442\u0438\u043d\u043a\u0430:",
"\u0418\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435:",
"\u041e\u0431\u043b\u043e\u0436\u043a\u0430:"
];
private static readonly string[] SeatLimitPrefixes =
[
"\u041c\u0435\u0441\u0442:",
"\u041b\u0438\u043c\u0438\u0442:",
"\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:"
];
private static readonly string[] RecurringCountPrefixes =
[
"\u0418\u0433\u0440:",
"\u0421\u0435\u0441\u0441\u0438\u0439:",
"\u041f\u043e\u0432\u0442\u043e\u0440\u043e\u0432:"
];
private static readonly string[] RecurringIntervalPrefixes =
[
"\u0428\u0430\u0433:",
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b:"
];
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
{
string? title = null;
string? link = null;
string? imageUrl = null;
int? maxPlayers = null;
int? recurringCount = null;
var recurringIntervalDays = 7;
var scheduledTimes = new List<DateTimeOffset>();
var pastTimeInputs = new List<string>();
var invalidTimeInputs = new List<string>();
var invalidSeatLimitInputs = new List<string>();
var invalidRecurringInputs = new List<string>();
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
{
if (line.StartsWith(TitlePrefix, StringComparison.OrdinalIgnoreCase))
{
title = line[TitlePrefix.Length..].Trim();
continue;
}
if (line.StartsWith(LinkPrefix, StringComparison.OrdinalIgnoreCase))
{
link = line[LinkPrefix.Length..].Trim();
continue;
}
var imagePrefix = ImagePrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (imagePrefix is not null)
{
imageUrl = line[imagePrefix.Length..].Trim();
continue;
}
var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (seatLimitPrefix is not null)
{
var seatLimitInput = line[seatLimitPrefix.Length..].Trim();
if (int.TryParse(seatLimitInput, out var parsedMaxPlayers) && parsedMaxPlayers > 0)
{
maxPlayers = parsedMaxPlayers;
}
else
{
invalidSeatLimitInputs.Add(seatLimitInput);
}
continue;
}
var recurringCountPrefix = RecurringCountPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (recurringCountPrefix is not null)
{
var recurringInput = line[recurringCountPrefix.Length..].Trim();
if (int.TryParse(recurringInput, out var parsedCount) &&
parsedCount is >= 1 and <= MaxRecurringSessionCount)
{
recurringCount = parsedCount;
}
else
{
invalidRecurringInputs.Add(recurringInput);
}
continue;
}
var recurringIntervalPrefix = RecurringIntervalPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (recurringIntervalPrefix is not null)
{
var recurringInput = line[recurringIntervalPrefix.Length..].Trim();
if (int.TryParse(recurringInput, out var parsedInterval) &&
parsedInterval is >= 1 and <= MaxRecurringIntervalDays)
{
recurringIntervalDays = parsedInterval;
}
else
{
invalidRecurringInputs.Add(recurringInput);
}
continue;
}
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var timeInput = line[TimePrefix.Length..].Trim();
if (!MoscowTime.TryParseMoscow(timeInput, out var scheduledAt))
{
invalidTimeInputs.Add(timeInput);
continue;
}
if (scheduledAt <= nowUtc)
{
pastTimeInputs.Add(timeInput);
continue;
}
scheduledTimes.Add(scheduledAt);
}
if (recurringCount.HasValue && scheduledTimes.Count == 1)
{
var firstScheduledTime = scheduledTimes[0];
scheduledTimes = Enumerable.Range(0, recurringCount.Value)
.Select(index => firstScheduledTime.AddDays(recurringIntervalDays * index))
.ToList();
}
return new NewSessionParseResult(
title,
link,
imageUrl,
maxPlayers,
scheduledTimes,
pastTimeInputs,
invalidTimeInputs,
invalidSeatLimitInputs,
invalidRecurringInputs);
}
}
@@ -1,8 +1,8 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -19,7 +19,7 @@ internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string Di
public sealed class PromoteWaitlistedPlayerHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
ITelegramBotClient bot,
ILogger<PromoteWaitlistedPlayerHandler> logger)
{
public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken ct)
@@ -41,27 +41,26 @@ public sealed class PromoteWaitlistedPlayerHandler(
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
AND p.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
FOR UPDATE
""",
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() },
new { command.SessionId, command.TelegramUserId },
transaction);
if (session is null)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
return;
}
if (!session.CanManage)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", ct, showAlert: true);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct);
return;
}
@@ -90,14 +89,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
if (waitlistedParticipants == 0)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Лист ожидания пуст.", ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Лист ожидания пуст.", cancellationToken: ct);
return;
}
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", ct, showAlert: true);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", showAlert: true, cancellationToken: ct);
return;
}
@@ -138,8 +137,7 @@ public sealed class PromoteWaitlistedPlayerHandler(
SELECT id AS SessionId,
scheduled_at AS ScheduledAt,
status AS Status,
max_players AS MaxPlayers,
join_link AS JoinLink
max_players AS MaxPlayers
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at
@@ -151,7 +149,7 @@ public sealed class PromoteWaitlistedPlayerHandler(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
p.telegram_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -166,15 +164,17 @@ public sealed class PromoteWaitlistedPlayerHandler(
transactionCommitted = true;
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
var messageId = session.BatchMessageId ?? command.MessageId;
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(command.ChatId),
view,
TelegramPlatformIds.Message(command.ChatId, threadId: null, messageId)),
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: command.ChatId,
messageId: session.BatchMessageId ?? command.MessageId,
text: renderResult.Text,
replyMarkup: renderResult.Markup,
ct);
await AnswerAsync(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", cancellationToken: ct);
}
catch (Exception ex)
{
@@ -187,10 +187,7 @@ public sealed class PromoteWaitlistedPlayerHandler(
var errorText = transactionCommitted
? "Игрок повышен, но не удалось обновить сообщение расписания."
: "Ошибка при обновлении листа ожидания.";
await AnswerAsync(command.CallbackQueryId, errorText, ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
}
}
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
}
@@ -1,142 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Telegram-side implementation of <see cref="IWizardMessenger"/>.
/// Translates the platform-neutral wizard contracts into the
/// <c>Telegram.Bot</c> SDK calls. All Telegram-specific behaviour
/// (message editing, callback ack, group lookup) lives behind the
/// interface so the wizard core stays in <c>GmRelay.Shared</c>.
/// </summary>
public sealed class TelegramWizardMessenger(
ITelegramBotClient bot,
NpgsqlDataSource dataSource) : IWizardMessenger
{
public async Task<string> EditDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> keyboard,
CancellationToken ct)
{
if (!TryParseChatId(draft.ChatId, out var chatId))
{
throw new InvalidOperationException(
$"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'.");
}
if (!TryParseMessageId(draft.DraftMessageId, out var messageId))
{
// No draft message recorded yet — fall back to sending a new one.
return await SendDraftMessageAsync(draft, text, keyboard, ct);
}
var msg = await bot.EditMessageText(
chatId: chatId,
messageId: messageId,
text: text,
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
cancellationToken: ct);
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
public async Task<string> SendDraftMessageAsync(
WizardDraft draft,
string text,
IReadOnlyList<WizardAction> keyboard,
CancellationToken ct)
{
if (!TryParseChatId(draft.ChatId, out var chatId))
{
throw new InvalidOperationException(
$"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'.");
}
int? threadId = TryParseThreadId(draft.MessageThreadId, out var parsedThread)
? parsedThread
: null;
var msg = await bot.SendMessage(
chatId: chatId,
text: text,
messageThreadId: threadId,
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
cancellationToken: ct);
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
{
return bot.AnswerCallbackQuery(interactionId, text: text, cancellationToken: ct);
}
public async Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct)
{
// Adjusted from the plan: this codebase models "clubs" as game_groups
// (V001 created game_groups; V026 added public_slug; no `clubs` table exists,
// and game_groups has no `club_id` FK). The picker therefore returns the
// game_groups the owner manages as a GM (via group_managers), matching
// the WizardClubOption contract (UUID id, name) used downstream.
//
// NativeAOT: Dapper.AOT 1.0.48 only generates interceptors for the
// (sql, object?) extension overload — not the (CommandDefinition) overload.
// The wizard reaches this method on the PickClub visibility step
// (issue #112 follow-up); using CommandDefinition here would fall back
// to Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit
// and throws PlatformNotSupportedException on AOT. Same root cause as
// WizardDraftRepository.GetActiveAsync in v3.9.0, same fix pattern.
const string sql = """
SELECT g.id AS ClubId,
g.name AS Name
FROM game_groups g
JOIN group_managers gm ON gm.group_id = g.id
JOIN players p ON p.id = gm.player_id
WHERE p.platform = @Platform
AND p.external_user_id = @ExternalId
GROUP BY g.id, g.name
ORDER BY g.name
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<WizardClubOption>(
sql,
new { Platform = "Telegram", ExternalId = ownerId });
return rows.AsList();
}
private static bool TryParseChatId(string raw, out long chatId)
{
if (long.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out chatId))
{
return true;
}
chatId = 0;
return false;
}
private static bool TryParseMessageId(string? raw, out int messageId)
{
if (raw is not null &&
int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out messageId))
{
return true;
}
messageId = 0;
return false;
}
private static bool TryParseThreadId(string? raw, out int threadId)
{
if (raw is not null &&
int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out threadId))
{
return true;
}
threadId = 0;
return false;
}
}
@@ -1,60 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public sealed class WizardDraftCleanupService : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private readonly IWizardDraftRepository _drafts;
private readonly ILogger<WizardDraftCleanupService> _log;
public WizardDraftCleanupService(
IWizardDraftRepository drafts,
ILogger<WizardDraftCleanupService> log)
{
_drafts = drafts;
_log = log;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TickInterval);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await RunOnceAsync(stoppingToken);
}
}
catch (OperationCanceledException)
{
// graceful shutdown
}
}
internal async Task RunOnceAsync(CancellationToken ct)
{
try
{
var deleted = await _drafts.DeleteExpiredAsync(ct);
if (deleted > 0)
{
_log.LogInformation("Wizard cleanup deleted {Count} expired drafts", deleted);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_log.LogError(ex, "Wizard cleanup tick failed");
}
}
}
@@ -1,68 +0,0 @@
using System;
using System.Globalization;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Converts a Telegram <see cref="Update"/> into the
/// platform-neutral <see cref="WizardInteraction"/> consumed by
/// <see cref="GameCreationWizard"/>. The mapping is the only place in
/// the bot that knows about both <c>Telegram.Bot.Types</c> and the
/// shared wizard contract, so a future Discord adapter can do the same
/// for its native event without changing the wizard core.
/// </summary>
public static class WizardInteractionMapper
{
/// <summary>
/// Returns <c>true</c> if <paramref name="update"/> carries a
/// wizard-relevant interaction (text message, photo, or
/// callback). Side-effect-free: the wizard state is not touched.
/// </summary>
public static bool TryMap(Update update, out WizardInteraction interaction)
{
interaction = default!;
if (update.CallbackQuery is { } cb && cb.From is not null)
{
interaction = new WizardInteraction(
OwnerId: cb.From.Id.ToString(CultureInfo.InvariantCulture),
Text: null,
CallbackPayload: cb.Data,
PhotoFileId: null,
PhotoUrl: null,
InteractionId: cb.Id);
return true;
}
if (update.Message is { From: not null } msg)
{
// The original Telegram wizard dispatched on
// `msg.Text is null` to identify a non-text update (photo,
// document, sticker, …) and only ran the text pipeline
// otherwise. We preserve that semantic: a message that
// carries a photo is a photo interaction even if it has a
// caption. Text is null for photos; the wizard checks
// PhotoFileId separately when Text is null.
//
// Note: `Message.MessageId` is exposed as a read-only
// property in Telegram.Bot, so the mapper cannot embed the
// numeric id in the interaction. Text interactions never
// need an ack, so the InteractionId is unused for them —
// we just emit a stable sentinel.
var hasPhoto = msg.Photo is { Length: > 0 };
var text = hasPhoto ? null : msg.Text;
var photoFileId = hasPhoto ? msg.Photo![^1].FileId : null;
interaction = new WizardInteraction(
OwnerId: msg.From!.Id.ToString(CultureInfo.InvariantCulture),
Text: text,
CallbackPayload: null,
PhotoFileId: photoFileId,
PhotoUrl: null,
InteractionId: "msg");
return true;
}
return false;
}
}
@@ -1,65 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Telegram-side renderer for wizard keyboards. Acts as the adapter
/// between the platform-neutral <see cref="WizardAction"/> list
/// produced by <see cref="WizardStepViewBuilder"/> and Telegram's
/// <see cref="InlineKeyboardMarkup"/>. Each <see cref="WizardAction"/>
/// becomes its own row (matching the pre-refactor Telegram layout).
/// <see cref="WizardActionStyle"/> is currently ignored by Telegram
/// because the platform has no native primary/danger/success button
/// colours.
/// </summary>
public static class WizardStep
{
public const int MaxTitleLength = WizardStepLimits.MaxTitleLength;
public const int MaxDescriptionLength = WizardStepLimits.MaxDescriptionLength;
public const int MaxSystemLength = WizardStepLimits.MaxSystemLength;
public const int MaxCapacity = WizardStepLimits.MaxCapacity;
public const int MinCapacity = WizardStepLimits.MinCapacity;
public const int MinDurationHours = WizardStepLimits.MinDurationHours;
public const int MaxDurationHours = WizardStepLimits.MaxDurationHours;
/// <summary>
/// Render the platform-neutral view into a (text, Telegram keyboard)
/// pair. Used by the wizard's surrounding code (router, create
/// handler) when it needs to send a fresh draft message or render
/// the resume/reset menu.
/// </summary>
public static (string Text, InlineKeyboardMarkup Keyboard) Render(
WizardDraft draft,
WizardPayload payload,
IReadOnlyList<WizardClubOption>? clubs = null)
{
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
return (text, ToInlineKeyboard(actions));
}
/// <summary>
/// Convert a flat list of <see cref="WizardAction"/>s into a
/// Telegram keyboard. Each action is placed in its own row to
/// preserve the pre-refactor visual layout.
/// </summary>
public static InlineKeyboardMarkup ToInlineKeyboard(IReadOnlyList<WizardAction> actions)
{
if (actions.Count == 0)
{
return new InlineKeyboardMarkup(Array.Empty<InlineKeyboardButton[]>());
}
var rows = new InlineKeyboardButton[actions.Count][];
for (var i = 0; i < actions.Count; i++)
{
rows[i] = new[]
{
InlineKeyboardButton.WithCallbackData(actions[i].Label, actions[i].Payload),
};
}
return new InlineKeyboardMarkup(rows);
}
}
@@ -1,25 +1,77 @@
using GmRelay.Shared.Platform;
using System.Text;
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
public sealed class ExportCalendarHandler(
GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler sharedHandler)
{
public Task HandleAsync(Message message, CancellationToken cancellationToken)
{
var command = new GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarCommand(
new PlatformGroup(
PlatformKind.Telegram,
message.Chat.Id.ToString(),
message.Chat.Title ?? "Private Chat",
message.MessageThreadId?.ToString()),
new PlatformUser(
PlatformKind.Telegram,
message.From?.Id.ToString() ?? string.Empty,
message.From?.FirstName ?? string.Empty,
message.From?.Username));
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
return sharedHandler.HandleAsync(command, cancellationToken);
public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<CalendarSessionDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE g.telegram_chat_id = @ChatId
AND s.status = @Planned
AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken: cancellationToken);
return;
}
var sb = new StringBuilder();
sb.AppendLine("BEGIN:VCALENDAR");
sb.AppendLine("VERSION:2.0");
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
foreach (var s in sessionsList)
{
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
sb.AppendLine("BEGIN:VEVENT");
sb.AppendLine($"UID:{s.Id}@gmrelay");
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{s.Title}");
// Escape special chars according to iCal standards (RFC 5545) -- simple escaping for summary
// In a fuller implementation we'd escape \r\n, commas, etc. But titles are mostly plain text.
sb.AppendLine("END:VEVENT");
}
sb.AppendLine("END:VCALENDAR");
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
using var stream = new MemoryStream(bytes);
var inputFile = InputFile.FromStream(stream, "schedule.ics");
await botClient.SendDocument(
chatId: message.Chat.Id,
document: inputFile,
caption: "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
messageThreadId: message.MessageThreadId,
cancellationToken: cancellationToken);
}
}
@@ -1,5 +1,7 @@
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Platform;
using Dapper;
using Npgsql;
using Telegram.Bot;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
@@ -10,88 +12,122 @@ public sealed record DeleteSessionCommand(
long ChatId,
int MessageId);
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, bool CanManage, int? ThreadId);
public sealed class DeleteSessionHandler(
GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler sharedHandler,
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler listSessionsHandler,
IPlatformMessenger messenger,
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
ILogger<DeleteSessionHandler> logger)
{
public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct)
{
var platformUser = new PlatformUser(
PlatformKind.Telegram,
command.TelegramUserId.ToString(),
string.Empty,
null);
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var platformGroup = new PlatformGroup(
PlatformKind.Telegram,
command.ChatId.ToString(),
string.Empty);
// 1. Fetch session and verify group manager.
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.thread_id AS ThreadId,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
""",
new { command.SessionId, command.TelegramUserId }, transaction);
var scheduleMessage = TelegramPlatformIds.Message(command.ChatId, null, command.MessageId);
var sharedCommand = new GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionCommand(
command.SessionId,
platformUser,
platformGroup,
scheduleMessage);
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
if (!result.Success)
if (session == null)
{
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("owner")),
ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
return;
}
// 4. If no sessions are left in a bot-owned forum topic, delete the topic.
if (result.ThreadId.HasValue &&
TelegramTopicRouting.ShouldDeleteForumTopic(result.TopicCreatedByBot, result.RemainingInTopic))
if (!session.CanManage)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может удалять сессию.", showAlert: true, cancellationToken: ct);
return;
}
// 2. Delete session
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
// 3. Check if any sessions are left in the batch
var remainingInBatch = await connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM sessions WHERE batch_id = @BatchId",
new { BatchId = session.BatchId }, transaction);
await transaction.CommitAsync(ct);
// 4. If no sessions left and we have a forum topic, delete the topic
if (remainingInBatch == 0 && session.ThreadId.HasValue)
{
try
{
await messenger.DeleteThreadAsync(
new PlatformGroup(PlatformKind.Telegram, command.ChatId.ToString(), string.Empty, null, result.ThreadId.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)),
ct);
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", result.ThreadId.Value, result.GroupId);
await bot.DeleteForumTopic(command.ChatId, session.ThreadId.Value, cancellationToken: ct);
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", session.ThreadId.Value, session.BatchId);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", result.ThreadId.Value);
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", session.ThreadId.Value);
}
}
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия удалена!", cancellationToken: ct);
// 5. Update the /listsessions message
var listCommand = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(platformGroup, platformUser);
var listResult = await listSessionsHandler.HandleAsync(listCommand, ct);
if (listResult.Sessions.Count == 0)
{
try
// 5. Update the /listsessions message (we delete the message or edit it to remove the button)
// A simple way is to re-render the list:
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
var sessions = await readConnection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC",
new
{
await messenger.UpdateGroupMessageAsync(
scheduleMessage,
"📭 В этой группе нет предстоящих игр.",
[],
ct);
}
catch { }
ChatId = command.ChatId,
command.TelegramUserId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
{
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch {}
return;
}
var text = SessionListMessageRenderer.RenderText(listResult.Sessions);
var actions = listResult.CanManage ? SessionListMessageRenderer.RenderActions(listResult.Sessions) : [];
var renderResult = SessionListMessageRenderer.Render(sessionsList);
try
{
await messenger.UpdateGroupMessageAsync(scheduleMessage, text, actions, ct);
await bot.EditMessageText(
command.ChatId,
command.MessageId,
renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
}
catch (Exception ex)
{
@@ -1,37 +1,114 @@
using GmRelay.Shared.Platform;
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
internal static class SessionListMessageRenderer
{
public static (string Text, InlineKeyboardMarkup? Markup) Render(IReadOnlyList<SessionListItemDto> sessions)
{
var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var session in sessions)
{
var seats = session.MaxPlayers.HasValue
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
}
var canManage = sessions.Count > 0 && sessions.First().CanManage;
if (!canManage)
{
return (text, null);
}
var buttons = new List<InlineKeyboardButton[]>();
foreach (var session in sessions)
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"❌ {dateTitle}", $"cancel_session:{session.Id}"),
InlineKeyboardButton.WithCallbackData($"⏰ {dateTitle}", $"reschedule_session:{session.Id}")
]);
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
{
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"⬆️ Из ожидания {dateTitle}", $"promote_waitlist:{session.Id}")
]);
}
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"🗑 Удалить {dateTitle}", $"delete_session:{session.Id}")
]);
}
return (text, new InlineKeyboardMarkup(buttons));
}
}
public sealed class ListSessionsHandler(
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler sharedHandler,
IPlatformMessenger messenger)
NpgsqlDataSource dataSource,
ITelegramBotClient botClient)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{
var command = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(
new PlatformGroup(
PlatformKind.Telegram,
message.Chat.Id.ToString(),
message.Chat.Title ?? "Private Chat",
message.MessageThreadId?.ToString()),
new PlatformUser(
PlatformKind.Telegram,
message.From?.Id.ToString() ?? string.Empty,
message.From?.FirstName ?? string.Empty,
message.From?.Username));
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var result = await sharedHandler.HandleAsync(command, cancellationToken);
var sessions = await connection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC",
new
{
ChatId = message.Chat.Id,
TelegramUserId = message.From?.Id,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
if (result.Sessions.Count == 0)
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
{
await messenger.SendGroupMessageAsync(command.Group, "📭 В этой группе нет предстоящих игр.", cancellationToken);
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "📭 В этой группе нет предстоящих игр.",
cancellationToken: cancellationToken);
return;
}
var text = SessionListMessageRenderer.RenderText(result.Sessions);
var actions = result.CanManage ? SessionListMessageRenderer.RenderActions(result.Sessions) : [];
var renderResult = SessionListMessageRenderer.Render(sessionsList);
await messenger.SendGroupMessageAsync(command.Group, text, actions, cancellationToken);
await botClient.SendMessage(
chatId: message.Chat.Id,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
}
@@ -1,63 +0,0 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Features.Sessions.ListSessions;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal static class SessionListMessageRenderer
{
public static string RenderText(IReadOnlyList<SessionListItemDto> sessions)
{
var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var session in sessions)
{
var seats = session.MaxPlayers.HasValue
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
}
return text;
}
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
{
if (sessions.Count == 0 || !sessions.First().CanManage)
{
return [];
}
var actions = new List<PlatformMessageAction>();
foreach (var session in sessions)
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
actions.Add(new PlatformMessageAction(
$"cancel_session:{session.Id}",
$"❌ {dateTitle}",
$"cancel_session:{session.Id}"));
actions.Add(new PlatformMessageAction(
$"reschedule_session:{session.Id}",
$"⏰ {dateTitle}",
$"reschedule_session:{session.Id}"));
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
{
actions.Add(new PlatformMessageAction(
$"promote_waitlist:{session.Id}",
$"⬆️ Из ожидания {dateTitle}",
$"promote_waitlist:{session.Id}"));
}
actions.Add(new PlatformMessageAction(
$"delete_session:{session.Id}",
$"🗑 Удалить {dateTitle}",
$"delete_session:{session.Id}"));
}
return actions;
}
}
@@ -1,8 +1,6 @@
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;
using Telegram.Bot;
@@ -12,156 +10,244 @@ using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record AwaitingProposalDto(
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
Guid BatchId, int? BatchMessageId, long TelegramChatId, string NotificationMode);
internal sealed record VoteParticipantDto(
Guid PlayerId,
string DisplayName,
string? TelegramUsername,
long TelegramId = 0);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Telegram adapter for reschedule time input.
/// Delegates core logic to the shared handler, then performs Telegram-specific
/// message sending, DM notifications, vote_message_id storage, and cleanup.
/// Handles text input from the GM who has an AwaitingTime proposal.
/// Parses reschedule options with a voting deadline, creates a voting message,
/// and tags all participants.
/// If no participants are registered, reschedules immediately.
/// </summary>
public sealed class HandleRescheduleTimeInputHandler(
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler sharedHandler,
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender,
ILogger<HandleRescheduleTimeInputHandler> logger)
{
/// <summary>
/// Attempts to handle a text message as reschedule time input.
/// Returns true if it was handled (i.e. user had an AwaitingTime proposal).
/// </summary>
public async Task<bool> TryHandleAsync(Message message, CancellationToken ct)
{
if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
return false;
var command = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputCommand(
new PlatformUser(
PlatformKind.Telegram,
message.From.Id.ToString(),
message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"),
message.From.Username),
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title),
message.Text.Trim());
var gmTelegramId = message.From.Id;
var chatId = message.Chat.Id;
var text = message.Text.Trim();
var result = await sharedHandler.HandleAsync(command, ct);
if (!result.Handled)
await using var connection = await dataSource.OpenConnectionAsync(ct);
// 1. Check if this GM has an AwaitingTime proposal in this chat
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.notification_mode AS NotificationMode
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.proposed_by = @GmId
AND rp.status = 'AwaitingTime'
AND g.telegram_chat_id = @ChatId
AND EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @GmId
)
ORDER BY rp.created_at DESC
LIMIT 1
""",
new { GmId = gmTelegramId, ChatId = chatId });
if (proposal is null)
return false;
if (!string.IsNullOrEmpty(result.ReplyText) && !result.IsRescheduledImmediately)
// 2. Parse voting input
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
{
await messenger.SendGroupMessageAsync(
command.Group,
$"""⚠️ {result.ReplyText}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>""",
ct);
await bot.SendMessage(
chatId: chatId,
text: $"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
return true;
}
if (result.IsRescheduledImmediately)
{
if (result.UpdatedView is not null && result.BatchMessageId.HasValue)
{
await TryUpdateBatchMessage(
command.Group,
result.UpdatedView,
TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, result.BatchMessageId.Value),
ct);
}
// 3. Load participants (non-GM) signed up for this session
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
await messenger.SendGroupMessageAsync(command.Group, result.ReplyText!, ct);
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
// 4. If no participants — reschedule immediately
if (participants.Count == 0)
{
await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct);
await TryDeleteMessage(chatId, message.MessageId, ct);
return true;
}
// Voting mode
// 5. Create voting message
await using var transaction = await connection.BeginTransactionAsync(ct);
var options = votingInput.Options
.Select((proposedAt, index) => new RescheduleOptionDto(
Guid.NewGuid(),
index + 1,
proposedAt))
.ToList();
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @ChatId
WHERE id = @Id
""",
new { votingInput.Deadline, ChatId = chatId, Id = proposal.Id },
transaction);
foreach (var option in options)
{
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
""",
new
{
option.OptionId,
ProposalId = proposal.Id,
option.ProposedAt,
option.DisplayOrder
},
transaction);
}
await transaction.CommitAsync(ct);
var voteText = BuildVotingMessage(
result.Title!,
result.CurrentScheduledAt,
result.VotingDeadlineAt!.Value,
result.Options,
result.Participants,
proposal.Title,
proposal.CurrentScheduledAt,
votingInput.Deadline,
options,
participants,
[]);
var keyboard = BuildVotingKeyboard(result.Options);
var keyboard = BuildVotingKeyboard(options);
var voteMsg = await bot.SendMessage(
chatId: message.Chat.Id,
messageThreadId: message.MessageThreadId,
chatId: chatId,
text: voteText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: ct);
var mode = await GetNotificationModeAsync(result.ProposalId!.Value, ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var optionsText = string.Join(
"\n",
result.Options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
var directText = $"""
🔄 <b>Голосование за перенос сессии</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
📅 Текущее время: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
🗳 Варианты:
{optionsText}
⏳ Дедлайн: <b>{result.VotingDeadlineAt.Value.FormatMoscow()}</b> (МСК)
⏳ Дедлайн: <b>{votingInput.Deadline.FormatMoscow()}</b> (МСК)
Проголосуйте кнопкой в групповом сообщении.
""";
await directSender.SendAsync(
result.Participants.Select(p => new DirectNotificationRecipient(
participants.Select(p => new DirectNotificationRecipient(
p.TelegramId,
p.DisplayName)),
directText,
"reschedule-vote",
result.ProposalId.Value,
proposal.SessionId,
ct);
}
await using var connection = await dataSource.OpenConnectionAsync(ct);
// Store vote message ID
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
new { MsgId = voteMsg.MessageId, Id = result.ProposalId.Value });
new { MsgId = voteMsg.MessageId, Id = proposal.Id });
logger.LogInformation(
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
result.ProposalId.Value,
result.ProposalId.Value,
result.Options.Count,
result.VotingDeadlineAt.Value);
proposal.SessionId,
proposal.Id,
options.Count,
votingInput.Deadline);
// Delete GM's time input message
await TryDeleteMessage(chatId, message.MessageId, ct);
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
return true;
}
private async Task<SessionNotificationMode> GetNotificationModeAsync(Guid proposalId, CancellationToken ct)
private async Task RescheduleImmediately(
NpgsqlConnection connection, AwaitingProposalDto proposal,
DateTimeOffset newTime, long chatId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var raw = await connection.QuerySingleOrDefaultAsync<string?>(
"""
SELECT s.notification_mode
FROM sessions s
JOIN reschedule_proposals rp ON rp.session_id = s.id
WHERE rp.id = @Id
""",
new { Id = proposalId });
return SessionNotificationModeExtensions.FromDatabaseValue(raw ?? string.Empty);
}
await using var transaction = await connection.BeginTransactionAsync(ct);
private async Task TryUpdateBatchMessage(
PlatformGroup group,
SessionBatchViewModel view,
PlatformMessageRef scheduleMessage,
CancellationToken ct)
{
try
{
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(group, view, scheduleMessage),
ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule");
}
await connection.ExecuteAsync(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
new { NewTime = newTime, Id = proposal.Id },
transaction);
await transaction.CommitAsync(ct);
await bot.SendMessage(
chatId: chatId,
text: $"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
// Re-render batch message with updated time
await TryUpdateBatchMessage(proposal, ct);
logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId);
}
internal static string BuildVotingMessage(
@@ -183,7 +269,7 @@ public sealed class HandleRescheduleTimeInputHandler(
var lines = new List<string>
{
$"""🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>""",
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
"",
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
@@ -264,6 +350,54 @@ public sealed class HandleRescheduleTimeInputHandler(
"dd.MM HH:mm",
System.Globalization.CultureInfo.InvariantCulture);
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
{
try
{
await using var conn = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await conn.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
""",
new { proposal.BatchId })).ToList();
if (proposal.BatchMessageId.HasValue)
{
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value,
text: renderResult.Text,
replyMarkup: renderResult.Markup,
ct);
}
else
{
logger.LogWarning("No batch_message_id stored for session {SessionId}, cannot edit batch message in-place", proposal.SessionId);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule for session {SessionId}", proposal.SessionId);
}
}
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
{
try
@@ -1,7 +1,6 @@
using GmRelay.Bot.Infrastructure.Telegram;
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -13,50 +12,147 @@ 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(
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<HandleRescheduleVoteHandler> logger)
{
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
{
var platformUser = new PlatformUser(
PlatformKind.Telegram,
command.TelegramUserId.ToString(),
string.Empty,
null);
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var platformGroup = new PlatformGroup(
PlatformKind.Telegram,
command.ChatId.ToString(),
string.Empty);
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { command.OptionId },
transaction);
var sharedCommand = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteCommand(
command.OptionId,
platformUser,
platformGroup,
command.CallbackQueryId,
TelegramPlatformIds.Message(command.ChatId, null, command.MessageId));
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
if (!result.Success)
if (proposal is null)
{
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("дедлайн")),
ct);
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Голосование уже завершено или не найдено.",
cancellationToken: ct);
return;
}
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
{
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Дедлайн уже прошёл. Результаты скоро будут применены.",
showAlert: true,
cancellationToken: ct);
return;
}
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
transaction);
if (playerId is null)
{
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Вы не являетесь участником этой сессии.",
cancellationToken: ct);
return;
}
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id,
voted_at = now()
""",
new
{
ProposalId = proposal.Id,
PlayerId = playerId.Value,
command.OptionId
},
transaction);
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
result.Title!,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Options,
result.Participants,
result.Votes);
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(result.Options);
proposal.Title,
proposal.CurrentScheduledAt,
proposal.VotingDeadlineAt,
options,
participants,
votes);
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
try
{
@@ -70,11 +166,12 @@ public sealed class HandleRescheduleVoteHandler(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", result.ProposalId);
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
}
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
ct);
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Ваш голос учтён. До дедлайна его можно изменить.",
cancellationToken: ct);
}
}
@@ -1,8 +1,7 @@
using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -13,7 +12,6 @@ public sealed record InitiateRescheduleCommand(
long TelegramUserId,
string CallbackQueryId,
long ChatId,
int? MessageThreadId,
int MessageId);
// ── DTOs ─────────────────────────────────────────────────────────────
@@ -29,7 +27,7 @@ internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage);
/// </summary>
public sealed class InitiateRescheduleHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
ITelegramBotClient bot,
ILogger<InitiateRescheduleHandler> logger)
{
public async Task HandleAsync(InitiateRescheduleCommand command, CancellationToken ct)
@@ -45,23 +43,23 @@ public sealed class InitiateRescheduleHandler(
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
AND p.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId AND s.status != @Cancelled
""",
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString(), Cancelled = SessionStatus.Cancelled });
new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled });
if (session is null)
{
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
return;
}
if (!session.CanManage)
{
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может переносить сессию.", ct, showAlert: true);
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Только owner или co-GM может переносить сессию.", showAlert: true, cancellationToken: ct);
return;
}
@@ -77,43 +75,38 @@ public sealed class InitiateRescheduleHandler(
if (hasActive)
{
await AnswerAsync(command.CallbackQueryId, "Уже есть активный запрос на перенос этой сессии.", ct, showAlert: true);
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Уже есть активный запрос на перенос этой сессии.", showAlert: true, cancellationToken: ct);
return;
}
// 3. Create proposal in AwaitingTime status
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_proposals (session_id, proposed_by_external_user_id, source_platform, status)
VALUES (@SessionId, @ProposedBy, 'Telegram', 'AwaitingTime')
INSERT INTO reschedule_proposals (session_id, proposed_by, status)
VALUES (@SessionId, @GmId, 'AwaitingTime')
""",
new { command.SessionId, ProposedBy = command.TelegramUserId.ToString() });
new { command.SessionId, GmId = command.TelegramUserId });
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
// 4. Prompt GM in chat
await AnswerAsync(command.CallbackQueryId, "Введите 2-3 варианта времени и дедлайн голосования.", ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Введите 2-3 варианта времени и дедлайн голосования.", cancellationToken: ct);
var prompt = string.Join(
"\n",
new[]
{
$"⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.",
"",
"Формат:",
"<code>25.04.2026 19:30",
"26.04.2026 18:00",
"Дедлайн: 25.04.2026 12:00</code>",
"",
"Дедлайн должен быть в будущем и раньше первого предложенного времени."
});
await bot.SendMessage(
chatId: command.ChatId,
text: $"""
⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
prompt,
ct);
Формат:
<code>25.04.2026 19:30
26.04.2026 18:00
Дедлайн: 25.04.2026 12:00</code>
Дедлайн должен быть в будущем и раньше первого предложенного времени.
""",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
}
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
}
@@ -1,13 +1,13 @@
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
public enum RescheduleVoteOutcome
internal enum RescheduleVoteOutcome
{
Pending,
Rejected,
Approved
}
public sealed record RescheduleVoteDecision(
internal sealed record RescheduleVoteDecision(
RescheduleVoteOutcome Outcome,
string Reason,
Guid? SelectedOptionId = null,
@@ -15,7 +15,7 @@ public sealed record RescheduleVoteDecision(
bool ShouldRescheduleSession = false,
bool ShouldResetParticipantRsvps = false);
public static class RescheduleVoteRules
internal static class RescheduleVoteRules
{
public static RescheduleVoteDecision SelectWinner(IReadOnlyList<RescheduleOptionVoteCount> voteCounts)
{
@@ -49,8 +49,8 @@ public static class RescheduleVoteRules
{
return new RescheduleVoteDecision(
Outcome: RescheduleVoteOutcome.Rejected,
Reason: "Один из участников отклонил перенос.",
CallbackText: "Вы проголосовали против переноса.");
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.");
}
var everyoneApproved = approvedParticipants == totalParticipants;
@@ -58,11 +58,11 @@ public 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);
}
@@ -1,25 +1,30 @@
using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Bot.Features.Notifications;
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 TelegramProposalFieldsDto(
int? VoteMessageId,
internal sealed record DueRescheduleProposalDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
int? BatchMessageId,
int? VoteMessageId,
long TelegramChatId,
int? ThreadId);
string NotificationMode);
public sealed class RescheduleVotingDeadlineService(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
PlatformDirectNotificationSender directSender,
RescheduleVotingFinalizer finalizer,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -43,7 +48,17 @@ public sealed class RescheduleVotingDeadlineService(
{
try
{
var proposalIds = await finalizer.GetDueProposalIdsAsync("Telegram", 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()
ORDER BY voting_deadline_at
LIMIT 25
""")).ToList();
foreach (var proposalId in proposalIds)
{
@@ -61,115 +76,224 @@ public sealed class RescheduleVotingDeadlineService(
private async Task FinalizeProposal(Guid proposalId, CancellationToken ct)
{
var result = await finalizer.FinalizeAsync(proposalId, ct);
if (result is null)
return;
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>(
await using var transaction = await connection.BeginTransactionAsync(ct);
var proposal = await connection.QuerySingleOrDefaultAsync<DueRescheduleProposalDto>(
"""
SELECT rp.vote_message_id AS VoteMessageId,
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,
s.batch_message_id AS BatchMessageId,
g.external_group_id::BIGINT AS TelegramChatId,
s.thread_id AS ThreadId
s.notification_mode AS NotificationMode,
g.telegram_chat_id AS TelegramChatId
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 });
new { ProposalId = proposalId },
transaction);
if (telegramFields is null)
{
logger.LogWarning("Could not find Telegram fields for proposal {ProposalId}", proposalId);
if (proposal is null)
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,
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 = result.Participants
.Select(p => TelegramPlatformIds.User(p.TelegramId, p.DisplayName))
var directRecipients = participants
.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName))
.ToList();
await TryUpdateVoteMessage(result, telegramFields, ct);
await transaction.CommitAsync(ct);
if (result.SelectedOption is not null)
await TryUpdateVoteMessage(proposal, options, participants, votes, decision, selectedOption, ct);
if (selectedOption is not null)
{
await TryUpdateBatchMessage(result, telegramFields, ct);
await TryUpdateBatchMessage(proposal, ct);
}
var mode = SessionNotificationModeExtensions.FromDatabaseValue(result.NotificationMode);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await SendDirectResult(result, directRecipients, ct);
await SendDirectResult(proposal, directRecipients, decision, selectedOption, ct);
}
logger.LogInformation(
"Updated Telegram messages for finalized reschedule proposal {ProposalId} for session {SessionId}",
result.ProposalId,
result.SessionId);
"Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
proposal.Id,
proposal.SessionId,
decision.Outcome);
}
private async Task TryUpdateVoteMessage(
RescheduleVotingFinalizerResult result,
TelegramProposalFieldsDto telegramFields,
DueRescheduleProposalDto proposal,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyList<RescheduleOptionVoteDto> votes,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
CancellationToken ct)
{
if (telegramFields.VoteMessageId is null)
if (proposal.VoteMessageId is null)
return;
try
{
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);
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);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", result.ProposalId);
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", proposal.Id);
}
}
private async Task TryUpdateBatchMessage(
RescheduleVotingFinalizerResult result,
TelegramProposalFieldsDto telegramFields,
CancellationToken ct)
private async Task TryUpdateBatchMessage(DueRescheduleProposalDto proposal, CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { result.BatchId })).ToList();
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
p.telegram_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -177,49 +301,63 @@ 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 { result.BatchId })).ToList();
new { proposal.BatchId })).ToList();
if (telegramFields.BatchMessageId.HasValue)
if (proposal.BatchMessageId.HasValue)
{
var view = SessionBatchViewBuilder.Build(result.Title, batchSessions, batchParticipants);
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
var renderResult = TelegramSessionBatchRenderer.Render(view);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
view,
TelegramPlatformIds.Message(telegramFields.TelegramChatId, telegramFields.ThreadId, telegramFields.BatchMessageId.Value)),
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value,
text: renderResult.Text,
replyMarkup: renderResult.Markup,
ct);
}
else
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
$"Расписание обновлено после голосования за перенос сессии \"{System.Net.WebUtility.HtmlEncode(result.Title)}\".",
ct);
await bot.SendMessage(
chatId: proposal.TelegramChatId,
text: $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».",
parseMode: ParseMode.Html,
cancellationToken: ct);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", result.ProposalId);
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", proposal.Id);
}
}
private async Task SendDirectResult(
RescheduleVotingFinalizerResult result,
IReadOnlyList<PlatformUser> recipients,
DueRescheduleProposalDto proposal,
IReadOnlyList<DirectNotificationRecipient> recipients,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
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,
result.SessionId,
result.Title,
result.SelectedOption?.ProposedAt.UtcDateTime ?? result.CurrentScheduledAt,
joinLink: null,
actorDisplayName: null,
reason: result.SelectedOption is null ? result.Decision.Reason : null,
htmlText,
selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
proposal.SessionId,
ct);
}
}
@@ -1,9 +1,9 @@
using System.Text.RegularExpressions;
using GmRelay.Shared.Domain;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
public sealed record RescheduleVotingInput(
internal sealed record RescheduleVotingInput(
IReadOnlyList<DateTimeOffset> Options,
DateTimeOffset Deadline)
{
@@ -93,3 +93,18 @@ public 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);
@@ -1,101 +0,0 @@
using System.Net;
namespace GmRelay.Bot.Infrastructure.Health;
public sealed class BotHealthCheckHostedService : IHostedService
{
private readonly ILogger<BotHealthCheckHostedService> _logger;
private readonly string _prefix;
private HttpListener? _listener;
private CancellationTokenSource? _cts;
private Task? _listenerTask;
public BotHealthCheckHostedService(
ILogger<BotHealthCheckHostedService> logger,
IConfiguration configuration)
{
_logger = logger;
_prefix = configuration.GetValue("HealthCheck:Prefix", "http://+:8081/")!;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_cts = new CancellationTokenSource();
_listener = new HttpListener();
_listener.Prefixes.Add(_prefix);
_listener.Start();
_logger.LogInformation("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("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 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 health check request");
}
finally
{
response.Close();
}
}
}
@@ -1,13 +0,0 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Infrastructure.Scheduling;
public sealed class SystemClock : ISystemClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
public sealed class FakeSystemClock : ISystemClock
{
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow;
}
@@ -0,0 +1,157 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using Npgsql;
namespace GmRelay.Bot.Infrastructure.Scheduling;
/// <summary>
/// Stateless scheduler: wakes every 60 seconds, queries PostgreSQL for actionable sessions.
/// Two triggers:
/// T-24h: send confirmation request with inline keyboard
/// T-5min: send join link to all confirmed players
///
/// If the Raspberry Pi reboots, nothing is lost — all state is in the DB.
/// </summary>
public sealed class SessionSchedulerService(
NpgsqlDataSource dataSource,
SendConfirmationHandler confirmationHandler,
SendOneHourReminderHandler oneHourReminderHandler,
SendJoinLinkHandler joinLinkHandler,
ILogger<SessionSchedulerService> logger) : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24);
private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1);
private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Session scheduler started (interval: {Interval})", TickInterval);
using var timer = new PeriodicTimer(TickInterval);
// Run immediately on startup, then on each tick
do
{
try
{
await ProcessConfirmationTriggers(stoppingToken);
await ProcessOneHourReminderTriggers(stoppingToken);
await ProcessJoinLinkTriggers(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Scheduler tick failed, will retry next tick");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
logger.LogInformation("Session scheduler stopped");
}
/// <summary>
/// T-1h trigger: process direct reminders according to the session notification mode.
/// </summary>
private async Task ProcessOneHourReminderTriggers(CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var sessionIds = 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
""",
new
{
Confirmed = SessionStatus.Confirmed,
ConfirmationSent = SessionStatus.ConfirmationSent,
LeadTime = OneHourReminderLeadTime
});
foreach (var sessionId in sessionIds)
{
try
{
await oneHourReminderHandler.HandleAsync(sessionId, ct);
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId);
}
}
}
/// <summary>
/// T-24h trigger: find sessions that need confirmation requests sent.
/// Condition: status='Planned' AND scheduled_at minus 24h is in the past.
/// </summary>
private async Task ProcessConfirmationTriggers(CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var sessionIds = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status = @Planned
AND scheduled_at - @LeadTime <= now()
""",
new { Planned = SessionStatus.Planned, LeadTime = ConfirmationLeadTime });
foreach (var sessionId in sessionIds)
{
try
{
await confirmationHandler.HandleAsync(sessionId, ct);
logger.LogInformation("Confirmation sent for session {SessionId}", sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send confirmation for session {SessionId}", sessionId);
}
}
}
/// <summary>
/// T-5min trigger: find confirmed sessions that need join links sent.
/// Condition: status='Confirmed' AND scheduled_at minus 5min is in the past AND link not yet sent.
/// </summary>
private async Task ProcessJoinLinkTriggers(CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var sessionIds = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status = @Confirmed
AND scheduled_at - @LeadTime <= now()
AND link_message_id IS NULL
""",
new { Confirmed = SessionStatus.Confirmed, LeadTime = JoinLinkLeadTime });
foreach (var sessionId in sessionIds)
{
try
{
await joinLinkHandler.HandleAsync(sessionId, ct);
logger.LogInformation("Join link sent for session {SessionId}", sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send join link for session {SessionId}", sessionId);
}
}
}
}
@@ -33,7 +33,7 @@ public static class BatchMessageEditor
replyMarkup: replyMarkup,
cancellationToken: ct);
}
catch (global::Telegram.Bot.Exceptions.ApiRequestException ex)
catch (Telegram.Bot.Exceptions.ApiRequestException ex)
when (ex.Message.Contains("there is no text in the message", StringComparison.OrdinalIgnoreCase))
{
// The batch message is a photo — use EditMessageCaption instead.
@@ -1,29 +0,0 @@
using System.Globalization;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Infrastructure.Telegram;
internal static class TelegramPlatformIds
{
public static PlatformGroup Group(long chatId, int? threadId = null, string? displayName = null) =>
new(
PlatformKind.Telegram,
chatId.ToString(CultureInfo.InvariantCulture),
displayName ?? "Telegram chat",
ExternalChannelId: chatId.ToString(CultureInfo.InvariantCulture),
ExternalThreadId: threadId?.ToString(CultureInfo.InvariantCulture));
public static PlatformUser User(long telegramId, string displayName, string? username = null) =>
new(
PlatformKind.Telegram,
telegramId.ToString(CultureInfo.InvariantCulture),
displayName,
username);
public static PlatformMessageRef Message(long chatId, int? threadId, int messageId) =>
new(
PlatformKind.Telegram,
chatId.ToString(CultureInfo.InvariantCulture),
threadId?.ToString(CultureInfo.InvariantCulture),
messageId.ToString(CultureInfo.InvariantCulture));
}
@@ -1,517 +0,0 @@
using System.Globalization;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram;
public sealed class TelegramPlatformMessenger(
ITelegramBotClient bot,
ILogger<TelegramPlatformMessenger> logger) : IPlatformMessenger
{
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
EnsureTelegram(message.Group.Platform);
var chatId = ParseLong(message.Group.ExternalGroupId);
var threadId = ParseNullableInt(message.Group.ExternalThreadId);
var renderResult = TelegramSessionBatchRenderer.Render(message.View);
Message sentMessage;
if (!string.IsNullOrWhiteSpace(message.ImageReference) && renderResult.Text.Length <= 1024)
{
try
{
sentMessage = await bot.SendPhoto(
chatId: chatId,
messageThreadId: threadId,
photo: InputFile.FromString(message.ImageReference),
caption: renderResult.Text,
parseMode: ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send Telegram schedule image for group {ExternalGroupId}", message.Group.ExternalGroupId);
sentMessage = await SendScheduleTextMessage(chatId, threadId, renderResult.Text, renderResult.Markup, ct);
}
}
else
{
if (!string.IsNullOrWhiteSpace(message.ImageReference))
{
await TrySendScheduleImageOnly(chatId, threadId, message.View.Title, message.ImageReference, ct);
}
sentMessage = await SendScheduleTextMessage(chatId, threadId, renderResult.Text, renderResult.Markup, ct);
}
return new PlatformMessageRef(
PlatformKind.Telegram,
message.Group.ExternalGroupId,
message.Group.ExternalThreadId,
sentMessage.MessageId.ToString(CultureInfo.InvariantCulture));
}
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
{
EnsureTelegram(message.Group.Platform);
var existingMessage = message.ExistingMessage;
if (existingMessage is null)
{
throw new ArgumentException("Existing schedule message reference is required.", nameof(message));
}
EnsureTelegram(existingMessage.Platform);
if (!string.Equals(message.Group.ExternalGroupId, existingMessage.ExternalGroupId, StringComparison.Ordinal) ||
!string.Equals(message.Group.ExternalThreadId, existingMessage.ExternalThreadId, StringComparison.Ordinal))
{
throw new ArgumentException("Existing schedule message reference must match the schedule group.", nameof(message));
}
var renderResult = TelegramSessionBatchRenderer.Render(message.View);
await BatchMessageEditor.EditBatchMessageAsync(
bot,
chatId: ParseLong(existingMessage.ExternalGroupId),
messageId: ParseInt(existingMessage.ExternalMessageId),
text: renderResult.Text,
replyMarkup: renderResult.Markup,
ct);
}
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
{
EnsureTelegram(group.Platform);
return bot.SendMessage(
chatId: ParseLong(group.ExternalGroupId),
messageThreadId: ParseNullableInt(group.ExternalThreadId),
text: htmlText,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
EnsureTelegram(group.Platform);
await bot.SendMessage(
chatId: ParseLong(group.ExternalGroupId),
messageThreadId: ParseNullableInt(group.ExternalThreadId),
text: htmlText,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(actions),
cancellationToken: ct);
}
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
EnsureTelegram(messageRef.Platform);
await bot.EditMessageText(
chatId: ParseLong(messageRef.ExternalGroupId),
messageId: ParseInt(messageRef.ExternalMessageId),
text: htmlText,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(actions),
cancellationToken: ct);
}
public async Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
{
EnsureTelegram(group.Platform);
var topic = await bot.CreateForumTopic(
chatId: ParseLong(group.ExternalGroupId),
name: title,
cancellationToken: ct);
return new PlatformMessageRef(
PlatformKind.Telegram,
group.ExternalGroupId,
topic.MessageThreadId.ToString(CultureInfo.InvariantCulture),
string.Empty);
}
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct)
{
EnsureTelegram(group.Platform);
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
{
return Task.CompletedTask;
}
return bot.DeleteForumTopic(
ParseLong(group.ExternalGroupId),
ParseInt(group.ExternalThreadId),
cancellationToken: ct);
}
public Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
{
EnsureTelegram(messageRef.Platform);
return bot.DeleteMessage(
ParseLong(messageRef.ExternalGroupId),
ParseInt(messageRef.ExternalMessageId),
cancellationToken: ct);
}
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
EnsureTelegram(message.Recipient.Platform);
return bot.SendMessage(
chatId: ParseLong(message.Recipient.ExternalUserId),
text: message.HtmlText,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) =>
bot.AnswerCallbackQuery(
callbackQueryId: reply.InteractionId,
text: reply.Text,
showAlert: reply.ShowAlert,
cancellationToken: ct);
public async Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
{
EnsureTelegram(file.Group.Platform);
using var stream = new MemoryStream(file.Content);
await bot.SendDocument(
chatId: ParseLong(file.Group.ExternalGroupId),
messageThreadId: ParseNullableInt(file.Group.ExternalThreadId),
document: InputFile.FromStream(stream, file.FileName),
caption: file.CaptionHtml,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(file.Actions),
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,
string text,
InlineKeyboardMarkup markup,
CancellationToken ct) =>
await bot.SendMessage(
chatId: chatId,
messageThreadId: threadId,
text: text,
parseMode: ParseMode.Html,
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,
string title,
string imageReference,
CancellationToken ct)
{
try
{
await bot.SendPhoto(
chatId: chatId,
messageThreadId: threadId,
photo: InputFile.FromString(imageReference),
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
parseMode: ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send Telegram schedule image for chat {ChatId}", chatId);
}
}
private static InlineKeyboardMarkup? BuildActionsMarkup(IReadOnlyList<PlatformMessageAction> actions)
{
if (actions.Count == 0)
{
return null;
}
return new InlineKeyboardMarkup(
actions.Select(action => new[]
{
Uri.TryCreate(action.Payload, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
? InlineKeyboardButton.WithUrl(action.Label, action.Payload)
: InlineKeyboardButton.WithCallbackData(action.Label, action.Payload)
}));
}
private static void EnsureTelegram(PlatformKind platform)
{
if (platform != PlatformKind.Telegram)
{
throw new NotSupportedException($"Telegram messenger cannot send messages for platform {platform}.");
}
}
private static long ParseLong(string value) => long.Parse(value, CultureInfo.InvariantCulture);
private static int ParseInt(string value) => int.Parse(value, CultureInfo.InvariantCulture);
private static int? ParseNullableInt(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : int.Parse(value, CultureInfo.InvariantCulture);
}
@@ -1,5 +1,3 @@
// NOTE: duplicated in GmRelay.Web/Services/TelegramSessionBatchRenderer.cs
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
@@ -21,11 +19,6 @@ public static class TelegramSessionBatchRenderer
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
if (!string.IsNullOrEmpty(session.JoinLink))
{
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
}
if (session.ActivePlayers.Count > 0)
{
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
@@ -1,40 +0,0 @@
namespace GmRelay.Bot.Infrastructure.Telegram;
public sealed record TelegramTopicDestination(
int? MessageThreadId,
bool ShouldCreateForumTopic,
bool TopicCreatedByBot);
public static class TelegramTopicRouting
{
public const string MissingForumTopicRightsMessage =
"Не удалось создать Telegram topic. Сделайте бота admin и включите право Manage Topics, затем повторите команду.";
public static TelegramTopicDestination ResolveNewScheduleDestination(
bool chatIsForum,
int? incomingMessageThreadId)
{
if (!chatIsForum)
{
return new TelegramTopicDestination(null, ShouldCreateForumTopic: false, TopicCreatedByBot: false);
}
if (incomingMessageThreadId.HasValue)
{
return new TelegramTopicDestination(
incomingMessageThreadId,
ShouldCreateForumTopic: false,
TopicCreatedByBot: false);
}
return new TelegramTopicDestination(null, ShouldCreateForumTopic: true, TopicCreatedByBot: true);
}
public static bool ShouldDeleteForumTopic(bool topicCreatedByBot, int remainingSessionsInTopic) =>
topicCreatedByBot && remainingSessionsInTopic == 0;
public static bool IsMissingForumTopicRightsError(string apiError) =>
apiError.Contains("not enough rights", StringComparison.OrdinalIgnoreCase) ||
apiError.Contains("CHAT_ADMIN_REQUIRED", StringComparison.OrdinalIgnoreCase) ||
apiError.Contains("not an administrator", StringComparison.OrdinalIgnoreCase);
}
@@ -1,23 +1,15 @@
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
using System.Globalization;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Features.Sessions.ListSessions;
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using BotRescheduleVoteHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler;
using GmRelay.Bot.Features.Sessions.ExportCalendar;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
namespace GmRelay.Bot.Infrastructure.Telegram;
@@ -27,7 +19,7 @@ namespace GmRelay.Bot.Infrastructure.Telegram;
/// </summary>
public sealed class UpdateRouter(
HandleRsvpHandler rsvpHandler,
BotCreateSessionHandler createSessionHandler,
CreateSessionHandler createSessionHandler,
JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
@@ -36,49 +28,14 @@ public sealed class UpdateRouter(
ListSessionsHandler listSessionsHandler,
ExportCalendarHandler exportCalendarHandler,
InitiateRescheduleHandler initiateRescheduleHandler,
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
BotRescheduleVoteHandler rescheduleVoteHandler,
SharedWizard wizard,
IWizardDraftRepository drafts,
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
HandleRescheduleVoteHandler rescheduleVoteHandler,
ITelegramBotClient bot,
IConfiguration configuration,
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
{
public async Task RouteAsync(Update update, CancellationToken ct)
{
// 1) Wizard delegation. If the GM has an active (non-expired) draft for this
// (chat, thread, owner), every update routes to the wizard. The wizard is
// responsible for both text input and callback handling.
if (TryGetWizardContext(update, out _, out _, out var ownerId))
{
var draft = await drafts.GetActiveAsync("Telegram", ownerId, ct);
if (draft is not null)
{
// Resume / Reset / Cancel menu callbacks live in the router because
// they cross draft boundaries (reset deletes + recreates a fresh
// draft, which the wizard instance doesn't know how to do).
if (await TryHandleDraftControlCallbackAsync(update, draft, ct))
{
return;
}
if (WizardInteractionMapper.TryMap(update, out var interaction))
{
await wizard.HandleInteractionAsync(interaction, draft, ct);
}
// The "✅ Создать" / "✅ Создать пул" button — the wizard only
// acknowledges the callback; the actual session creation lives in
// CreateSessionHandler.
if (update.CallbackQuery?.Data is { } data &&
data == WizardCallbackData.Create())
{
await createSessionHandler.SubmitDraftAsync(draft, ct);
}
return;
}
}
switch (update)
{
case { CallbackQuery: { } query }:
@@ -102,106 +59,9 @@ public sealed class UpdateRouter(
}
}
/// <summary>
/// Handles router-level draft-control callbacks ("resume", "reset"). Returns true
/// if the update was consumed and the wizard should be skipped. The wizard still
/// owns "cancel" and "create".
/// </summary>
private async Task<bool> TryHandleDraftControlCallbackAsync(
Update update, WizardDraft draft, CancellationToken ct)
{
if (update.CallbackQuery is not { Data: { } data, Message: { } cbMessage, From: { } cbFrom })
return false;
switch (data)
{
case WizardControlCallbacks.Resume:
// Re-render the current step of the existing draft. We answer the
// callback here because the wizard will not be called.
var (text, kb) = WizardStep.Render(draft, LoadPayload(draft));
await bot.EditMessageText(
chatId: cbMessage.Chat.Id,
messageId: cbMessage.MessageId,
text: text,
replyMarkup: kb,
cancellationToken: ct);
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
return true;
case WizardControlCallbacks.Reset:
// Delete the existing draft and start a fresh one. The wizard is
// bypassed entirely because the active draft is now gone.
await drafts.DeleteAsync(draft.Id, ct);
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
var newDraft = await createSessionHandler.StartWizardAsync(
SyntheticStartMessage(cbMessage.Chat.Id, cbMessage.MessageThreadId, cbFrom.Id), ct);
if (newDraft is null)
{
// Race: another wizard just started for the same owner. The
// user can simply re-run /newsession. We don't loop.
await bot.SendMessage(
chatId: cbMessage.Chat.Id,
text: "Не удалось начать заново — попробуйте ещё раз через /newsession.",
cancellationToken: ct);
}
return true;
}
return false;
}
/// <summary>
/// Build a synthetic <see cref="Message"/> carrying just the fields
/// <see cref="CreateSessionHandler.StartWizardAsync"/> reads (chat, thread, from).
/// </summary>
private static Message SyntheticStartMessage(long chatId, int? messageThreadId, long fromId) => new()
{
Chat = new Chat { Id = chatId },
MessageThreadId = messageThreadId,
From = new User { Id = fromId },
};
private static WizardPayload LoadPayload(WizardDraft draft) =>
SharedWizard.LoadPayload(draft);
internal static string GetCommandText(Message message)
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart();
/// <summary>
/// Extracts the (chat, thread, owner) triple from an update for wizard lookups.
/// Returns false for updates that carry no usable origin (e.g. inline queries).
/// </summary>
private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out string ownerId)
{
chatId = 0;
messageThreadId = null;
ownerId = string.Empty;
switch (update)
{
case { Message: { From: not null, Chat: { } chat } msg }:
chatId = chat.Id;
messageThreadId = msg.MessageThreadId;
ownerId = msg.From!.Id.ToString(CultureInfo.InvariantCulture);
return true;
case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }:
chatId = cbmChat.Id;
messageThreadId = cb.Message?.MessageThreadId;
ownerId = cb.From!.Id.ToString(CultureInfo.InvariantCulture);
return true;
case { CallbackQuery: { From: not null } cb2 }:
// Callback arrived without a message (e.g. from a Mini App). No chat
// context → wizard cannot run on this update.
ownerId = cb2.From!.Id.ToString(CultureInfo.InvariantCulture);
return false;
default:
return false;
}
}
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
{
if (query.Data is not { } data || query.Message is not { } message)
@@ -209,22 +69,18 @@ public sealed class UpdateRouter(
var parts = data.Split(':', 3);
var action = parts[0];
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);
if (action == "join_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var joinSessionId))
{
var command = new JoinSessionCommand(
SessionId: joinSessionId,
User: user,
InteractionId: query.Id,
Group: group,
ScheduleMessage: scheduleMessage);
TelegramUserId: query.From.Id,
DisplayName: query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
TelegramUsername: query.From.Username,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
await joinSessionHandler.HandleAsync(command, ct);
return;
}
@@ -233,10 +89,10 @@ public sealed class UpdateRouter(
{
var command = new LeaveSessionCommand(
SessionId: leaveSessionId,
User: user,
InteractionId: query.Id,
Group: group,
ScheduleMessage: scheduleMessage);
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
await leaveSessionHandler.HandleAsync(command, ct);
return;
@@ -249,7 +105,6 @@ public sealed class UpdateRouter(
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageThreadId: message.MessageThreadId,
MessageId: message.MessageId);
await cancelSessionHandler.HandleAsync(command, ct);
@@ -289,7 +144,6 @@ public sealed class UpdateRouter(
TelegramUserId: query.From.Id,
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageThreadId: message.MessageThreadId,
MessageId: message.MessageId);
await initiateRescheduleHandler.HandleAsync(command, ct);
@@ -326,11 +180,11 @@ public sealed class UpdateRouter(
var command = new HandleRsvpCommand(
SessionId: sessionId,
User: user,
TelegramUserId: query.From.Id,
Status: status,
InteractionId: query.Id,
Group: group,
ConfirmationMessage: scheduleMessage);
CallbackQueryId: query.Id,
ChatId: message.Chat.Id,
MessageId: message.MessageId);
await rsvpHandler.HandleAsync(command, ct);
}
@@ -349,7 +203,7 @@ public sealed class UpdateRouter(
break;
case "/newsession":
await HandleNewSessionCommandAsync(message, ct);
await createSessionHandler.HandleAsync(message, ct);
break;
case "/listsessions":
@@ -392,45 +246,6 @@ public sealed class UpdateRouter(
}
}
private async Task HandleNewSessionCommandAsync(Message message, CancellationToken ct)
{
// Try to start a fresh wizard. StartWizardAsync returns null when a
// non-expired draft already exists for this (chat, thread, owner).
var draft = await createSessionHandler.StartWizardAsync(message, ct);
if (draft is not null)
{
// New draft was created and its first step has been rendered.
return;
}
// Existing draft. Look it up so we can describe the current step and offer
// a Continue / Start over / Cancel menu.
var existing = await createSessionHandler.TryResumeAsync(message, ct);
if (existing is null)
{
// Race: the draft expired between the two calls (or the user lacks
// ownership metadata). Fall back to silently starting a new wizard.
await createSessionHandler.StartWizardAsync(message, ct);
return;
}
await bot.SendMessage(
chatId: message.Chat.Id,
text: "У вас уже есть незавершённый мастер. Продолжить?",
replyMarkup: ContinueResetCancelKeyboard(),
cancellationToken: ct);
}
private InlineKeyboardMarkup ContinueResetCancelKeyboard() => new(new[]
{
// "Продолжить" re-renders the existing draft's current step (router-level).
// "Начать заново" deletes the existing draft and creates a fresh one (router-level).
// "Отмена" delegates to the wizard's normal cancel handler.
new[] { InlineKeyboardButton.WithCallbackData("➡️ Продолжить", WizardControlCallbacks.Resume) },
new[] { InlineKeyboardButton.WithCallbackData("🔁 Начать заново", WizardControlCallbacks.Reset) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
});
private async Task SendStartMessageAsync(Message message, CancellationToken ct)
{
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
@@ -451,14 +266,3 @@ public sealed class UpdateRouter(
cancellationToken: ct);
}
}
/// <summary>
/// Router-level callback data for the Continue / Start over / Cancel menu shown
/// when /newsession detects an existing wizard draft. Distinct from
/// <see cref="WizardCallbackData"/> which is parsed and consumed by the wizard itself.
/// </summary>
internal static class WizardControlCallbacks
{
public const string Resume = "wizard:resume";
public const string Reset = "wizard:reset";
}
@@ -1,11 +0,0 @@
CREATE TABLE calendar_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token TEXT UNIQUE NOT NULL,
user_telegram_id BIGINT NOT NULL,
group_id UUID REFERENCES game_groups(id) ON DELETE CASCADE,
filter_type SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);
CREATE INDEX ix_calendar_subscriptions_user_telegram_id ON calendar_subscriptions (user_telegram_id);
@@ -1,66 +0,0 @@
-- =============================================================
-- Attendance statistics view for GM analytics
-- Returns per-player aggregated metrics for a given game group.
-- NOTE: waitlist count reflects CURRENT registration_status only.
-- Full historical waitlist tracking will come with #15.
-- =============================================================
CREATE OR REPLACE FUNCTION get_group_attendance_stats(p_group_id UUID)
RETURNS TABLE (
player_id UUID,
display_name VARCHAR,
telegram_username VARCHAR,
total_sessions BIGINT,
confirmed_count BIGINT,
declined_count BIGINT,
no_response_count BIGINT,
waitlisted_count BIGINT,
cancellation_affected_count BIGINT,
attendance_rate NUMERIC
) AS $$
BEGIN
RETURN QUERY
WITH player_sessions AS (
SELECT
sp.player_id,
s.id AS session_id,
sp.rsvp_status,
sp.registration_status,
s.status AS session_status,
s.scheduled_at
FROM session_participants sp
JOIN sessions s ON s.id = sp.session_id
WHERE s.group_id = p_group_id
),
player_totals AS (
SELECT
ps.player_id,
COUNT(*) FILTER (WHERE ps.session_status <> 'Cancelled') AS total_sessions,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Confirmed' AND ps.session_status <> 'Cancelled') AS confirmed_count,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Declined' AND ps.session_status <> 'Cancelled') AS declined_count,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Pending' AND ps.scheduled_at < NOW() AND ps.session_status <> 'Cancelled') AS no_response_count,
COUNT(*) FILTER (WHERE ps.registration_status = 'Waitlisted' AND ps.session_status <> 'Cancelled') AS waitlisted_count,
COUNT(*) FILTER (WHERE ps.session_status = 'Cancelled') AS cancellation_affected_count
FROM player_sessions ps
GROUP BY ps.player_id
)
SELECT
pt.player_id,
p.display_name,
COALESCE(p.external_username, p.telegram_username) AS telegram_username,
pt.total_sessions,
pt.confirmed_count,
pt.declined_count,
pt.no_response_count,
pt.waitlisted_count,
pt.cancellation_affected_count,
ROUND(
100.0 * pt.confirmed_count
/ NULLIF(pt.total_sessions, 0),
1
) AS attendance_rate
FROM player_totals pt
JOIN players p ON p.id = pt.player_id
ORDER BY pt.confirmed_count DESC, pt.total_sessions DESC;
END;
$$ LANGUAGE plpgsql STABLE;
@@ -1,16 +0,0 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE session_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
actor_telegram_id BIGINT NOT NULL,
actor_name VARCHAR(255) NOT NULL,
change_type VARCHAR(50) NOT NULL
CHECK (change_type IN ('Title','Time','Link','MaxPlayers','Status','WaitlistPromote','PlayerRemoved','BatchRescheduled','Cancelled')),
old_value TEXT,
new_value TEXT,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_session_audit_log_session_id ON session_audit_log(session_id);
CREATE INDEX ix_session_audit_log_changed_at ON session_audit_log(changed_at);
@@ -1,13 +0,0 @@
ALTER TABLE sessions
ADD COLUMN confirmation_sent_at TIMESTAMPTZ;
-- Update existing ConfirmationSent sessions to have a sentinel value
-- so they don't get re-processed after migration
UPDATE sessions
SET confirmation_sent_at = now()
WHERE status = 'ConfirmationSent';
-- Partial index for efficient T-24h query
CREATE INDEX ix_sessions_confirmation_reminders ON sessions (scheduled_at)
WHERE status = 'Planned'
AND confirmation_sent_at IS NULL;
@@ -1,6 +0,0 @@
ALTER TABLE sessions
ADD COLUMN topic_created_by_bot BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE sessions
SET topic_created_by_bot = TRUE
WHERE thread_id IS NOT NULL;
@@ -1,119 +0,0 @@
-- =============================================================
-- V016: Add platform identity columns and platform_messages table
-- =============================================================
-- Scope: Prepare schema for multi-platform support (Discord, etc).
-- Legacy telegram_* columns are retained for backward compatibility.
-- =============================================================
-- -- Players: platform-agnostic identity
ALTER TABLE players
ADD COLUMN platform VARCHAR(50),
ADD COLUMN external_user_id VARCHAR(255),
ADD COLUMN external_username VARCHAR(255);
CREATE UNIQUE INDEX ix_players_platform_external_user_id
ON players (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL;
-- -- Game groups: platform-agnostic identity
ALTER TABLE game_groups
ADD COLUMN platform VARCHAR(50),
ADD COLUMN external_group_id VARCHAR(255),
ADD COLUMN external_channel_id VARCHAR(255);
CREATE UNIQUE INDEX ix_game_groups_platform_external_group_id
ON game_groups (platform, external_group_id)
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL;
-- -- Backfill existing Telegram data
UPDATE players
SET platform = 'Telegram',
external_user_id = telegram_id::TEXT,
external_username = telegram_username
WHERE platform IS NULL;
UPDATE game_groups
SET platform = 'Telegram',
external_group_id = telegram_chat_id::TEXT
WHERE platform IS NULL;
-- -- Platform messages: store per-platform message references
CREATE TABLE platform_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform VARCHAR(50) NOT NULL,
group_id UUID REFERENCES game_groups(id) ON DELETE CASCADE,
batch_id UUID,
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
external_channel_id VARCHAR(255),
external_thread_id VARCHAR(255),
external_message_id VARCHAR(255) NOT NULL,
purpose VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_platform_messages_group_id ON platform_messages(group_id);
CREATE INDEX ix_platform_messages_batch_id ON platform_messages(batch_id);
CREATE INDEX ix_platform_messages_session_id ON platform_messages(session_id);
CREATE INDEX ix_platform_messages_platform_message
ON platform_messages (platform, external_message_id);
-- -- Recreate attendance stats function for new columns (prod back-compat)
CREATE OR REPLACE FUNCTION get_group_attendance_stats(p_group_id UUID)
RETURNS TABLE (
player_id UUID,
display_name VARCHAR,
telegram_username VARCHAR,
total_sessions BIGINT,
confirmed_count BIGINT,
declined_count BIGINT,
no_response_count BIGINT,
waitlisted_count BIGINT,
cancellation_affected_count BIGINT,
attendance_rate NUMERIC
) AS $$
BEGIN
RETURN QUERY
WITH player_sessions AS (
SELECT
sp.player_id,
s.id AS session_id,
sp.rsvp_status,
sp.registration_status,
s.status AS session_status,
s.scheduled_at
FROM session_participants sp
JOIN sessions s ON s.id = sp.session_id
WHERE s.group_id = p_group_id
),
player_totals AS (
SELECT
ps.player_id,
COUNT(*) FILTER (WHERE ps.session_status <> 'Cancelled') AS total_sessions,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Confirmed' AND ps.session_status <> 'Cancelled') AS confirmed_count,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Declined' AND ps.session_status <> 'Cancelled') AS declined_count,
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Pending' AND ps.scheduled_at < NOW() AND ps.session_status <> 'Cancelled') AS no_response_count,
COUNT(*) FILTER (WHERE ps.registration_status = 'Waitlisted' AND ps.session_status <> 'Cancelled') AS waitlisted_count,
COUNT(*) FILTER (WHERE ps.session_status = 'Cancelled') AS cancellation_affected_count
FROM player_sessions ps
GROUP BY ps.player_id
)
SELECT
pt.player_id,
p.display_name,
COALESCE(p.external_username, p.telegram_username) AS telegram_username,
pt.total_sessions,
pt.confirmed_count,
pt.declined_count,
pt.no_response_count,
pt.waitlisted_count,
pt.cancellation_affected_count,
ROUND(
100.0 * pt.confirmed_count
/ NULLIF(pt.total_sessions, 0),
1
) AS attendance_rate
FROM player_totals pt
JOIN players p ON p.id = pt.player_id
ORDER BY pt.confirmed_count DESC, pt.total_sessions DESC;
END;
$$ LANGUAGE plpgsql STABLE;
@@ -1,9 +0,0 @@
-- =============================================================
-- 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;
@@ -1,19 +0,0 @@
-- =============================================================
-- 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;
@@ -1,18 +0,0 @@
-- =============================================================
-- 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;
@@ -1,37 +0,0 @@
-- =============================================================
-- V020: Player identity linking for unified multi-platform accounts
-- =============================================================
-- Scope: Allow linking multiple platform identities (Telegram, Discord)
-- to a single "primary" player account. All group/session permissions
-- resolve through the effective (primary) player id.
-- =============================================================
-- player_links: secondary player → primary player (1:1 on secondary)
CREATE TABLE player_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
primary_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
secondary_player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE,
linked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
linked_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
-- Prevent self-linking at the DB level
CONSTRAINT no_self_link CHECK (primary_player_id <> secondary_player_id)
);
CREATE INDEX ix_player_links_primary_player_id
ON player_links(primary_player_id);
-- identity_audit_log: security-sensitive link/unlink actions
CREATE TABLE identity_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
action VARCHAR(50) NOT NULL, -- 'link', 'unlink', 'link_attempt_conflict'
target_platform VARCHAR(50),
target_external_user_id VARCHAR(255),
performed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
performed_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL
);
CREATE INDEX ix_identity_audit_log_player_id
ON identity_audit_log(player_id);
CREATE INDEX ix_identity_audit_log_performed_at
ON identity_audit_log(performed_at DESC);
@@ -1,8 +0,0 @@
-- =============================================================
-- V021: Add avatar_url column to players table
-- =============================================================
-- Scope: Support storing avatar URLs for Discord and other platforms.
-- =============================================================
ALTER TABLE players
ADD COLUMN avatar_url VARCHAR(500);
@@ -1,16 +0,0 @@
-- =============================================================
-- V022: Fix incorrectly oriented player_links for Discord↔Telegram
-- =============================================================
-- Scope: Reverse player_links where Discord was incorrectly made primary
-- and Telegram secondary. Telegram (with historical group/session data)
-- must always be the primary account.
-- =============================================================
UPDATE player_links pl
SET primary_player_id = pl.secondary_player_id,
secondary_player_id = pl.primary_player_id
FROM players p1, players p2
WHERE pl.primary_player_id = p1.id
AND pl.secondary_player_id = p2.id
AND p1.platform = 'Discord'
AND p2.platform = 'Telegram';
@@ -1,14 +0,0 @@
-- =============================================================
-- V023: Make legacy Telegram columns nullable for multi-platform
-- =============================================================
-- Scope: Allow Discord (and future platforms) to create players
-- and game_groups without legacy telegram_* values.
-- Existing Telegram data was backfilled in V016.
-- =============================================================
ALTER TABLE game_groups
ALTER COLUMN telegram_chat_id DROP NOT NULL,
ALTER COLUMN gm_telegram_id DROP NOT NULL;
ALTER TABLE players
ALTER COLUMN telegram_id DROP NOT NULL;
@@ -1,41 +0,0 @@
-- =============================================================
-- V024: Deprecate legacy Telegram-specific columns
-- =============================================================
-- Scope: Complete platform migration by backfilling any remaining
-- external_* gaps and officially deprecating telegram_* columns.
-- No columns are dropped — rollback-safe.
-- =============================================================
-- 1. Backfill players platform identity (safeguard for any rows missed in V016)
UPDATE players
SET platform = 'Telegram',
external_user_id = telegram_id::TEXT,
external_username = telegram_username
WHERE platform IS NULL;
-- 2. Backfill game_groups platform identity (safeguard for any rows missed in V016)
UPDATE game_groups
SET platform = 'Telegram',
external_group_id = telegram_chat_id::TEXT
WHERE platform IS NULL;
-- 3. Add platform identity to calendar_subscriptions
ALTER TABLE calendar_subscriptions
ADD COLUMN user_platform VARCHAR(50),
ADD COLUMN user_external_id VARCHAR(255);
UPDATE calendar_subscriptions
SET user_external_id = user_telegram_id::TEXT,
user_platform = 'Telegram'
WHERE user_platform IS NULL;
-- 4. Migrate calendar subscription index
DROP INDEX IF EXISTS ix_calendar_subscriptions_user_telegram_id;
CREATE INDEX ix_calendar_subscriptions_user_external_id ON calendar_subscriptions (user_external_id);
-- 5. Deprecation comments on legacy columns
COMMENT ON COLUMN players.telegram_id IS 'DEPRECATED: use platform + external_user_id';
COMMENT ON COLUMN players.telegram_username IS 'DEPRECATED: use external_username';
COMMENT ON COLUMN game_groups.telegram_chat_id IS 'DEPRECATED: use platform + external_group_id';
COMMENT ON COLUMN game_groups.gm_telegram_id IS 'DEPRECATED: group ownership is tracked in group_managers';
COMMENT ON COLUMN calendar_subscriptions.user_telegram_id IS 'DEPRECATED: use user_platform + user_external_id';
@@ -1,11 +0,0 @@
-- =============================================================
-- V025: Backfill proposed_by_external_user_id for Telegram proposals
-- =============================================================
-- Scope: Ensure all reschedule_proposals have proposed_by_external_user_id
-- populated so that InitiateRescheduleHandler can stop writing proposed_by.
-- =============================================================
UPDATE reschedule_proposals
SET proposed_by_external_user_id = proposed_by::TEXT
WHERE proposed_by_external_user_id IS NULL
AND proposed_by IS NOT NULL;
@@ -1,17 +0,0 @@
-- Public club pages and read-only schedule publication controls.
ALTER TABLE game_groups
ADD COLUMN public_slug VARCHAR(120),
ADD COLUMN public_schedule_enabled BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN public_schedule_updated_at TIMESTAMPTZ;
ALTER TABLE sessions
ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT false;
CREATE UNIQUE INDEX ux_game_groups_public_slug
ON game_groups (lower(public_slug))
WHERE public_slug IS NOT NULL;
CREATE INDEX ix_sessions_public_schedule
ON sessions (group_id, scheduled_at)
WHERE is_public = true AND status <> 'Cancelled';
@@ -1,14 +0,0 @@
-- Showcase fields for game catalog / public session browsing.
ALTER TABLE sessions
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN system VARCHAR(50),
ADD COLUMN description TEXT,
ADD COLUMN cover_image_url TEXT,
ADD COLUMN duration_minutes INTEGER,
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX ix_sessions_showcase
ON sessions (scheduled_at, system, is_one_shot, format)
WHERE is_public = true AND status <> 'Cancelled';
@@ -1,20 +0,0 @@
-- Public GM profiles for catalog and club trust pages.
CREATE TABLE master_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE,
public_slug VARCHAR(120),
is_public BOOLEAN NOT NULL DEFAULT false,
display_name VARCHAR(255) NOT NULL,
bio TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ux_master_profiles_public_slug
ON master_profiles (lower(public_slug))
WHERE public_slug IS NOT NULL;
CREATE INDEX ix_master_profiles_public
ON master_profiles (lower(public_slug))
WHERE is_public = true AND public_slug IS NOT NULL;
@@ -1,261 +0,0 @@
-- Completed adventure portfolio cards with linked sessions, masters, and moderated reviews.
CREATE TABLE portfolio_games (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
public_slug VARCHAR(160),
title VARCHAR(255) NOT NULL,
description TEXT,
cover_storage_key TEXT,
system VARCHAR(50),
format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
is_public BOOLEAN NOT NULL DEFAULT false,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (
NOT is_public
OR (
public_slug IS NOT NULL
AND description IS NOT NULL
AND cover_storage_key IS NOT NULL
AND published_at IS NOT NULL
)
)
);
CREATE UNIQUE INDEX ux_portfolio_games_public_slug
ON portfolio_games (lower(public_slug))
WHERE public_slug IS NOT NULL;
CREATE INDEX ix_portfolio_games_group
ON portfolio_games (group_id, completed_at DESC);
CREATE INDEX ix_portfolio_games_public
ON portfolio_games (completed_at DESC)
WHERE is_public = true;
CREATE TABLE portfolio_game_sessions (
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
PRIMARY KEY (portfolio_game_id, session_id),
UNIQUE (session_id)
);
CREATE TABLE portfolio_game_masters (
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
PRIMARY KEY (portfolio_game_id, player_id)
);
CREATE INDEX ix_portfolio_game_masters_player
ON portfolio_game_masters (player_id, portfolio_game_id);
CREATE FUNCTION lock_portfolio_publication_mutation()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM pg_advisory_xact_lock(20260530, 108);
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_portfolio_games_lock_publication_mutation
BEFORE INSERT OR DELETE OR UPDATE OF is_public ON portfolio_games
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE TRIGGER trg_portfolio_game_sessions_lock_publication_mutation
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_sessions
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE TRIGGER trg_portfolio_game_masters_lock_publication_mutation
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_masters
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE TRIGGER trg_sessions_lock_portfolio_publication_mutation
BEFORE DELETE OR UPDATE OF scheduled_at ON sessions
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE TRIGGER trg_game_groups_lock_portfolio_publication_mutation_before_delete
BEFORE DELETE ON game_groups
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE TRIGGER trg_players_lock_portfolio_publication_mutation_before_delete
BEFORE DELETE ON players
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE FUNCTION validate_public_portfolio_game_required_links()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
target_portfolio_game_id UUID;
target_portfolio_game_ids UUID[];
BEGIN
PERFORM pg_advisory_xact_lock(20260530, 108);
IF TG_TABLE_NAME = 'portfolio_games' THEN
target_portfolio_game_ids := ARRAY[NEW.id];
ELSIF TG_OP = 'DELETE' THEN
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id];
ELSIF TG_OP = 'INSERT' THEN
target_portfolio_game_ids := ARRAY[NEW.portfolio_game_id];
ELSE
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id, NEW.portfolio_game_id];
END IF;
IF current_setting('transaction_isolation') <> 'read committed' THEN
RAISE EXCEPTION
'portfolio publication validation requires read committed isolation'
USING ERRCODE = '0A000';
END IF;
SELECT pg.id
INTO target_portfolio_game_id
FROM portfolio_games pg
WHERE pg.id = ANY(target_portfolio_game_ids)
AND pg.is_public = true
AND (
NOT EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
WHERE pgs.portfolio_game_id = pg.id
)
OR EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
WHERE pgs.portfolio_game_id = pg.id
AND s.scheduled_at >= now()
)
OR NOT EXISTS (
SELECT 1
FROM portfolio_game_masters pgm
WHERE pgm.portfolio_game_id = pg.id
)
)
LIMIT 1;
IF target_portfolio_game_id IS NOT NULL THEN
RAISE EXCEPTION
'published portfolio game % must have at least one linked session and at least one linked master',
target_portfolio_game_id
USING ERRCODE = '23514';
END IF;
RETURN NULL;
END;
$$;
CREATE FUNCTION unpublish_public_portfolio_games_for_future_session()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
final_scheduled_at TIMESTAMPTZ;
BEGIN
SELECT s.scheduled_at
INTO final_scheduled_at
FROM sessions s
WHERE s.id = NEW.id;
IF final_scheduled_at >= now() THEN
IF current_setting('transaction_isolation') <> 'read committed' THEN
RAISE EXCEPTION
'portfolio future reschedule requires read committed isolation'
USING ERRCODE = '0A000';
END IF;
PERFORM pg.id
FROM portfolio_games pg
WHERE EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
WHERE pgs.portfolio_game_id = pg.id
AND s.scheduled_at >= now()
)
ORDER BY pg.id
FOR UPDATE OF pg;
PERFORM pg_advisory_xact_lock(20260530, 108);
UPDATE portfolio_games pg
SET is_public = false,
updated_at = now()
WHERE pg.is_public = true
AND EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
WHERE pgs.portfolio_game_id = pg.id
AND s.scheduled_at >= now()
);
END IF;
RETURN NULL;
END;
$$;
CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule
AFTER UPDATE OF scheduled_at ON sessions
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();
CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links
AFTER INSERT OR UPDATE OF is_public ON portfolio_games
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links
AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links
AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
CREATE TABLE portfolio_game_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
author_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
author_display_name VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
publication_consent_at TIMESTAMPTZ NOT NULL,
moderation_status VARCHAR(20) NOT NULL DEFAULT 'Pending'
CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden')),
moderated_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
moderated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (portfolio_game_id, author_player_id)
);
CREATE INDEX ix_portfolio_game_reviews_author
ON portfolio_game_reviews (author_player_id);
CREATE INDEX ix_portfolio_game_reviews_moderator
ON portfolio_game_reviews (moderated_by_player_id)
WHERE moderated_by_player_id IS NOT NULL;
CREATE INDEX ix_portfolio_game_reviews_public
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL;
CREATE INDEX ix_portfolio_game_reviews_pending
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
WHERE moderation_status = 'Pending';
@@ -1,66 +0,0 @@
-- V030: Private club showcases. Adds club_memberships (member access control)
-- and replaces sessions.is_public with a 4-state publication_mode enum.
-- Backfills existing data: is_public=true → 'Both', is_public=false → 'None'.
-- portfolio_games gains the same enum (default 'Both' for pre-V030 rows).
-- 1. club_memberships
CREATE TABLE club_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'Pending'
CHECK (status IN ('Pending', 'Active', 'Rejected', 'Left')),
role VARCHAR(20) NOT NULL DEFAULT 'Member'
CHECK (role IN ('Member')),
message TEXT,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
decided_at TIMESTAMPTZ,
decided_by UUID REFERENCES players(id) ON DELETE SET NULL
);
-- Only one Active row per (group, player).
-- Re-application after Rejected/Left creates a new row.
CREATE UNIQUE INDEX ux_club_memberships_one_active
ON club_memberships (group_id, player_id)
WHERE status = 'Active';
CREATE INDEX ix_club_memberships_group_status
ON club_memberships (group_id, status);
CREATE INDEX ix_club_memberships_player_status
ON club_memberships (player_id, status);
-- 2. sessions.publication_mode (replaces is_public)
ALTER TABLE sessions
ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'None';
-- Backfill before constraint so existing data maps cleanly.
UPDATE sessions SET publication_mode = 'Both' WHERE is_public = true;
UPDATE sessions SET publication_mode = 'None' WHERE is_public = false;
ALTER TABLE sessions
ADD CONSTRAINT ck_sessions_publication_mode
CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both'));
ALTER TABLE sessions DROP COLUMN is_public;
DROP INDEX IF EXISTS ix_sessions_public_schedule;
DROP INDEX IF EXISTS ix_sessions_showcase;
CREATE INDEX ix_sessions_public_schedule
ON sessions (group_id, scheduled_at)
WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled';
CREATE INDEX ix_sessions_showcase
ON sessions (scheduled_at, system, is_one_shot, format)
WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled';
-- 3. portfolio_games.publication_mode
-- Existing rows in portfolio_games keep 'Both' to stay visible to anonymous visitors.
ALTER TABLE portfolio_games
ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'Both'
CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both'));
CREATE INDEX ix_portfolio_games_showcase
ON portfolio_games (created_at DESC)
WHERE publication_mode IN ('Catalog', 'Both');
@@ -1,21 +0,0 @@
-- V031: Per-(chat, thread, owner) wizard drafts for the game-creation wizard (issue #111).
-- Stores in-progress wizard state in JSONB with a 24h TTL managed by WizardDraftCleanupService.
CREATE TABLE wizard_drafts (
id UUID PRIMARY KEY,
chat_id BIGINT NOT NULL,
message_thread_id INT,
owner_telegram_id BIGINT NOT NULL,
step TEXT NOT NULL,
payload JSONB NOT NULL,
draft_message_id BIGINT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_wizard_drafts_owner
ON wizard_drafts(chat_id, message_thread_id, owner_telegram_id);
CREATE INDEX idx_wizard_drafts_expires
ON wizard_drafts(expires_at);
@@ -1,40 +0,0 @@
-- V032: Platform-neutral wizard drafts (issue #112).
-- Adds the platform discriminator and switches owner/chat/thread/message
-- columns from numeric to TEXT so the same table can hold both Telegram
-- ids (long) and Discord snowflakes (ulong). All conversions are safe:
-- the affected columns are nullable except chat_id/owner_telegram_id
-- which we cast via TEXT.
ALTER TABLE wizard_drafts
ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram';
-- Convert chat_id: BIGINT → TEXT. Existing rows hold Telegram chat ids
-- which convert losslessly to their decimal string form.
ALTER TABLE wizard_drafts
ALTER COLUMN chat_id TYPE TEXT USING chat_id::TEXT;
-- Convert message_thread_id: INT (nullable) → TEXT (nullable).
ALTER TABLE wizard_drafts
ALTER COLUMN message_thread_id TYPE TEXT USING message_thread_id::TEXT;
-- Convert draft_message_id: BIGINT (nullable) → TEXT (nullable).
ALTER TABLE wizard_drafts
ALTER COLUMN draft_message_id TYPE TEXT USING draft_message_id::TEXT;
-- Rename owner_telegram_id → owner_id (now platform-agnostic) and
-- convert from BIGINT to TEXT.
ALTER TABLE wizard_drafts
RENAME COLUMN owner_telegram_id TO owner_id;
ALTER TABLE wizard_drafts
ALTER COLUMN owner_id TYPE TEXT USING owner_id::TEXT;
-- Replace the old owner lookup index with one that uses the new column
-- names and the platform discriminator.
DROP INDEX IF EXISTS idx_wizard_drafts_owner;
CREATE INDEX idx_wizard_drafts_owner
ON wizard_drafts(platform, owner_id);
CREATE INDEX idx_wizard_drafts_platform
ON wizard_drafts(platform);
+9 -44
View File
@@ -1,21 +1,14 @@
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.CreateSession.Wizard;
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.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Infrastructure.Scheduling;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
@@ -56,44 +49,24 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
return new TelegramBotClient(token);
});
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<PlatformDirectNotificationSender>();
builder.Services.AddSingleton<DirectSessionNotificationSender>();
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<GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler>();
// Wizard services (issue #111)
builder.Services.AddSingleton<IWizardDraftRepository, WizardDraftRepository>();
builder.Services.AddSingleton<IWizardMessenger, TelegramWizardMessenger>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<CreateSessionHandler>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
builder.Services.AddSingleton<CancelSessionHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
builder.Services.AddSingleton<InitiateRescheduleHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
builder.Services.AddSingleton<DirectSessionNotificationSender>();
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
// ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>();
@@ -101,17 +74,9 @@ builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredServic
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
builder.Services.AddHostedService<TelegramBotService>();
// ── Clock and scheduling ──────────────────────────────────────────────
builder.Services.AddSingleton<ISystemClock, GmRelay.Bot.Infrastructure.Scheduling.SystemClock>();
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
// ── Session scheduler ────────────────────────────────────────────────
builder.Services.AddHostedService<SessionSchedulerService>();
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
builder.Services.AddHostedService<WizardDraftCleanupService>();
// ── Health check server ──────────────────────────────────────────────
builder.Services.AddHostedService<BotHealthCheckHostedService>();
var host = builder.Build();
-3
View File
@@ -9,8 +9,5 @@
"Telegram": {
"BotToken": "",
"MiniAppUrl": ""
},
"Web": {
"BaseUrl": ""
}
}
-695
View File
@@ -1,695 +0,0 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Aspire.Npgsql": {
"type": "Direct",
"requested": "[13.2.2, )",
"resolved": "13.2.2",
"contentHash": "nEYgziWN7hksgEQEWy24JypcMCU8gKYcIIyPL05JfdXxUWuPRLotH/KOeuHevAjSEOYkL3dtGakBkJAuPobGmA==",
"dependencies": {
"AspNetCore.HealthChecks.NpgSql": "9.0.0",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics.HealthChecks": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5",
"Npgsql.DependencyInjection": "10.0.1",
"Npgsql.OpenTelemetry": "10.0.1",
"OpenTelemetry.Extensions.Hosting": "1.15.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=="
},
"dbup-postgresql": {
"type": "Direct",
"requested": "[7.0.1, )",
"resolved": "7.0.1",
"contentHash": "mRnmENWWPuuMZ538gOd1mZnzucx6FQk0anmw3EABjGfcbp24FDb9QdGepYrDiaM8K9s5/gd49+5cmBOlniH/lg==",
"dependencies": {
"Npgsql": "10.0.1",
"dbup-core": "6.1.1"
}
},
"Microsoft.DotNet.ILCompiler": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "yadTZIkStCVsG8nGwvfroSfBApPsgjQbodQyaIfp53dgayE0qhZpywixiCB6lx57JYQ+KVg1m1AFLrj54pxpZg=="
},
"Microsoft.Extensions.Hosting": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "8i7e5IBdiKLNqt/+ciWrS8U95Rv5DClaaj7ulkZbimnCi4uREWd+lXzkp3joofFuIPOlAzV4AckxLTIELv2jdg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.Configuration.CommandLine": "10.0.5",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.5",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
"Microsoft.Extensions.Configuration.Json": "10.0.5",
"Microsoft.Extensions.Configuration.UserSecrets": "10.0.5",
"Microsoft.Extensions.DependencyInjection": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Diagnostics": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
"Microsoft.Extensions.Logging.Console": "10.0.5",
"Microsoft.Extensions.Logging.Debug": "10.0.5",
"Microsoft.Extensions.Logging.EventLog": "10.0.5",
"Microsoft.Extensions.Logging.EventSource": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "A+5ZuQ0f449tM+MQrhf6R9ZX7lYpjk/ODEwLYKrnF6111rtARx8fVsm4YznUnQiKnnXfaXNBqgxmil6RW3L3SA=="
},
"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=="
},
"Telegram.Bot": {
"type": "Direct",
"requested": "[22.9.5.3, )",
"resolved": "22.9.5.3",
"contentHash": "7u8rZU9Vx9XEyIm6pB+dAlITsi1v63I+hKo7IEXGiQZnVjzvZgPs9yDCP17/Cwm7lgjCNEqknlbv/yoBnsUYFw==",
"dependencies": {
"Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0"
}
},
"AspNetCore.HealthChecks.NpgSql": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11",
"Npgsql": "8.0.3"
}
},
"dbup-core": {
"type": "Transitive",
"resolved": "6.1.1",
"contentHash": "kgpuyJVEFJHoIj/slnc994Go88aoeZqNDfGHDBr4sh7CsEWwJhOTCt/FJqO4ziUImL5L0NEY0kxxOiNgPKI2Fw==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.AmbientMetadata.Application": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "CNrEjaOCZ8d1HtB0mvpiX4EWxLkee2xy+CsYXxmsEYJSFgw3OmF9pIhP/tCTeYBHhpsKJj5wM63G8IBFGxAcsw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.2",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.2",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.2"
}
},
"Microsoft.Extensions.Compliance.Abstractions": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "1a4xDAT6fRyP8t419q3WvWMmMslDTvI7OAZLWBhn5rysFG0bl5xFenTswd1xAbT/3u3mx4Xyb5bPx+V+18tJeQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
"Microsoft.Extensions.ObjectPool": "10.0.2"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"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.Configuration.Binder": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.CommandLine": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "or9fOLopMUTJOQVJ3bou4aD6PwvsiKf4kZC4EE5sRRKSkmh+wfk/LekJXRjAX88X+1JA9zHjDo+5fiQ7z3MY/A==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.EnvironmentVariables": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "tchMGQ+zVTO40np/Zzg2Li/TIR8bksQgg4UVXZa0OzeFCKWnIYtxE2FVs+eSmjPGCjMS2voZbwN/mUcYfpSTuA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "OhTr0O79dP49734lLTqVveivVX9sDXxbI/8vjELAZTHXqoN90mdpgTAgwicJED42iaHMCcZcK6Bj+8wNyBikaw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "brBM/WP0YAUYh2+QqSYVdK8eQHYQTtTEUJXJ+84Zkdo2buGLja9VSrMIhgoeBUU7JBmcskAib8Lb/N83bvxgYQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Configuration.UserSecrets": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "fhdG6UV9lIp70QhNkVyaHciUVq25IPFkczheVJL9bIFvmnJ+Zghaie6dWkDbbVmxZlHl9gj3zTDxMxJs5zNhIA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Json": "10.0.5",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileProviders.Physical": "10.0.5"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA=="
},
"Microsoft.Extensions.DependencyInjection.AutoActivation": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "Z/OI261l7LnxyODKPx0trQyIHFyicCR/akfn64lGOjPcf4FpAZ7ePAGl2HPvQBUBSNfPTF0gWeCfuFmyftMgYA==",
"dependencies": {
"Microsoft.Extensions.Hosting.Abstractions": "10.0.2"
}
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vAJHd4yOpmKoK+jBuYV7a3y+Ab9U4ARCc29b6qvMy276RgJFw9LFs0DdsPqOL3ahwzyrX7tM+i4cCxU/RX0qAg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
}
},
"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.Diagnostics.ExceptionSummarization": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "3qMK1D40D10kb5TdBtFJpzz6/WH0NinWs68ZZS8jCFgHMXDiOjGiPOneMmIocCP/wnUUW4Hzf8lMsIE1xIGxDA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2"
}
},
"Microsoft.Extensions.Diagnostics.HealthChecks": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "REdt95QXHscGdtw/UUgyCW2lF9DJcAOJxmebKW2IkgUjuCAdMODIi2HNOWg5utW98nm8ekgV0Gjqs/sljwwqMw==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.5",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "NrIMTy7dpqxAvA6kHAYH8cXID/YgeNOy0OqFKpLtkPu5X4WS/basX91UszANzVrMNRAICJ2GOnGiRxJtsRyEQw=="
},
"Microsoft.Extensions.Features": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "X7tm2aV2w3lN9roSSGhl19lz4w76HvdiuKNhIv2XOiorYII9XCm66o/z9IJ0+QwkgvEv5gMZDM6rV6uwABHEQQ=="
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "dMu5kUPSfol1Rqhmr6nWPSmbFjDe9w6bkoKithG17bWTZA0UyKirTatM5mqYUN3mGpNA0MorlusIoVTh6J7o5g==",
"dependencies": {
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "mOE3ARusNQR0a5x8YOcnUbfyyXGqoAWQtEc7qFOfNJgruDWQLo39Re+3/Lzj5pLPFuFYj8hN4dgKzaSQDKiOCw=="
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
"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.Http": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "egUPC0xydb1ugCMcRyJ6zaOGOzx7N4coOVlGeLcIsXhUf1xHHwZeX+ob7JuG0dXExFduHYE/t+4/4y8BLlBKmw==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.2",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
"Microsoft.Extensions.Diagnostics": "10.0.2",
"Microsoft.Extensions.Logging": "10.0.2",
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.Extensions.Options": "10.0.2"
}
},
"Microsoft.Extensions.Http.Diagnostics": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "I0FBgF6yZRwYH9E3KQ2vHm80YZ7YBj+52GDsmOWXPBv/p15b/wUoNupV9kw3LnSNVsWMqlGbiuZgBnHpMwPh+Q==",
"dependencies": {
"Microsoft.Extensions.Http": "10.0.2",
"Microsoft.Extensions.Telemetry": "10.2.0"
}
},
"Microsoft.Extensions.Http.Resilience": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "Lg+OjBW+ODDbM4Ax4LoERvQ1dqSZ8I2gQc2+B0/WOWl2+PunLJ3xb3x8MtHGfcb/Mp98RoMpwRKm6Aj9mzXwrA==",
"dependencies": {
"Microsoft.Extensions.Http.Diagnostics": "10.2.0",
"Microsoft.Extensions.ObjectPool": "10.0.2",
"Microsoft.Extensions.Resilience": "10.2.0"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Configuration": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "cSgxsDgfP0+gmVRPVoNHI/KIDavIZxh+CxE6tSLPlYTogqccDnjBFI9CgEsiNuMP6+fiuXUwhhlTz36uUEpwbQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Console": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "PMs2gha2v24hvH5o5KQem5aNK4mN0BhhCWlMqsg9tzifWKzjeQi2tyPOP/RaWMVvalOhVLcrmoMYPqbnia/epg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5"
}
},
"Microsoft.Extensions.Logging.Debug": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/VacEkBQ02A8PBXSa6YpbIXCuisYy6JJr62/+ANJDZE+RMBfZMcXJXLfr/LpyLE6pgdp17Wxlt7e7R9zvkwZ3Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
}
},
"Microsoft.Extensions.Logging.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "0ezhWYJS4/6KrqQel9JL+Tr4n+4EX2TF5EYiaysBWNNEM2c3Gtj1moD39esfgk8OHblSX+UFjtZ3z0c4i9tRvw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"System.Diagnostics.EventLog": "10.0.5"
}
},
"Microsoft.Extensions.Logging.EventSource": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vN+aq1hBFXyYvY5Ow9WyeR66drKQxRZmas4lAjh6QWfryPkjTn1uLtX5AFIxyDaZj78v5TG2sELUyvrXpAPQQw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Logging": "10.0.5",
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.ObjectPool": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "kpCp4m7nwJVBcRKWXYHdVK/W0dkKyyFOjCmKVdO+zKThWvUxP1V+jVEP9FGpqRu4GPl9041SEXu2f+U/l825nQ=="
},
"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.Options.ConfigurationExtensions": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "BB9uUW3+6Rxu1R97OB1H/13lUF8P2+H1+eDhpZlK30kDh/6E4EKHBUqTp+ilXQmZLzsRErxON8aBSR6WpUKJdg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
"Microsoft.Extensions.Options": "10.0.5",
"Microsoft.Extensions.Primitives": "10.0.5"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g=="
},
"Microsoft.Extensions.Resilience": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "v4WOdAOFxB3AcsUkZWNcHL3mYzs4KAPtHO8rkoQlFKOBoD3KyjjAL+h3tRwSK5i4UpF/yhxsQRY0JxKj4osxxw==",
"dependencies": {
"Microsoft.Extensions.Diagnostics": "10.0.2",
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.2.0",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.2",
"Microsoft.Extensions.Telemetry.Abstractions": "10.2.0",
"Polly.Extensions": "8.4.2",
"Polly.RateLimiting": "8.4.2"
}
},
"Microsoft.Extensions.ServiceDiscovery": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "AHTPfiKodj66xA8RwRkFD4q11V2AvzcuDsujv6ViPkOPtvBEYcPVplHakK56pPzWlX08MDS+TAQXfFXAeP7J5w==",
"dependencies": {
"Microsoft.Extensions.Http": "10.0.2",
"Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.2.0"
}
},
"Microsoft.Extensions.ServiceDiscovery.Abstractions": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "sANlOvfqfw/yfych4CLlHSKSWzIie6mQG7w83gVur1foNOafyHxcgpoQMvBf+KiB4Tpls6P1/Z77IIQSK8hxFg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.2",
"Microsoft.Extensions.Configuration.Binder": "10.0.2",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
"Microsoft.Extensions.Features": "10.0.2",
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.Extensions.Options": "10.0.2",
"Microsoft.Extensions.Primitives": "10.0.2"
}
},
"Microsoft.Extensions.Telemetry": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "ssW5gosYlewNH/ISTyaLD/XfJT4GSjwShOUKv61fpXrqVmHkhuIA/5bBAGStM1XbzJjt9IG2vzfdHTu4zlX9Ew==",
"dependencies": {
"Microsoft.Extensions.AmbientMetadata.Application": "10.2.0",
"Microsoft.Extensions.DependencyInjection.AutoActivation": "10.2.0",
"Microsoft.Extensions.Logging.Configuration": "10.0.2",
"Microsoft.Extensions.ObjectPool": "10.0.2",
"Microsoft.Extensions.Telemetry.Abstractions": "10.2.0"
}
},
"Microsoft.Extensions.Telemetry.Abstractions": {
"type": "Transitive",
"resolved": "10.2.0",
"contentHash": "6V4V6NX6RLUYWwV89DeW/4zK5xOycYHWhsfMXSpKVGgMHfXcczmbk6hBeqTnRPzhpATYcOWlmA6hk1jgdxUugA==",
"dependencies": {
"Microsoft.Extensions.Compliance.Abstractions": "10.2.0",
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.Extensions.ObjectPool": "10.0.2",
"Microsoft.Extensions.Options": "10.0.2"
}
},
"Npgsql.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "YHFa4vD27sNIfv6s5q8Zi1fLvKfmK1xcpMv0PUvXOxDFbRmuMRSHwpZTbPvsAlj97q1/o7DfyynLqfqrCm1VnA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Npgsql": "10.0.1"
}
},
"Npgsql.OpenTelemetry": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "G9fEIBaHggZXWfDSDnKLc0XwKcbuU6i2eXp7zDqpgYxbhCmIN9fRgaSOGyyMNHSo/yY1IB4G4CjW5VO/SKRR0g==",
"dependencies": {
"Npgsql": "10.0.1",
"OpenTelemetry.API": "1.14.0"
}
},
"OpenTelemetry": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "N0i6WjPoHPbZyms1ugbDIFAJFuGlpeExJMU/+XSL0lQRUkg/D0utFkDoLXf8Z1km5B+xVZ2GyMXXiX8qdeNmPg==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0",
"Microsoft.Extensions.Logging.Configuration": "10.0.0",
"OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.3"
}
},
"OpenTelemetry.Api": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g=="
},
"OpenTelemetry.Api.ProviderBuilderExtensions": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
"OpenTelemetry.Api": "1.15.3"
}
},
"OpenTelemetry.Exporter.OpenTelemetryProtocol": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==",
"dependencies": {
"OpenTelemetry": "1.15.3"
}
},
"OpenTelemetry.Extensions.Hosting": {
"type": "Transitive",
"resolved": "1.15.3",
"contentHash": "u8n/W8yIlqv0BXZmvId1iVaeWXG42tGKdTkuLYg5g57Y/r9CeUNzqtrSHNdG5IoO8iPX79w3v+WsbAHgUQbfeg==",
"dependencies": {
"Microsoft.Extensions.Hosting.Abstractions": "10.0.0",
"OpenTelemetry": "1.15.3"
}
},
"OpenTelemetry.Instrumentation.AspNetCore": {
"type": "Transitive",
"resolved": "1.15.2",
"contentHash": "2nPd7r0ug/gd6/CNFL6Rlu+RSQ9WYGSGHAYQ1ssbSqyzKJpqTunfx2I/1O0WB5k+L0cyXbG4XVZpoSoUc3M7wg==",
"dependencies": {
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
}
},
"OpenTelemetry.Instrumentation.Http": {
"type": "Transitive",
"resolved": "1.15.1",
"contentHash": "vFO4Fj/dXkoVNGo/nhoGpO2zYQmZwr4jTID7oRGo+XlQ8LqksyZjUXQ4p39RfUvTID7IzzL8Qe71tW7CcAFymA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.0",
"Microsoft.Extensions.Options": "10.0.0",
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
}
},
"OpenTelemetry.Instrumentation.Runtime": {
"type": "Transitive",
"resolved": "1.15.1",
"contentHash": "cpPwlUT5HXcLGPaIgsbSy0W9eFYAPGVbTP1p8/uyQ4Osvf5BJuPpEXE7crL09SmEd44r0DGNKDtsqxaAz0HxQw==",
"dependencies": {
"OpenTelemetry.Api": "[1.15.3, 2.0.0)"
}
},
"Polly.Core": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g=="
},
"Polly.Extensions": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0",
"Polly.Core": "8.4.2"
}
},
"Polly.RateLimiting": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==",
"dependencies": {
"Polly.Core": "8.4.2",
"System.Threading.RateLimiting": "8.0.0"
}
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "wugvy+pBVzjQEnRs9wMTWwoaeNFX3hsaHeVHFDIvJSWXp7wfmNWu3mxAwBIE6pyW+g6+rHa1Of5fTzb0QVqUTA=="
},
"System.Threading.RateLimiting": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q=="
},
"gmrelay.servicedefaults": {
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Http.Resilience": "[10.2.0, )",
"Microsoft.Extensions.ServiceDiscovery": "[10.2.0, )",
"OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
"OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
"OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )",
"OpenTelemetry.Instrumentation.Http": "[1.15.1, )",
"OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )"
}
},
"gmrelay.shared": {
"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": {
"Microsoft.DotNet.ILCompiler": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "yadTZIkStCVsG8nGwvfroSfBApPsgjQbodQyaIfp53dgayE0qhZpywixiCB6lx57JYQ+KVg1m1AFLrj54pxpZg==",
"dependencies": {
"runtime.win-x64.Microsoft.DotNet.ILCompiler": "10.0.5"
}
},
"runtime.win-x64.Microsoft.DotNet.ILCompiler": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "vblLkpVhSDYOmrEW0jypX7YVtLg7idU1QzUyx45ZdZ2sFUSSf3mYFCr0FW3+KZgXWpN1ve9ZPrxNywvHISF4bA=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "wugvy+pBVzjQEnRs9wMTWwoaeNFX3hsaHeVHFDIvJSWXp7wfmNWu3mxAwBIE6pyW+g6+rHa1Of5fTzb0QVqUTA=="
}
}
}
}
-15
View File
@@ -1,15 +0,0 @@
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.");
}
}
}
-25
View File
@@ -1,25 +0,0 @@
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build
WORKDIR /src
COPY ["src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj", "src/GmRelay.DiscordBot/"]
COPY ["src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj", "src/GmRelay.ServiceDefaults/"]
COPY ["src/GmRelay.Shared/GmRelay.Shared.csproj", "src/GmRelay.Shared/"]
RUN dotnet restore "src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj"
COPY src/ src/
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/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"]
@@ -1,119 +0,0 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record DiscordDeleteSessionResult(
string ReplyText,
SessionBatchViewModel? UpdatedView,
string? EmptyMessage = null);
public sealed class DiscordDeleteSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
DiscordListSessionsHandler listSessionsHandler,
ILogger<DiscordDeleteSessionHandler> logger)
{
public async Task<DiscordDeleteSessionResult> HandleAsync(
string guildId,
string channelId,
ulong userId,
ulong resolvedPermissions,
ulong guildOwnerId,
Guid sessionId,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord'
AND p.platform = 'Discord'
AND g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
{
return new DiscordDeleteSessionResult(
"Только owner, администратор или manager могут удалять сессии.",
UpdatedView: null);
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
await connection.ExecuteAsync(
"SELECT pg_advisory_xact_lock(20260530, 108)",
transaction: transaction);
_ = await connection.QuerySingleOrDefaultAsync<Guid?>(
"""
SELECT s.id
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
AND g.platform = 'Discord'
AND g.external_group_id = @GuildId
FOR UPDATE OF s
""",
new { SessionId = sessionId, GuildId = guildId },
transaction);
await connection.ExecuteAsync(
"""
UPDATE portfolio_games pg
SET is_public = false,
updated_at = now()
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE pgs.portfolio_game_id = pg.id
AND s.id = @SessionId
AND g.platform = 'Discord'
AND g.external_group_id = @GuildId
AND pg.is_public = true
""",
new { SessionId = sessionId, GuildId = guildId },
transaction);
var deletedRows = await connection.ExecuteAsync(
"""
DELETE FROM sessions s
USING game_groups g
WHERE s.group_id = g.id
AND s.id = @SessionId
AND g.platform = 'Discord'
AND g.external_group_id = @GuildId
""",
new { SessionId = sessionId, GuildId = guildId },
transaction);
await transaction.CommitAsync(cancellationToken);
if (deletedRows == 0)
{
return new DiscordDeleteSessionResult(
"Сессия не найдена или уже удалена.",
UpdatedView: null);
}
logger.LogInformation("Deleted Discord session {SessionId} in guild {GuildId}", sessionId, guildId);
var updatedView = await listSessionsHandler.BuildScheduleAsync(
guildId,
channelId,
userId,
resolvedPermissions,
guildOwnerId,
cancellationToken);
return updatedView is null
? new DiscordDeleteSessionResult(
"Сессия удалена.",
UpdatedView: null,
EmptyMessage: "В этом сервере нет предстоящих игр.")
: new DiscordDeleteSessionResult("Сессия удалена.", updatedView);
}
}
@@ -1,48 +0,0 @@
using NetCord;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordListSessionsHandler _handler;
public DiscordListSessionsCommand(DiscordListSessionsHandler handler)
{
_handler = handler;
}
[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
public async Task ExecuteAsync()
{
var guildId = Context.Interaction.GuildId?.ToString()
?? throw new InvalidOperationException("This command can only be used in a guild.");
var channelId = Context.Channel.Id.ToString();
var member = Context.User as GuildInteractionUser;
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
var guildOwnerId = 0UL;
var view = await _handler.BuildScheduleAsync(
guildId,
channelId,
Context.User.Id,
resolvedPermissions,
guildOwnerId,
CancellationToken.None);
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)));
}
}
@@ -1,110 +0,0 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
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,
DiscordPermissionChecker permissionChecker)
{
public Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
CancellationToken cancellationToken) =>
BuildScheduleAsync(guildId, channelId, 0, 0, 0, cancellationToken);
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
ulong userId,
ulong resolvedPermissions,
ulong guildOwnerId,
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)::int as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted)::int 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() - interval '4 hours'
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 dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
new { GuildId = guildId });
var canManage = permissionChecker.CanManageSchedule(
guildOwnerId,
userId,
dbManagerUserIds,
resolvedPermissions);
var sessionIds = sessionList.Select(s => s.Id).ToList();
var participants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId,
p.display_name as DisplayName,
p.external_username as TelegramUsername,
sp.registration_status as RegistrationStatus
FROM session_participants sp
JOIN players p ON 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();
var view = SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
return canManage ? AddManagerActions(view) : view;
}
internal static SessionBatchViewModel AddManagerActions(SessionBatchViewModel view) =>
view with
{
Sessions = view.Sessions
.Select(session =>
{
if (SessionStatus.IsCancelled(session.Status))
return session;
var actions = session.AvailableActions
.Concat([new AvailableAction("delete_session", $"Удалить {session.ScheduledAt.FormatMoscowShort()}", session.SessionId)])
.ToList();
return session with { AvailableActions = actions };
})
.ToList()
};
}
@@ -1,128 +0,0 @@
using GmRelay.DiscordBot.Rendering;
using NetCord;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
namespace GmRelay.DiscordBot.Features.Sessions;
public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordNewSessionHandler _handler;
private readonly ILogger<DiscordNewSessionCommand> _logger;
public DiscordNewSessionCommand(DiscordNewSessionHandler handler, ILogger<DiscordNewSessionCommand> logger)
{
_handler = handler;
_logger = logger;
}
[SlashCommand("newsession", "Create a new game session")]
public async Task ExecuteAsync(
[SlashCommandParameter(Name = "title", Description = "Game title")] string title,
[SlashCommandParameter(Name = "time", Description = "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)")] string time,
[SlashCommandParameter(Name = "seats", Description = "Maximum number of players")] long? seats = null,
[SlashCommandParameter(Name = "link", Description = "Join link")] string? link = null)
{
_logger.LogInformation(
"newsession called by user {UserId} ({UserType}) in guild {GuildId}, channel {ChannelId}",
Context.User.Id,
Context.User.GetType().Name,
Context.Interaction.GuildId,
Context.Channel?.Id);
var guildId = Context.Interaction.GuildId
?? throw new InvalidOperationException("This command can only be used in a guild.");
var member = Context.User as GuildInteractionUser;
if (member is null)
{
_logger.LogError("Context.User is not GuildInteractionUser. Actual type: {ActualType}", Context.User.GetType().Name);
throw new InvalidOperationException("Guild member data not available in interaction.");
}
var resolvedPermissions = (ulong)member.Permissions;
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
ulong guildOwnerId = 0;
var guildName = guildId.ToString();
try
{
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
guildOwnerId = guild.OwnerId;
guildName = guild.Name;
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
}
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogWarning(
ex,
"Bot is not a REST member of guild {GuildId}; using resolved permissions from interaction payload",
guildId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error fetching guild {GuildId}", guildId);
}
var timeResult = DiscordNewSessionHandler.ParseTimeInput(time);
if (!timeResult.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"X {timeResult.Error}"));
return;
}
// Defer the response to avoid Discord 3-second interaction timeout
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
try
{
_logger.LogInformation("Creating session for guild {GuildId}, user {UserId}", guildId, Context.User.Id);
var view = await _handler.HandleAsync(
guildId: guildId.ToString(),
channelId: Context.Channel!.Id.ToString(),
groupName: guildName,
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
resolvedPermissions: resolvedPermissions,
guildOwnerId: guildOwnerId,
title: title,
scheduledAt: timeResult.Value,
maxPlayers: seats is null ? null : (int)seats.Value,
joinLink: link,
CancellationToken.None);
_logger.LogInformation("Session created successfully. Building render.");
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
_logger.LogInformation("Sending success response.");
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = ":white_check_mark: **Session created successfully!**";
message.Embeds = embeds;
message.Components = actionRows;
});
_logger.LogInformation("Success response sent.");
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning(ex, "Unauthorized session creation attempt by user {UserId}", Context.User.Id);
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = $":no_entry: {ex.Message}";
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create session for user {UserId} in guild {GuildId}", Context.User.Id, guildId);
await Context.Interaction.ModifyResponseAsync(message =>
{
message.Content = ":boom: An error occurred while creating the session.";
});
}
}
}
@@ -1,156 +0,0 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using System.Globalization;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);
public sealed class DiscordNewSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
ILogger<DiscordNewSessionHandler> logger)
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
public static TimeParseResult ParseTimeInput(string input)
{
var trimmed = input.Trim();
if (DateTime.TryParseExact(
trimmed,
"yyyy-MM-dd HH:mm",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var dt1))
{
var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, offset, null);
}
if (DateTime.TryParseExact(
trimmed,
"dd.MM.yyyy HH:mm",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var dt2))
{
var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, offset, 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,
string groupName,
ulong userId,
string userDisplayName,
ulong resolvedPermissions,
ulong guildOwnerId,
string title,
DateTimeOffset scheduledAt,
int? maxPlayers,
string? joinLink,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var displayGroupName = string.IsNullOrWhiteSpace(groupName) || string.Equals(groupName, guildId, StringComparison.Ordinal)
? title
: groupName.Trim();
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"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 p.platform = 'Discord'
AND g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
{
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут создавать сессии.");
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var transactionCommitted = false;
try
{
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);
var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
VALUES (@GroupName, '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 { GroupName = displayGroupName, GuildId = guildId, ChannelId = channelId },
transaction);
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);
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);
transactionCommitted = true;
logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId);
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
return SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
}
catch
{
if (!transactionCommitted)
{
await transaction.RollbackAsync(cancellationToken);
}
throw;
}
}
}

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