Toutsu 85ff3a7faf
PR Checks / test-and-build (pull_request) Successful in 9m54s
fix(discord): address code-review findings on wizard adapter (issue #112)
VERDICT from verifier (D:\Projects\Game\docs\review-report.md):
REQUEST_CHANGES — wizard was functionally broken at runtime.

## Critical

C-1. Choice-button customId was missing the 'choice:' segment.
    ButtonCustomId emitted 'wizard:btn:<step>:<value>' but the
    dispatcher's switch matches parts[1] == 'choice'. Every choice
    button (D&D 5e, Pathfinder, Waitlist, Publish, Confirm) fell
    into the default branch and showed 'Unknown button'.

    Fix: split into 3 customId helpers:
      ChoiceButtonCustomId(step, value)       -> 'wizard:btn:choice:<step>:<value>'
      ControlButtonCustomId(action)            -> 'wizard:btn:<action>:1'  (back/cancel/skip/create)
      ModalTriggerButtonCustomId(modalStep)    -> 'wizard:btn:modal:<modalStep>'
    Bulk-rewrote all 66 Btn() call sites in DiscordWizardStep.cs.

C-2. "Другое…" modal-trigger buttons were unrouted in dispatcher.
    Added 'parts[1] == "modal"' branch that opens the modal via
    InteractionCallback.Modal(BuildModal(parts[2], draft.ChatId)).

C-3. DiscordWizardSubmitter was leaking ex.Message from
    CreateSessionHandler to the user-visible draft embed. Postgres
    exceptions expose schema/constraint names. Replaced with
    generic user-facing error; full exception still logged
    server-side on the existing catch block.

## I-3 — parser-roundtrip tests (the gap that let C-1/C-2 through)

Added two real behavioural tests (not string-grep) to
DiscordWizardInteractionModuleSourceTests:
  - Renderer_And_Dispatcher_Agree_On_Wire_Format
  - ControlButtons_Are_Parsed_As_Control_Not_Choice
These mirror NetCord's [ComponentInteraction("wizard")] prefix
strip, run the parser, and assert the dispatcher would route to
the right branch. Catches the entire class of 'renderer and
dispatcher disagree on the wire format' regressions.

## I-6 — BuildResumeRow (cascading fix from C-1)

After C-1, BuildResumeRow's ButtonCustomId('cancel', '1') would
emit the wrong format. Switched to direct format strings
('wizard:btn:cancel:1', 'wizard:btn:resume:continue', etc.) which
match the dispatcher's 'back'/'cancel'/'create'/'resume' cases
directly, not the 'choice' prefix.

## Version sync (3.8.0 -> 3.9.0)

Directory.Build.props: <Version>3.9.0</Version>
compose.yaml: all 3 image tags -> 3.9.0
Version_ShouldBeSynchronizedForDiscordFeatureRelease test now green.

## Stats

build: 0 warnings, 0 errors
format: 0 of 279 files need changes
tests: 583 passed, 2 skipped (pre-existing), 0 failed
files: 7 changed, 226 +, 79 -
2026-06-05 23:09:24 +03:00
2026-04-13 13:52:49 +03:00
2026-05-12 16:25:17 +03:00

🎲 GM-Relay: TTRPG Session Scheduling Bot & Web Dashboard

GM-Relay — это комплексное решение для Мастеров Подземелий (ГМов), состоящее из высокопроизводительного Telegram-бота, Discord worker и удобного веб-интерфейса. Предназначено для автоматизации записи игроков на сессии, управления расписанием и проведения игр.

Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.

Текущая версия: v3.6.0.


Key Features

🤖 Telegram Bot

  • 📅 Создание расписаний (Batch Sessions): Создавайте сразу несколько игр одним сообщением изменения (на недельный месяц в перед).
  • 🖼 Обложки расписаний: И batch-посту можно прикрепить фото к /newsession или указать строку Картинка: https://...; бот отправит обложку перед сообщением записи.
  • Быстрые повторы расписания: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
  • Интерактивная запись и выход: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
  • 👥 Лимит мест и лист ожидания: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
  • 📁 Поддержка Форумов (Telegram Topics): Если /newsession запущен в теме форума Telegram, расписание и групповые уведомления остаются в этой теме; при запуске из корня форума бот создает отдельную тему и сообщает о необходимости прав admin/Manage Topics, если их не хватает.
  • Управление сессиями: 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, а при выходе участника первый ожидающий автоматически продвигается в основной состав.

🌐 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.

🛠 Технологический стек

Компонент Технология
Язык 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) не поддерживаются.


🚀 Быстрый старт (Docker Compose)

Требования: Docker и Docker Compose.

1. Настройка окружения

cp .env.example .env

Ключевые переменные .env:

# Токен от @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_BOT_USERNAME=ваше_имя_бота_здесь

# HTTPS URL Mini App, например https://your-domain.example/miniapp
TELEGRAM_MINI_APP_URL=https://your-domain.example/miniapp

POSTGRES_PASSWORD=ваш_надежный_пароль
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.

2. Запуск

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:

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).

Проверка бэкапов

docker compose exec db-backup ls -la /backups

Ручное создание дампа

docker compose exec db-backup sh -c "pg_dump -h db -U gmrelay -d gmrelay_db | gzip > /backups/gmrelay_db_manual.sql.gz"

Восстановление из бэкапа

# Использовать последний автоматический бэкап
./scripts/restore.sh

# Или указать конкретный файл
./scripts/restore.sh backups/gmrelay_db_20260512_030000.sql.gz

Warning

Восстановление перезаписывает текущую базу данных. Убедитесь, что вы понимаете последствия, прежде чем запускать restore.sh.

Переменные окружения (опциональные)

BACKUP_RETENTION_DAYS=7
BACKUP_VOLUME_NAME=game_pgbackups

🗂 Структура репозитория

├── 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                  # Шаблон переменных окружения

👨‍💻 Для разработчиков

  • Архитектура: проект следует Vertical Slice с явным DI. Подробности — в ADR-001 и ADR-002.
  • Добавление обработчика: из-за Native AOT все DI-регистрации выполняются вручную в src/GmRelay.Bot/Program.cs (assembly scanning не используется).
  • Миграции: SQL-скрипты добавляются как embedded resources в src/GmRelay.Bot/Migrations/ и применяются автоматически при старте бота через DbUp.
  • Тесты: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
  • Сборка: dotnet build

📜 Лицензия

MIT License. См. LICENSE.


Построено с ❤️ для TTRPG-сообщества.

S
Description
No description provided
Readme MIT 10 MiB
2026-06-13 21:32:07 +03:00
Languages
C# 81%
HTML 13.8%
CSS 3.6%
PLpgSQL 1%
Dockerfile 0.2%
Other 0.4%