6 Commits

Author SHA1 Message Date
Toutsu 41f2ea6e90 feat: add telegram mini app dashboard
Deploy Telegram Bot / build-and-push (push) Successful in 23s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-04-28 14:56:55 +03:00
Toutsu 5082dd4fcf fix: stack sidebar template nav item
Deploy Telegram Bot / build-and-push (push) Successful in 3m45s
Deploy Telegram Bot / deploy (push) Successful in 9s
2026-04-28 10:36:52 +03:00
Toutsu cfbda4ca05 fix: move campaign templates to dedicated tab
Deploy Telegram Bot / build-and-push (push) Successful in 3m28s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-04-28 10:22:12 +03:00
Toutsu 0218890a7a feat: add campaign templates and recurring schedules
Deploy Telegram Bot / build-and-push (push) Successful in 3m49s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-04-28 10:01:18 +03:00
Toutsu a1ec688ec8 feat: add multi-option reschedule voting
Deploy Telegram Bot / build-and-push (push) Successful in 3m44s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-27 14:58:32 +03:00
Toutsu 2529df4157 feat: support co-gm group delegation
Deploy Telegram Bot / build-and-push (push) Successful in 3m51s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-27 14:27:16 +03:00
51 changed files with 3858 additions and 448 deletions
+4
View File
@@ -6,6 +6,10 @@ TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN_HERE
# Найти его можно в информации о боте у @BotFather. # Найти его можно в информации о боте у @BotFather.
TELEGRAM_BOT_USERNAME=YOUR_BOT_USERNAME_HERE TELEGRAM_BOT_USERNAME=YOUR_BOT_USERNAME_HERE
# HTTPS URL Mini App dashboard, например: https://your-domain.example/miniapp
# Используется ботом для кнопки меню Telegram и кнопки /start.
TELEGRAM_MINI_APP_URL=
# Пароль для базы данных PostgreSQL # Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=StrongPasswordForDatabase POSTGRES_PASSWORD=StrongPasswordForDatabase
+2 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 1.5.0 VERSION: 1.9.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -64,6 +64,7 @@ jobs:
echo "TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}" > .env echo "TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}" > .env
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env
echo "TELEGRAM_MINI_APP_URL=${{ secrets.TELEGRAM_MINI_APP_URL }}" >> .env
- name: Deploy Containers - name: Deploy Containers
run: | run: |
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.5.0</Version> <Version>1.9.0</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+54 -7
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v1.5.0`. **Текущая версия:** `v1.9.0`.
--- ---
@@ -12,17 +12,22 @@
### 🤖 Telegram Бот ### 🤖 Telegram Бот
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед). - **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки. - **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему. - **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр. - **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
- **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания. - **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры прямо из Telegram.
- **🔄 Голосование за перенос**: При переносе сессии GM предлагает 2-3 новых времени и дедлайн, игроки голосуют кнопками, а бот показывает текущие результаты и применяет победивший вариант.
- **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются. - **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой. - **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
- **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**. - **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**.
### 🌐 Web Dashboard (Blazor Server) ### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация). - **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
- **📱 Telegram Mini App Dashboard**: Мобильная версия dashboard открывается прямо из Telegram, проверяет WebApp `initData` на сервере и использует те же права owner/co-GM, что и обычный Web Dashboard.
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов. - **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
- **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard.
- **📋 Шаблоны кампаний**: Owner и co-GM управляют типовыми параметрами кампаний в отдельной вкладке `Шаблоны`, а на странице группы запускают новый повторяющийся batch из выбранного шаблона.
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц. - **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`. - **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди. - **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
@@ -70,6 +75,10 @@ TELEGRAM_BOT_TOKEN=ваш_токен_здесь
# Используется для работы виджета авторизации (Telegram Login Widget). # Используется для работы виджета авторизации (Telegram Login Widget).
TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
# HTTPS URL Mini App dashboard, например: https://your-domain.example/miniapp.
# Используется кнопкой меню Telegram и кнопкой /start.
TELEGRAM_MINI_APP_URL=https://your-domain.example/miniapp
# Пароль для базы данных PostgreSQL # Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=ваш_надежный_пароль POSTGRES_PASSWORD=ваш_надежный_пароль
@@ -79,6 +88,8 @@ GMRELAY_WEB_PORT=8080
*(Опционально)* Настройте домен Telegram бота в @BotFather командой `/setdomain` для работы виджета авторизации на вашем сайте. *(Опционально)* Настройте домен Telegram бота в @BotFather командой `/setdomain` для работы виджета авторизации на вашем сайте.
Для Telegram Mini App настройте в @BotFather домен Web Dashboard и menu button на URL из `TELEGRAM_MINI_APP_URL`. Бот также показывает кнопку `Открыть dashboard` в ответе на `/start`, если переменная задана.
### 3. Запуск ### 3. Запуск
Выполните команду: Выполните команду:
```bash ```bash
@@ -103,7 +114,7 @@ docker compose up -d
* `Закрепление сообщений` — рекомендуется. * `Закрепление сообщений` — рекомендуется.
> [!TIP] > [!TIP]
> Колонку "Мастер" (GM) бот определяет по первому человеку, который создал сессию в этой группе. Только этот пользователь сможет отменять игры через кнопки бота и редактировать их в веб-интерфейсе. > Owner группы определяется по первому человеку, который создал сессию в этой группе. Owner может назначать co-GM в Web Dashboard; owner и co-GM могут управлять сессиями через кнопки бота и веб-интерфейс.
--- ---
@@ -123,22 +134,58 @@ docker compose up -d
Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard. Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard.
Для регулярной кампании можно не перечислять все даты вручную. Укажите одну строку `Время:`, количество игр и интервал в днях:
```text
/newsession
Название: Kingmaker
Время: 30.04.2026 19:30
Игр: 6
Интервал: 7
Мест: 5
Ссылка: https://discord.gg/invite-link
```
Бот создаст 6 игр с недельным шагом. Вместо `Игр:` также принимается `Сессий:` или `Повторов:`, вместо `Интервал:``Шаг:`.
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки. Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
### Bulk-операции в Web Dashboard ### Делегирование управления
На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. ГМ может: На странице группы Web Dashboard показывает owner и список co-GM. Owner может добавить помощника по Telegram ID, имени и username, а также снять роль co-GM. Назначенный co-GM видит группу в панели управления и может редактировать сессии, управлять batch-операциями, очередью, переносами и удалением игр, но не может назначать других co-GM.
### Перенос сессии голосованием
Owner или co-GM нажимает кнопку `⏰ Перенести` у нужной сессии и отправляет в чат 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; - обновить общий `title` и `link` сразу у всех сессий batch;
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления; - выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях; - перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
- клонировать batch на следующую неделю или следующий календарный месяц. - клонировать batch на следующую неделю или следующий календарный месяц.
После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается. При клонировании создаётся новая пачка с новым Telegram-сообщением и пустым составом игроков. После создания из шаблона или клонирования появляется новая пачка с новым Telegram-сообщением и пустым составом игроков. После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается.
Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам. Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам.
### Telegram Mini App Dashboard
Owner и co-GM могут открыть мобильный dashboard прямо из Telegram через кнопку меню бота или кнопку `Открыть dashboard` после `/start`. Страница `/miniapp` получает `Telegram.WebApp.initData`, отправляет его на серверный endpoint `/auth/telegram-webapp`, проходит HMAC-проверку токеном бота и выдаёт обычную cookie-сессию dashboard.
После входа Mini App использует те же страницы, что и Web Dashboard: список групп, карточки сессий, редактирование игры, повышение игрока из листа ожидания, применение шаблонов и bulk-операции batch. Доступ к чужим группам не появляется: все данные по-прежнему фильтруются через `AuthorizedSessionService` по роли owner/co-GM.
### Другие команды ### Другие команды
- `/listsessions` — Показать список всех актуальных игр в этой группе. - `/listsessions` — Показать список всех актуальных игр в этой группе.
- `/reschedulesession` — Перенести сессию на другое время с голосованием игроков. - `⏰ Перенести` в сообщении расписания — Запустить голосование по 2-3 вариантам нового времени.
- `/deletesession` — Удалить сессию. - `/deletesession` — Удалить сессию.
- `/exportcalendar` — Получить `.ics` файл с играми. - `/exportcalendar` — Получить `.ics` файл с играми.
- `/help` — Справка по формату. - `/help` — Справка по формату.
+4 -2
View File
@@ -17,7 +17,7 @@ services:
retries: 10 retries: 10
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.5.0 image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -25,11 +25,12 @@ services:
environment: environment:
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}" - "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__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
networks: networks:
- gmrelay - gmrelay
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.5.0 image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -38,6 +39,7 @@ services:
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}" - "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__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}" - "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}"
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
ports: ports:
- "${GMRELAY_WEB_PORT:-8080}:8080" - "${GMRELAY_WEB_PORT:-8080}:8080"
volumes: volumes:
@@ -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`.
@@ -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.
@@ -16,7 +16,7 @@ public sealed record CancelSessionCommand(
int MessageId); int MessageId);
// DTOs for AOT compilation // DTOs for AOT compilation
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, long GmId, string NotificationMode); internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, bool CanManage, string NotificationMode);
public sealed class CancelSessionHandler( public sealed class CancelSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
@@ -29,13 +29,23 @@ public sealed class CancelSessionHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Проверяем, что запрос делает ГМ данной сессии // 1. Проверяем, что запрос делает управляющий данной группы.
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>( var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
@"SELECT s.title as Title, s.batch_id as BatchId, g.gm_telegram_id as GmId, s.notification_mode as NotificationMode """
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.notification_mode AS NotificationMode,
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 FROM sessions s
JOIN game_groups g ON s.group_id = g.id WHERE s.id = @SessionId
WHERE s.id = @SessionId", """,
new { command.SessionId }, transaction); new { command.SessionId, command.TelegramUserId }, transaction);
if (session == null) if (session == null)
{ {
@@ -43,9 +53,9 @@ public sealed class CancelSessionHandler(
return; return;
} }
if (session.GmId != command.TelegramUserId) if (!session.CanManage)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может отменять сессию.", showAlert: true, cancellationToken: ct); await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", showAlert: true, cancellationToken: ct);
return; return;
} }
@@ -7,6 +7,8 @@ using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
public sealed class CreateSessionHandler( public sealed class CreateSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient botClient, ITelegramBotClient botClient,
@@ -40,11 +42,19 @@ public sealed class CreateSessionHandler(
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
} }
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
{
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
cancellationToken: cancellationToken);
}
if (!parseResult.IsValid) if (!parseResult.IsValid)
{ {
await botClient.SendMessage( await botClient.SendMessage(
chatId: message.Chat.Id, 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", 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\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7",
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
return; return;
} }
@@ -74,16 +84,64 @@ public sealed class CreateSessionHandler(
new { TgId = gmId, Name = gmName, Username = gmUsername }, new { TgId = gmId, Name = gmName, Username = gmUsername },
transaction); transaction);
var groupId = await connection.ExecuteScalarAsync<Guid>( 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)
{
groupId = await connection.ExecuteScalarAsync<Guid>(
""" """
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id) INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
VALUES (@ChatId, @ChatName, @GmId) VALUES (@ChatId, @ChatName, @GmId)
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
RETURNING id; RETURNING id;
""", """,
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId }, new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
transaction); 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 transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(
chatId,
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
cancellationToken: cancellationToken);
return;
}
groupId = existingGroup.GroupId;
await connection.ExecuteAsync(
"UPDATE game_groups SET name = @ChatName WHERE id = @GroupId",
new { ChatName = chatTitle, GroupId = groupId },
transaction);
}
int? messageThreadId = null; int? messageThreadId = null;
if (message.Chat.IsForum) if (message.Chat.IsForum)
{ {
@@ -9,17 +9,21 @@ internal sealed record NewSessionParseResult(
IReadOnlyList<DateTimeOffset> ScheduledTimes, IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs, IReadOnlyList<string> PastTimeInputs,
IReadOnlyList<string> InvalidTimeInputs, IReadOnlyList<string> InvalidTimeInputs,
IReadOnlyList<string> InvalidSeatLimitInputs) IReadOnlyList<string> InvalidSeatLimitInputs,
IReadOnlyList<string> InvalidRecurringInputs)
{ {
public bool IsValid => public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) && !string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) && !string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.Count > 0 && ScheduledTimes.Count > 0 &&
InvalidSeatLimitInputs.Count == 0; InvalidSeatLimitInputs.Count == 0 &&
InvalidRecurringInputs.Count == 0;
} }
internal static class NewSessionCommandParser 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 TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:"; private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:"; private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
@@ -29,16 +33,30 @@ internal static class NewSessionCommandParser
"\u041b\u0438\u043c\u0438\u0442:", "\u041b\u0438\u043c\u0438\u0442:",
"\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:" "\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) public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
{ {
string? title = null; string? title = null;
string? link = null; string? link = null;
int? maxPlayers = null; int? maxPlayers = null;
int? recurringCount = null;
var recurringIntervalDays = 7;
var scheduledTimes = new List<DateTimeOffset>(); var scheduledTimes = new List<DateTimeOffset>();
var pastTimeInputs = new List<string>(); var pastTimeInputs = new List<string>();
var invalidTimeInputs = new List<string>(); var invalidTimeInputs = new List<string>();
var invalidSeatLimitInputs = new List<string>(); var invalidSeatLimitInputs = new List<string>();
var invalidRecurringInputs = new List<string>();
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries)) foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
{ {
@@ -71,6 +89,42 @@ internal static class NewSessionCommandParser
continue; 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)) if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
{ {
continue; continue;
@@ -92,6 +146,14 @@ internal static class NewSessionCommandParser
scheduledTimes.Add(scheduledAt); 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( return new NewSessionParseResult(
title, title,
link, link,
@@ -99,6 +161,7 @@ internal static class NewSessionCommandParser
scheduledTimes, scheduledTimes,
pastTimeInputs, pastTimeInputs,
invalidTimeInputs, invalidTimeInputs,
invalidSeatLimitInputs); invalidSeatLimitInputs,
invalidRecurringInputs);
} }
} }
@@ -13,7 +13,7 @@ public sealed record PromoteWaitlistedPlayerCommand(
long ChatId, long ChatId,
int MessageId); int MessageId);
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, long GmId, int? MaxPlayers); internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, bool CanManage, int? MaxPlayers);
internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName); internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName);
public sealed class PromoteWaitlistedPlayerHandler( public sealed class PromoteWaitlistedPlayerHandler(
@@ -34,13 +34,18 @@ public sealed class PromoteWaitlistedPlayerHandler(
SELECT s.title AS Title, SELECT s.title AS Title,
s.batch_id AS BatchId, s.batch_id AS BatchId,
s.max_players AS MaxPlayers, s.max_players AS MaxPlayers,
g.gm_telegram_id AS GmId 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 FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId WHERE s.id = @SessionId
FOR UPDATE FOR UPDATE
""", """,
new { command.SessionId }, new { command.SessionId, command.TelegramUserId },
transaction); transaction);
if (session is null) if (session is null)
@@ -50,10 +55,10 @@ public sealed class PromoteWaitlistedPlayerHandler(
return; return;
} }
if (session.GmId != command.TelegramUserId) if (!session.CanManage)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct); await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct);
return; return;
} }
@@ -12,7 +12,7 @@ public sealed record DeleteSessionCommand(
long ChatId, long ChatId,
int MessageId); int MessageId);
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, long GmId, int? ThreadId); internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, bool CanManage, int? ThreadId);
public sealed class DeleteSessionHandler( public sealed class DeleteSessionHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
@@ -24,13 +24,23 @@ public sealed class DeleteSessionHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Fetch session and verify GM // 1. Fetch session and verify group manager.
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>( var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
@"SELECT s.title as Title, s.batch_id as BatchId, s.thread_id as ThreadId, g.gm_telegram_id as GmId """
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 FROM sessions s
JOIN game_groups g ON s.group_id = g.id WHERE s.id = @SessionId
WHERE s.id = @SessionId", """,
new { command.SessionId }, transaction); new { command.SessionId, command.TelegramUserId }, transaction);
if (session == null) if (session == null)
{ {
@@ -38,9 +48,9 @@ public sealed class DeleteSessionHandler(
return; return;
} }
if (session.GmId != command.TelegramUserId) if (!session.CanManage)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может удалять сессию.", showAlert: true, cancellationToken: ct); await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может удалять сессию.", showAlert: true, cancellationToken: ct);
return; return;
} }
@@ -77,16 +87,23 @@ public sealed class DeleteSessionHandler(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers, @"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 = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount, COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
g.gm_telegram_id as GmId 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 FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW() WHERE g.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, g.gm_telegram_id GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new new
{ {
ChatId = command.ChatId, ChatId = command.ChatId,
command.TelegramUserId,
Cancelled = SessionStatus.Cancelled, Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active, Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted Waitlisted = ParticipantRegistrationStatus.Waitlisted
@@ -110,8 +127,8 @@ public sealed class DeleteSessionHandler(
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n"; text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
} }
var isGm = command.TelegramUserId == sessionsList.First().GmId; var canManage = sessionsList.First().CanManage;
var keyboard = isGm var keyboard = canManage
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup( ? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") })) sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
: null; : null;
@@ -6,7 +6,7 @@ using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.ListSessions; namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, long GmId); internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
public sealed class ListSessionsHandler( public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
@@ -20,16 +20,23 @@ public sealed class ListSessionsHandler(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers, @"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 = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount, COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
g.gm_telegram_id as GmId 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 FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW() WHERE g.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, g.gm_telegram_id GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new new
{ {
ChatId = message.Chat.Id, ChatId = message.Chat.Id,
TelegramUserId = message.From?.Id,
Cancelled = SessionStatus.Cancelled, Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active, Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted Waitlisted = ParticipantRegistrationStatus.Waitlisted
@@ -56,8 +63,8 @@ public sealed class ListSessionsHandler(
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n"; text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
} }
var isGm = message.From?.Id == sessionsList.First().GmId; var canManage = sessionsList.First().CanManage;
var keyboard = isGm var keyboard = canManage
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup( ? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") })) sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
: null; : null;
@@ -25,7 +25,8 @@ internal sealed record VoteParticipantDto(
/// <summary> /// <summary>
/// Handles text input from the GM who has an AwaitingTime proposal. /// Handles text input from the GM who has an AwaitingTime proposal.
/// Parses the new time, creates a voting message, and tags all participants. /// Parses reschedule options with a voting deadline, creates a voting message,
/// and tags all participants.
/// If no participants are registered, reschedules immediately. /// If no participants are registered, reschedules immediately.
/// </summary> /// </summary>
public sealed class HandleRescheduleTimeInputHandler( public sealed class HandleRescheduleTimeInputHandler(
@@ -62,6 +63,13 @@ public sealed class HandleRescheduleTimeInputHandler(
WHERE rp.proposed_by = @GmId WHERE rp.proposed_by = @GmId
AND rp.status = 'AwaitingTime' AND rp.status = 'AwaitingTime'
AND g.telegram_chat_id = @ChatId 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 ORDER BY rp.created_at DESC
LIMIT 1 LIMIT 1
""", """,
@@ -70,26 +78,17 @@ public sealed class HandleRescheduleTimeInputHandler(
if (proposal is null) if (proposal is null)
return false; return false;
// 2. Parse the new time // 2. Parse voting input
if (!MoscowTime.TryParseMoscow(text, out var newTime)) if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
{ {
await bot.SendMessage( await bot.SendMessage(
chatId: chatId, chatId: chatId,
text: "⚠️ Не удалось распознать время. Используйте формат: <code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\nНапример: <code>25.04.2026 19:30</code>", 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, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct); cancellationToken: ct);
return true; return true;
} }
if (newTime <= DateTimeOffset.UtcNow)
{
await bot.SendMessage(
chatId: chatId,
text: "⚠️ Новое время должно быть в будущем. Попробуйте снова.",
cancellationToken: ct);
return true;
}
// 3. Load participants (non-GM) signed up for this session // 3. Load participants (non-GM) signed up for this session
var participants = (await connection.QueryAsync<VoteParticipantDto>( var participants = (await connection.QueryAsync<VoteParticipantDto>(
""" """
@@ -108,35 +107,56 @@ public sealed class HandleRescheduleTimeInputHandler(
// 4. If no participants — reschedule immediately // 4. If no participants — reschedule immediately
if (participants.Count == 0) if (participants.Count == 0)
{ {
await RescheduleImmediately(connection, proposal, newTime, chatId, ct); await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct);
await TryDeleteMessage(chatId, message.MessageId, ct); await TryDeleteMessage(chatId, message.MessageId, ct);
return true; return true;
} }
// 5. Create voting message // 5. Create voting message
await using var transaction = await connection.BeginTransactionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
var options = votingInput.Options
.Select((proposedAt, index) => new RescheduleOptionDto(
Guid.NewGuid(),
index + 1,
proposedAt))
.ToList();
// Update proposal with proposed time and Voting status
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
UPDATE reschedule_proposals UPDATE reschedule_proposals
SET proposed_at = @ProposedAt, status = 'Voting', vote_chat_id = @ChatId SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @ChatId
WHERE id = @Id WHERE id = @Id
""", """,
new { ProposedAt = newTime, ChatId = chatId, Id = proposal.Id }, new { votingInput.Deadline, ChatId = chatId, Id = proposal.Id },
transaction); 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); await transaction.CommitAsync(ct);
// Build voting message text var voteText = BuildVotingMessage(
var voteText = BuildVotingMessage(proposal.Title, proposal.CurrentScheduledAt, newTime, participants, []); proposal.Title,
proposal.CurrentScheduledAt,
var keyboard = new InlineKeyboardMarkup([ votingInput.Deadline,
[ options,
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{proposal.Id}"), participants,
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{proposal.Id}") []);
] var keyboard = BuildVotingKeyboard(options);
]);
var voteMsg = await bot.SendMessage( var voteMsg = await bot.SendMessage(
chatId: chatId, chatId: chatId,
@@ -148,12 +168,18 @@ public sealed class HandleRescheduleTimeInputHandler(
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode); var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages()) if (mode.ShouldSendDirectMessages())
{ {
var optionsText = string.Join(
"\n",
options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
var directText = $""" var directText = $"""
🔄 <b>Голосование за перенос сессии</b> 🔄 <b>Голосование за перенос сессии</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b> 📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК) 📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
📅 Новое время: <b>{newTime.FormatMoscow()}</b> (МСК) 🗳 Варианты:
{optionsText}
⏳ Дедлайн: <b>{votingInput.Deadline.FormatMoscow()}</b> (МСК)
Проголосуйте кнопкой в групповом сообщении. Проголосуйте кнопкой в групповом сообщении.
"""; """;
@@ -173,7 +199,12 @@ public sealed class HandleRescheduleTimeInputHandler(
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id", "UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
new { MsgId = voteMsg.MessageId, Id = proposal.Id }); new { MsgId = voteMsg.MessageId, Id = proposal.Id });
logger.LogInformation("Reschedule voting started for session {SessionId}, proposal {ProposalId}", proposal.SessionId, proposal.Id); logger.LogInformation(
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
proposal.SessionId,
proposal.Id,
options.Count,
votingInput.Deadline);
// Delete GM's time input message // Delete GM's time input message
await TryDeleteMessage(chatId, message.MessageId, ct); await TryDeleteMessage(chatId, message.MessageId, ct);
@@ -219,33 +250,105 @@ public sealed class HandleRescheduleTimeInputHandler(
} }
internal static string BuildVotingMessage( internal static string BuildVotingMessage(
string title, DateTime currentTime, DateTimeOffset newTime, string title,
DateTime currentTime,
DateTimeOffset deadline,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants, IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyCollection<Guid> approvedPlayerIds) IReadOnlyList<RescheduleOptionVoteDto> votes)
{ {
var votesByOption = votes
.GroupBy(v => v.OptionId)
.ToDictionary(g => g.Key, g => g.ToList());
var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet();
var pendingParticipants = participants
.Where(p => !votedPlayerIds.Contains(p.PlayerId))
.Select(FormatParticipantName)
.ToList();
var lines = new List<string> var lines = new List<string>
{ {
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>", $"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
"", "",
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)", $"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
$"📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)", $"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
"", "",
"Для переноса нужно согласие всех участников:" "Выберите один из вариантов:"
}; };
foreach (var p in participants) foreach (var option in options.OrderBy(x => x.DisplayOrder))
{ {
var name = p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName; var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []);
var icon = approvedPlayerIds.Contains(p.PlayerId) ? "✅" : "⏳"; lines.Add(
lines.Add($" {icon} {name}"); $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК) — {FormatVoteCount(optionVotes.Count)}");
if (optionVotes.Count > 0)
{
lines.Add($" {string.Join(", ", optionVotes.Select(FormatParticipantName))}");
}
}
if (pendingParticipants.Count > 0)
{
lines.Add("");
lines.Add($"Не проголосовали: {string.Join(", ", pendingParticipants)}");
} }
lines.Add(""); lines.Add("");
lines.Add($"Голоса: {approvedPlayerIds.Count}/{participants.Count}"); lines.Add($"Голосов: {votedPlayerIds.Count}/{participants.Count}");
lines.Add("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется.");
return string.Join("\n", lines); return string.Join("\n", lines);
} }
internal static InlineKeyboardMarkup BuildVotingKeyboard(IReadOnlyList<RescheduleOptionDto> options)
{
return new InlineKeyboardMarkup(
options
.OrderBy(option => option.DisplayOrder)
.Select(option => new[]
{
InlineKeyboardButton.WithCallbackData(
$"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}",
$"reschedule_vote:{option.OptionId}")
}));
}
internal static string FormatParticipantName(VoteParticipantDto participant)
{
return participant.TelegramUsername is { Length: > 0 } username
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
: System.Net.WebUtility.HtmlEncode(participant.DisplayName);
}
internal static string FormatParticipantName(RescheduleOptionVoteDto vote)
{
return vote.TelegramUsername is { Length: > 0 } username
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
: System.Net.WebUtility.HtmlEncode(vote.DisplayName);
}
private static string FormatVoteCount(int count)
{
var modulo100 = count % 100;
var modulo10 = count % 10;
var word = modulo100 is >= 11 and <= 14
? "голосов"
: modulo10 switch
{
1 => "голос",
>= 2 and <= 4 => "голоса",
_ => "голосов"
};
return $"{count} {word}";
}
private static string FormatButtonTime(DateTimeOffset utc)
=> utc.ToOffset(TimeSpan.FromHours(3)).ToString(
"dd.MM HH:mm",
System.Globalization.CultureInfo.InvariantCulture);
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct) private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
{ {
try try
@@ -1,16 +1,12 @@
using Dapper; using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
public sealed record HandleRescheduleVoteCommand( public sealed record HandleRescheduleVoteCommand(
Guid ProposalId, Guid OptionId,
string Vote,
long TelegramUserId, long TelegramUserId,
string CallbackQueryId, string CallbackQueryId,
long ChatId, long ChatId,
@@ -19,20 +15,13 @@ public sealed record HandleRescheduleVoteCommand(
internal sealed record VoteProposalDto( internal sealed record VoteProposalDto(
Guid Id, Guid Id,
Guid SessionId, Guid SessionId,
DateTime ProposedAt, DateTimeOffset VotingDeadlineAt,
string Title, string Title,
DateTime CurrentScheduledAt, DateTime CurrentScheduledAt);
Guid BatchId,
string SessionStatus,
long TelegramChatId,
int? ConfirmationMessageId,
int? BatchMessageId,
string NotificationMode);
public sealed class HandleRescheduleVoteHandler( public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<HandleRescheduleVoteHandler> logger) ILogger<HandleRescheduleVoteHandler> logger)
{ {
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct) public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
@@ -44,21 +33,15 @@ public sealed class HandleRescheduleVoteHandler(
""" """
SELECT rp.id AS Id, SELECT rp.id AS Id,
rp.session_id AS SessionId, rp.session_id AS SessionId,
rp.proposed_at AS ProposedAt, rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title, s.title AS Title,
s.scheduled_at AS CurrentScheduledAt, s.scheduled_at AS CurrentScheduledAt
s.batch_id AS BatchId, FROM reschedule_options ro
s.status AS SessionStatus, JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
s.confirmation_message_id AS ConfirmationMessageId,
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 sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id WHERE ro.id = @OptionId AND rp.status = 'Voting'
WHERE rp.id = @ProposalId AND rp.status = 'Voting'
""", """,
new { command.ProposalId }, new { command.OptionId },
transaction); transaction);
if (proposal is null) if (proposal is null)
@@ -70,6 +53,16 @@ public sealed class HandleRescheduleVoteHandler(
return; return;
} }
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
{
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Дедлайн уже прошёл. Результаты скоро будут применены.",
showAlert: true,
cancellationToken: ct);
return;
}
var playerId = await connection.ExecuteScalarAsync<Guid?>( var playerId = await connection.ExecuteScalarAsync<Guid?>(
""" """
SELECT p.id SELECT p.id
@@ -94,18 +87,21 @@ public sealed class HandleRescheduleVoteHandler(
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
INSERT INTO reschedule_votes (proposal_id, player_id, vote) INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @Vote) VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE ON CONFLICT (proposal_id, player_id) DO UPDATE
SET vote = EXCLUDED.vote, SET option_id = EXCLUDED.option_id,
voted_at = now() voted_at = now()
""", """,
new { command.ProposalId, PlayerId = playerId.Value, command.Vote }, new
{
ProposalId = proposal.Id,
PlayerId = playerId.Value,
command.OptionId
},
transaction); transaction);
var participants = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase) var participants = (await connection.QueryAsync<VoteParticipantDto>(
? new List<VoteParticipantDto>()
: (await connection.QueryAsync<VoteParticipantDto>(
""" """
SELECT p.id AS PlayerId, SELECT p.id AS PlayerId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
@@ -116,159 +112,47 @@ public sealed class HandleRescheduleVoteHandler(
WHERE sp.session_id = @SessionId WHERE sp.session_id = @SessionId
AND sp.is_gm = false AND sp.is_gm = false
AND sp.registration_status = @Active AND sp.registration_status = @Active
ORDER BY p.display_name
""", """,
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active }, new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList(); transaction)).ToList();
var approvedPlayerIds = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase) var options = (await connection.QueryAsync<RescheduleOptionDto>(
? new HashSet<Guid>()
: (await connection.QueryAsync<Guid>(
""" """
SELECT player_id SELECT id AS OptionId,
FROM reschedule_votes display_order AS DisplayOrder,
WHERE proposal_id = @ProposalId AND vote = 'yes' proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""", """,
new { command.ProposalId }, new { ProposalId = proposal.Id },
transaction)).ToHashSet(); transaction)).ToList();
var decision = RescheduleVoteRules.Evaluate(command.Vote, participants.Count, approvedPlayerIds.Count); var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
if (decision.Outcome == RescheduleVoteOutcome.Rejected)
{
var directRecipients = await LoadDirectRecipients(connection, proposal.SessionId, transaction);
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
new { Id = command.ProposalId },
transaction);
await transaction.CommitAsync(ct);
var voterName = await connection.QuerySingleOrDefaultAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
new { command.TelegramUserId });
try
{
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: $"❌ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» отклонён!</b>\n\n{voterName ?? "Участник"} проголосовал(а) против. Время сессии остаётся прежним:\n📅 <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update vote message after rejection");
}
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await directSender.SendAsync(
directRecipients,
$"❌ <b>Перенос сессии отклонён</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>\n📅 Время остаётся прежним: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)",
"reschedule-rejected",
proposal.SessionId,
ct);
}
logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId);
return;
}
if (decision.ShouldRescheduleSession)
{
var directRecipients = await LoadDirectRecipients(connection, proposal.SessionId, transaction);
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero);
await connection.ExecuteAsync(
""" """
UPDATE sessions SELECT rov.option_id AS OptionId,
SET scheduled_at = @NewTime, p.id AS PlayerId,
status = @Status, p.display_name AS DisplayName,
confirmation_message_id = NULL, p.telegram_username AS TelegramUsername
link_message_id = NULL, FROM reschedule_option_votes rov
one_hour_reminder_processed_at = NULL, JOIN players p ON p.id = rov.player_id
updated_at = now() WHERE rov.proposal_id = @ProposalId
WHERE id = @SessionId ORDER BY rov.voted_at, p.display_name
""", """,
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned }, new { ProposalId = proposal.Id },
transaction); transaction)).ToList();
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Approved' WHERE id = @Id",
new { Id = command.ProposalId },
transaction);
if (decision.ShouldResetParticipantRsvps)
{
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 transaction.CommitAsync(ct);
try
{
await bot.EditMessageText(
chatId: command.ChatId,
messageId: command.MessageId,
text: $"✅ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» одобрен!</b>\n\nВсе участники согласились.\n📅 Новое время: <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК)\n\n<i>Уведомления будут приходить согласно новому расписанию.</i>",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update vote message after approval");
}
await TryUpdateBatchMessage(proposal, ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await directSender.SendAsync(
directRecipients,
$"✅ <b>Сессия перенесена</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>\n📅 Новое время: <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК)",
"reschedule-approved",
proposal.SessionId,
ct);
}
logger.LogInformation(
"Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
proposal.SessionId,
newTime,
command.ProposalId);
}
else
{
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage( var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title, proposal.Title,
proposal.CurrentScheduledAt, proposal.CurrentScheduledAt,
new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero), proposal.VotingDeadlineAt,
options,
participants, participants,
approvedPlayerIds); votes);
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
var keyboard = new InlineKeyboardMarkup([
[
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{command.ProposalId}"),
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{command.ProposalId}")
]
]);
try try
{ {
@@ -282,80 +166,12 @@ public sealed class HandleRescheduleVoteHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to update vote message with progress"); logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
}
} }
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct); await bot.AnswerCallbackQuery(
} command.CallbackQueryId,
"Ваш голос учтён. До дедлайна его можно изменить.",
private static async Task<List<DirectNotificationRecipient>> LoadDirectRecipients(
Npgsql.NpgsqlConnection connection,
Guid sessionId,
Npgsql.NpgsqlTransaction transaction)
{
return (await connection.QueryAsync<DirectNotificationRecipient>(
"""
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
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
}
private async Task TryUpdateBatchMessage(VoteProposalDto 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 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.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 renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText(
chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct); cancellationToken: ct);
} }
else
{
await bot.SendMessage(
chatId: proposal.TelegramChatId,
text: $"📣 Расписание обновлено! Сессия «{proposal.Title}» перенесена на <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК).",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message for proposal {ProposalId}", proposal.Id);
}
}
} }
@@ -16,14 +16,14 @@ public sealed record InitiateRescheduleCommand(
// ── DTOs ───────────────────────────────────────────────────────────── // ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record RescheduleSessionInfoDto(string Title, long GmId); internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage);
// ── Handler ────────────────────────────────────────────────────────── // ── Handler ──────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// Handles the "⏰ Перенести" button press from the batch message. /// Handles the "⏰ Перенести" button press from the batch message.
/// Creates a reschedule proposal in AwaitingTime status and prompts /// Creates a reschedule proposal in AwaitingTime status and prompts
/// the GM to enter the new time via a regular text message. /// the GM to enter 2-3 new time options and a voting deadline.
/// </summary> /// </summary>
public sealed class InitiateRescheduleHandler( public sealed class InitiateRescheduleHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
@@ -34,15 +34,21 @@ public sealed class InitiateRescheduleHandler(
{ {
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
// 1. Verify GM ownership // 1. Verify group management access.
var session = await connection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>( var session = await connection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
""" """
SELECT s.title AS Title, g.gm_telegram_id AS GmId SELECT s.title AS Title,
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 FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId AND s.status != @Cancelled WHERE s.id = @SessionId AND s.status != @Cancelled
""", """,
new { command.SessionId, Cancelled = SessionStatus.Cancelled }); new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled });
if (session is null) if (session is null)
{ {
@@ -50,10 +56,10 @@ public sealed class InitiateRescheduleHandler(
return; return;
} }
if (session.GmId != command.TelegramUserId) if (!session.CanManage)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Только Мастер Игры (GM) может переносить сессию.", showAlert: true, cancellationToken: ct); "Только owner или co-GM может переносить сессию.", showAlert: true, cancellationToken: ct);
return; return;
} }
@@ -86,11 +92,20 @@ public sealed class InitiateRescheduleHandler(
// 4. Prompt GM in chat // 4. Prompt GM in chat
await bot.AnswerCallbackQuery(command.CallbackQueryId, await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Введите новое время в чат (формат: ДД.ММ.ГГГГ ЧЧ:ММ)", cancellationToken: ct); "Введите 2-3 варианта времени и дедлайн голосования.", cancellationToken: ct);
await bot.SendMessage( await bot.SendMessage(
chatId: command.ChatId, chatId: command.ChatId,
text: $"⏰ Укажите новое время для сессии «{session.Title}» в формате:\n<code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\n\nНапример: <code>25.04.2026 19:30</code>", text: $"""
⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.
Формат:
<code>25.04.2026 19:30
26.04.2026 18:00
Дедлайн: 25.04.2026 12:00</code>
Дедлайн должен быть в будущем и раньше первого предложенного времени.
""",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct); cancellationToken: ct);
} }
@@ -9,27 +9,57 @@ internal enum RescheduleVoteOutcome
internal sealed record RescheduleVoteDecision( internal sealed record RescheduleVoteDecision(
RescheduleVoteOutcome Outcome, RescheduleVoteOutcome Outcome,
string CallbackText, string Reason,
bool ShouldRescheduleSession, Guid? SelectedOptionId = null,
bool ShouldResetParticipantRsvps); string CallbackText = "",
bool ShouldRescheduleSession = false,
bool ShouldResetParticipantRsvps = false);
internal static class RescheduleVoteRules internal static class RescheduleVoteRules
{ {
public static RescheduleVoteDecision SelectWinner(IReadOnlyList<RescheduleOptionVoteCount> voteCounts)
{
var maxVotes = voteCounts.Count == 0 ? 0 : voteCounts.Max(x => x.VoteCount);
if (maxVotes == 0)
{
return new RescheduleVoteDecision(
RescheduleVoteOutcome.Rejected,
"Никто не проголосовал до дедлайна, перенос не применяется.");
}
var winners = voteCounts.Where(x => x.VoteCount == maxVotes).ToList();
if (winners.Count > 1)
{
return new RescheduleVoteDecision(
RescheduleVoteOutcome.Rejected,
"Голоса разделились поровну, перенос не применяется.");
}
return new RescheduleVoteDecision(
RescheduleVoteOutcome.Approved,
"Победил вариант с большинством голосов.",
winners[0].OptionId,
ShouldRescheduleSession: true,
ShouldResetParticipantRsvps: true);
}
public static RescheduleVoteDecision Evaluate(string vote, int totalParticipants, int approvedParticipants) public static RescheduleVoteDecision Evaluate(string vote, int totalParticipants, int approvedParticipants)
{ {
if (string.Equals(vote, "no", StringComparison.OrdinalIgnoreCase)) if (string.Equals(vote, "no", StringComparison.OrdinalIgnoreCase))
{ {
return new RescheduleVoteDecision( return new RescheduleVoteDecision(
Outcome: RescheduleVoteOutcome.Rejected, Outcome: RescheduleVoteOutcome.Rejected,
CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.", Reason: "\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.",
ShouldRescheduleSession: false, 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.");
ShouldResetParticipantRsvps: false);
} }
var everyoneApproved = approvedParticipants == totalParticipants; var everyoneApproved = approvedParticipants == totalParticipants;
return new RescheduleVoteDecision( return new RescheduleVoteDecision(
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending, 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 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! \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!", : "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!",
@@ -0,0 +1,361 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record DueRescheduleProposalDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
int? BatchMessageId,
int? VoteMessageId,
long TelegramChatId,
string NotificationMode);
public sealed class RescheduleVotingDeadlineService(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await ProcessDueProposals(stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await ProcessDueProposals(stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
}
private async Task ProcessDueProposals(CancellationToken ct)
{
try
{
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)
{
await FinalizeProposal(proposalId, ct);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process due reschedule voting proposals");
}
}
private async Task FinalizeProposal(Guid proposalId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var proposal = await connection.QuerySingleOrDefaultAsync<DueRescheduleProposalDto>(
"""
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,
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 },
transaction);
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 = participants
.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName))
.ToList();
await transaction.CommitAsync(ct);
await TryUpdateVoteMessage(proposal, options, participants, votes, decision, selectedOption, ct);
if (selectedOption is not null)
{
await TryUpdateBatchMessage(proposal, ct);
}
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await SendDirectResult(proposal, directRecipients, decision, selectedOption, ct);
}
logger.LogInformation(
"Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
proposal.Id,
proposal.SessionId,
decision.Outcome);
}
private async Task TryUpdateVoteMessage(
DueRescheduleProposalDto proposal,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyList<RescheduleOptionVoteDto> votes,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
CancellationToken ct)
{
if (proposal.VoteMessageId is null)
return;
try
{
var resultText = selectedOption is not null
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {selectedOption.DisplayOrder}: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(decision.Reason)}";
var text = $"""
{HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title,
proposal.CurrentScheduledAt,
proposal.VotingDeadlineAt,
options,
participants,
votes)}
{resultText}
""";
await bot.EditMessageText(
chatId: proposal.TelegramChatId,
messageId: proposal.VoteMessageId.Value,
text: text,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", proposal.Id);
}
}
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 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.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 renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText(
chatId: proposal.TelegramChatId,
messageId: proposal.BatchMessageId.Value,
text: renderResult.Text,
parseMode: ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
}
else
{
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}", proposal.Id);
}
}
private async Task SendDirectResult(
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(
recipients,
htmlText,
selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
proposal.SessionId,
ct);
}
}
@@ -0,0 +1,110 @@
using System.Text.RegularExpressions;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record RescheduleVotingInput(
IReadOnlyList<DateTimeOffset> Options,
DateTimeOffset Deadline)
{
private static readonly Regex DateTimePattern = new(
@"(?<date>\d{1,2}\.\d{2}\.\d{4})\s+(?<time>\d{1,2}:\d{2})",
RegexOptions.CultureInvariant);
public static bool TryParse(
string text,
DateTimeOffset nowUtc,
out RescheduleVotingInput input,
out string error)
{
input = new RescheduleVotingInput([], default);
error = string.Empty;
var options = new List<DateTimeOffset>();
DateTimeOffset? deadline = null;
foreach (var rawLine in text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var line = rawLine.Trim();
var match = DateTimePattern.Match(line);
if (!match.Success)
continue;
var value = $"{match.Groups["date"].Value} {match.Groups["time"].Value}";
if (!MoscowTime.TryParseMoscow(value, out var parsed))
continue;
if (IsDeadlineLine(line))
{
deadline = parsed;
}
else
{
options.Add(parsed);
}
}
if (options.Count is < 2 or > 3)
{
error = "Укажите от 2 до 3 вариантов времени.";
return false;
}
if (options.Distinct().Count() != options.Count)
{
error = "Варианты времени не должны повторяться.";
return false;
}
if (deadline is null)
{
error = "Укажите дедлайн голосования строкой «Дедлайн: ДД.ММ.ГГГГ ЧЧ:ММ».";
return false;
}
if (options.Any(option => option <= nowUtc))
{
error = "Все варианты времени должны быть в будущем.";
return false;
}
if (deadline.Value <= nowUtc)
{
error = "Дедлайн голосования должен быть в будущем.";
return false;
}
if (deadline.Value >= options.Min())
{
error = "Дедлайн голосования должен быть раньше первого предложенного времени.";
return false;
}
input = new RescheduleVotingInput(options, deadline.Value);
return true;
}
private static bool IsDeadlineLine(string line)
{
var normalized = line.TrimStart('-', '*', ' ', '\t').ToLowerInvariant();
return normalized.StartsWith("дедлайн", StringComparison.Ordinal)
|| normalized.StartsWith("deadline", StringComparison.Ordinal)
|| 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);
@@ -0,0 +1,46 @@
using Telegram.Bot;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Infrastructure.Telegram;
public sealed class TelegramMiniAppMenuButtonService(
ITelegramBotClient bot,
IConfiguration configuration,
ILogger<TelegramMiniAppMenuButtonService> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
if (string.IsNullOrWhiteSpace(miniAppUrl))
{
logger.LogInformation("Telegram Mini App URL is not configured; menu button setup skipped.");
return;
}
if (!Uri.TryCreate(miniAppUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttps && !uri.IsLoopback))
{
logger.LogWarning("Telegram Mini App URL {MiniAppUrl} is not a valid HTTPS URL.", miniAppUrl);
return;
}
try
{
await bot.SetChatMenuButton(
menuButton: new MenuButtonWebApp
{
Text = "Dashboard",
WebApp = new WebAppInfo(miniAppUrl)
},
cancellationToken: cancellationToken);
logger.LogInformation("Telegram Mini App menu button configured for {MiniAppUrl}.", miniAppUrl);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to configure Telegram Mini App menu button.");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -9,6 +9,7 @@ using GmRelay.Bot.Features.Sessions.RescheduleSession;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums; using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Infrastructure.Telegram;
@@ -30,6 +31,7 @@ public sealed class UpdateRouter(
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler, HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
HandleRescheduleVoteHandler rescheduleVoteHandler, HandleRescheduleVoteHandler rescheduleVoteHandler,
ITelegramBotClient bot, ITelegramBotClient bot,
IConfiguration configuration,
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
{ {
public async Task RouteAsync(Update update, CancellationToken ct) public async Task RouteAsync(Update update, CancellationToken ct)
@@ -139,15 +141,10 @@ public sealed class UpdateRouter(
return; return;
} }
if (action == "reschedule_vote" && parts.Length >= 3 && Guid.TryParse(parts[2], out var proposalId)) if (action == "reschedule_vote" && parts.Length >= 2 && Guid.TryParse(parts[1], out var optionId))
{ {
var vote = parts[1]; // "yes" or "no"
if (vote is not ("yes" or "no"))
return;
var command = new HandleRescheduleVoteCommand( var command = new HandleRescheduleVoteCommand(
ProposalId: proposalId, OptionId: optionId,
Vote: vote,
TelegramUserId: query.From.Id, TelegramUserId: query.From.Id,
CallbackQueryId: query.Id, CallbackQueryId: query.Id,
ChatId: message.Chat.Id, ChatId: message.Chat.Id,
@@ -193,10 +190,7 @@ public sealed class UpdateRouter(
switch (command) switch (command)
{ {
case "/start": case "/start":
await bot.SendMessage( await SendStartMessageAsync(message, ct);
chatId: message.Chat.Id,
text: "GM-Relay Bot ready. Use /help for commands.",
cancellationToken: ct);
break; break;
case "/newsession": case "/newsession":
@@ -223,8 +217,13 @@ public sealed class UpdateRouter(
Мест: 4 Мест: 4
Ссылка: https://link Ссылка: https://link
Для регулярного расписания можно указать одну дату:
Игр: 4
Интервал: 7
/listsessions список предстоящих сессий /listsessions список предстоящих сессий
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти». Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
Owner и co-GM могут переносить сессии кнопкой «Перенести»: бот попросит 2-3 варианта времени и дедлайн голосования.
/help эта справка /help эта справка
""", """,
cancellationToken: ct); cancellationToken: ct);
@@ -236,4 +235,24 @@ public sealed class UpdateRouter(
break; break;
} }
} }
private async Task SendStartMessageAsync(Message message, CancellationToken ct)
{
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
if (string.IsNullOrWhiteSpace(miniAppUrl))
{
await bot.SendMessage(
chatId: message.Chat.Id,
text: "GM-Relay Bot ready. Use /help for commands.",
cancellationToken: ct);
return;
}
await bot.SendMessage(
chatId: message.Chat.Id,
text: "GM-Relay Bot ready. Откройте dashboard внутри Telegram или используйте /help для команд.",
replyMarkup: new InlineKeyboardMarkup(
InlineKeyboardButton.WithWebApp("Открыть dashboard", new WebAppInfo(miniAppUrl))),
cancellationToken: ct);
}
} }
@@ -0,0 +1,26 @@
-- Add explicit owner/co-GM management roles for each Telegram group.
INSERT INTO players (telegram_id, display_name)
SELECT DISTINCT gg.gm_telegram_id,
'GM ' || gg.gm_telegram_id::text
FROM game_groups gg
ON CONFLICT (telegram_id) DO NOTHING;
CREATE TABLE group_managers (
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,
role VARCHAR(50) NOT NULL CHECK (role IN ('Owner', 'CoGm')),
added_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (group_id, player_id)
);
INSERT INTO group_managers (group_id, player_id, role)
SELECT gg.id, p.id, 'Owner'
FROM game_groups gg
JOIN players p ON p.telegram_id = gg.gm_telegram_id
ON CONFLICT (group_id, player_id) DO NOTHING;
CREATE INDEX ix_group_managers_group_role ON group_managers (group_id, role);
CREATE INDEX ix_group_managers_player ON group_managers (player_id);
@@ -0,0 +1,35 @@
-- Multi-option reschedule voting with a deadline.
ALTER TABLE reschedule_proposals
ADD COLUMN voting_deadline_at TIMESTAMPTZ,
ADD COLUMN selected_option_id UUID;
CREATE TABLE reschedule_options (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
proposal_id UUID NOT NULL REFERENCES reschedule_proposals(id) ON DELETE CASCADE,
proposed_at TIMESTAMPTZ NOT NULL,
display_order INTEGER NOT NULL CHECK (display_order BETWEEN 1 AND 3),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (proposal_id, id),
UNIQUE (proposal_id, display_order),
UNIQUE (proposal_id, proposed_at)
);
CREATE TABLE reschedule_option_votes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
proposal_id UUID NOT NULL REFERENCES reschedule_proposals(id) ON DELETE CASCADE,
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
option_id UUID NOT NULL,
voted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (proposal_id, player_id),
FOREIGN KEY (proposal_id, option_id)
REFERENCES reschedule_options(proposal_id, id)
ON DELETE CASCADE
);
CREATE INDEX ix_reschedule_voting_deadline
ON reschedule_proposals (voting_deadline_at)
WHERE status = 'Voting';
CREATE INDEX ix_reschedule_option_votes_option
ON reschedule_option_votes (option_id);
@@ -0,0 +1,17 @@
CREATE TABLE campaign_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
title VARCHAR(500) NOT NULL,
join_link TEXT NOT NULL,
session_count INTEGER NOT NULL CHECK (session_count BETWEEN 1 AND 52),
interval_days INTEGER NOT NULL CHECK (interval_days BETWEEN 1 AND 365),
max_players INTEGER CHECK (max_players IS NULL OR max_players > 0),
notification_mode VARCHAR(32) NOT NULL DEFAULT 'GroupAndDirect'
CHECK (notification_mode IN ('GroupAndDirect', 'GroupOnly')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (group_id, name)
);
CREATE INDEX ix_campaign_templates_group ON campaign_templates (group_id, created_at DESC);
+2
View File
@@ -71,10 +71,12 @@ builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
// ── Telegram infrastructure ────────────────────────────────────────── // ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>(); builder.Services.AddSingleton<UpdateRouter>();
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>()); builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
builder.Services.AddHostedService<TelegramBotService>(); builder.Services.AddHostedService<TelegramBotService>();
// ── Session scheduler ──────────────────────────────────────────────── // ── Session scheduler ────────────────────────────────────────────────
builder.Services.AddHostedService<SessionSchedulerService>(); builder.Services.AddHostedService<SessionSchedulerService>();
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
var host = builder.Build(); var host = builder.Build();
+2 -1
View File
@@ -7,6 +7,7 @@
} }
}, },
"Telegram": { "Telegram": {
"BotToken": "" "BotToken": "",
"MiniAppUrl": ""
} }
} }
@@ -0,0 +1,34 @@
namespace GmRelay.Shared.Domain;
public enum GroupManagerRole
{
Owner,
CoGm
}
public static class GroupManagerRoleExtensions
{
public const string OwnerValue = "Owner";
public const string CoGmValue = "CoGm";
public static string ToDatabaseValue(this GroupManagerRole role) => role switch
{
GroupManagerRole.Owner => OwnerValue,
GroupManagerRole.CoGm => CoGmValue,
_ => throw new ArgumentOutOfRangeException(nameof(role), role, "Unknown group manager role.")
};
public static GroupManagerRole FromDatabaseValue(string value) => value switch
{
OwnerValue => GroupManagerRole.Owner,
CoGmValue => GroupManagerRole.CoGm,
_ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown group manager role.")
};
public static string ToDisplayName(this GroupManagerRole role) => role switch
{
GroupManagerRole.Owner => "Owner",
GroupManagerRole.CoGm => "Co-GM",
_ => throw new ArgumentOutOfRangeException(nameof(role), role, "Unknown group manager role.")
};
}
+35
View File
@@ -13,6 +13,7 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="@Assets["app.css"]" /> <link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["GmRelay.Web.styles.css"]" /> <link rel="stylesheet" href="@Assets["GmRelay.Web.styles.css"]" />
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<ImportMap /> <ImportMap />
<link rel="icon" type="image/png" href="favicon.png" /> <link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveServer" /> <HeadOutlet @rendermode="InteractiveServer" />
@@ -23,6 +24,14 @@
<ReconnectModal /> <ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script> <script src="@Assets["_framework/blazor.web.js"]"></script>
<script> <script>
(function () {
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData) {
var webApp = window.Telegram.WebApp;
document.body.classList.add('telegram-mini-app');
webApp.ready();
}
})();
window.loadTelegramWidget = function (botUsername, authUrl) { window.loadTelegramWidget = function (botUsername, authUrl) {
var container = document.getElementById('telegram-login-container'); var container = document.getElementById('telegram-login-container');
if (!container) return; if (!container) return;
@@ -36,6 +45,32 @@
script.setAttribute('data-request-access', 'write'); script.setAttribute('data-request-access', 'write');
container.appendChild(script); container.appendChild(script);
}; };
window.authenticateTelegramMiniApp = async function (authUrl, redirectUrl) {
if (!window.Telegram || !window.Telegram.WebApp || !window.Telegram.WebApp.initData) {
return false;
}
var webApp = window.Telegram.WebApp;
document.body.classList.add('telegram-mini-app');
webApp.ready();
webApp.expand();
var response = await fetch(authUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ initData: webApp.initData })
});
if (!response.ok) {
return false;
}
var payload = await response.json();
window.location.href = payload.redirectUrl || redirectUrl || '/';
return true;
};
</script> </script>
</body> </body>
@@ -60,12 +60,17 @@
/* === Mobile Responsive === */ /* === Mobile Responsive === */
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { .sidebar {
transform: translateX(-100%); transform: none;
width: 280px; width: 100%;
height: auto;
min-height: 0;
position: sticky;
border-right: none;
border-bottom: 1px solid var(--border-color);
} }
.sidebar.open { .page {
transform: translateX(0); display: block;
} }
.main-area { .main-area {
@@ -25,6 +25,15 @@
</svg> </svg>
Панель управления Панель управления
</NavLink> </NavLink>
<NavLink class="nav-item" href="templates" @onclick="CloseMenu">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="16" rx="2"/>
<path d="M7 8h10"/>
<path d="M7 12h6"/>
<path d="M7 16h8"/>
</svg>
Шаблоны
</NavLink>
</div> </div>
<div class="nav-footer"> <div class="nav-footer">
@@ -47,7 +56,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v1.3.0</div> <div class="nav-version">v1.9.0</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@@ -56,13 +56,17 @@
.nav-section { .nav-section {
padding: 0 0.75rem; padding: 0 0.75rem;
flex: 1; flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
} }
/* === Nav Items === */ /* === Nav Items === */
.nav-item { .nav-section ::deep .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.875rem; padding: 0.625rem 0.875rem;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--text-secondary); color: var(--text-secondary);
@@ -70,16 +74,16 @@
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
transition: all var(--transition-normal); transition: all var(--transition-normal);
margin-bottom: 0.125rem; white-space: nowrap;
box-sizing: border-box;
} }
.nav-item:hover { .nav-section ::deep .nav-item:hover {
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
color: var(--text-primary); color: var(--text-primary);
} }
.nav-item.active, .nav-section ::deep .nav-item.active {
.nav-item ::deep a.active {
background: rgba(124, 58, 237, 0.15); background: rgba(124, 58, 237, 0.15);
color: var(--accent-primary); color: var(--accent-primary);
border: 1px solid rgba(124, 58, 237, 0.2); border: 1px solid rgba(124, 58, 237, 0.2);
@@ -0,0 +1,402 @@
@page "/templates"
@using GmRelay.Web.Services
@using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Шаблоны кампаний — GM-Relay</PageTitle>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li class="active">Шаблоны кампаний</li>
</ul>
<div class="page-header animate-fade-in">
<h2>📋 Шаблоны кампаний</h2>
<p>Сохраняйте типовые кампании один раз и применяйте их на странице нужной группы.</p>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
⚠️ @errorMessage
</div>
}
@if (!string.IsNullOrEmpty(successMessage))
{
<div class="gm-alert gm-alert-success" style="margin-bottom: 1rem;">
✅ @successMessage
</div>
}
@if (groups is null)
{
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 80%; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 60%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 70%;"></div>
</div>
}
else if (groups.Count == 0)
{
<div class="glass-card">
<div class="empty-state">
<div class="empty-state-icon">🤖</div>
<div class="empty-state-title">Нет доступных групп</div>
<p class="empty-state-text">Добавьте бота GM-Relay в группу Telegram, чтобы создать первый шаблон кампании.</p>
</div>
</div>
}
else
{
<div class="glass-card campaign-template-panel animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Группа для шаблонов</h3>
<p>@(SelectedGroup?.Name ?? "Выберите группу")</p>
</div>
@if (SelectedGroup is not null)
{
<span class="status-badge @(SelectedGroup.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
@FormatRole(SelectedGroup.ManagerRole)
</span>
}
</div>
<div class="template-group-selector">
<select value="@selectedGroupId" @onchange="OnSelectedGroupChanged" class="gm-form-control">
@foreach (var group in groups)
{
<option value="@group.Id">@group.Name</option>
}
</select>
@if (SelectedGroup is not null)
{
<a href="/group/@SelectedGroup.Id" class="btn-gm btn-gm-outline">Открыть группу →</a>
}
</div>
</div>
<div class="glass-card campaign-template-panel animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Новый шаблон</h3>
<p>Эти параметры будут использоваться при запуске batch из группы.</p>
</div>
<span class="status-badge status-info">Template</span>
</div>
<EditForm Model="@templateModel" OnValidSubmit="CreateCampaignTemplate">
<div class="campaign-template-fields">
<div class="gm-form-group">
<label class="gm-form-label">Название шаблона</label>
<InputText @bind-Value="templateModel.Name" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Название кампании</label>
<InputText @bind-Value="templateModel.Title" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Ссылка</label>
<InputText @bind-Value="templateModel.JoinLink" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Игр</label>
<InputNumber @bind-Value="templateModel.SessionCount" class="gm-form-control" min="1" max="52" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Интервал, дней</label>
<InputNumber @bind-Value="templateModel.IntervalDays" class="gm-form-control" min="1" max="365" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Мест</label>
<InputNumber @bind-Value="templateModel.MaxPlayers" class="gm-form-control" min="1" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Уведомления</label>
<select @bind="templateModel.NotificationMode" class="gm-form-control">
<option value="@SessionNotificationModeExtensions.GroupAndDirectValue">В группе и в личку</option>
<option value="@SessionNotificationModeExtensions.GroupOnlyValue">Только в группе</option>
</select>
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isCreatingTemplate">
@(isCreatingTemplate ? "⏳ Сохраняем..." : "💾 Сохранить шаблон")
</button>
</EditForm>
</div>
<div class="glass-card campaign-template-panel animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Сохранённые шаблоны</h3>
<p>@campaignTemplateModels.Count для выбранной группы</p>
</div>
<span class="status-badge status-info">@campaignTemplateModels.Count</span>
</div>
@if (campaignTemplates is null)
{
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 55%;"></div>
}
else if (campaignTemplateModels.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Шаблонов пока нет</div>
<p class="empty-state-text">Создайте первый шаблон для выбранной группы.</p>
</div>
}
else
{
<div class="campaign-template-list">
@foreach (var template in campaignTemplateModels)
{
<div class="campaign-template-row template-management-row">
<div class="campaign-template-info">
<h3>@template.Name</h3>
<p>@FormatTemplateSummary(template)</p>
</div>
<div class="template-management-actions">
<span class="status-badge status-neutral">@FormatLocalMoscow(template.UpdatedAt.ToMoscow())</span>
<button type="button" class="btn-gm btn-gm-danger" disabled="@(deletingTemplateId == template.Id)" @onclick="() => DeleteCampaignTemplate(template)">
@(deletingTemplateId == template.Id ? "⏳ Удаляем..." : "Удалить")
</button>
</div>
</div>
}
</div>
}
</div>
}
</div>
@code {
private List<WebGameGroup>? groups;
private List<WebCampaignTemplate>? campaignTemplates;
private List<CampaignTemplateManagementModel> campaignTemplateModels = [];
private Guid selectedGroupId;
private Guid? deletingTemplateId;
private bool isCreatingTemplate;
private long telegramId;
private string? errorMessage;
private string? successMessage;
private CampaignTemplateEditModel templateModel = new();
private WebGameGroup? SelectedGroup => groups?.FirstOrDefault(group => group.Id == selectedGroupId);
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
groups = await SessionService.GetGroupsForGmAsync(telegramId);
selectedGroupId = groups.FirstOrDefault()?.Id ?? Guid.Empty;
if (selectedGroupId != Guid.Empty)
{
await LoadTemplates();
}
}
private async Task OnSelectedGroupChanged(ChangeEventArgs args)
{
if (!Guid.TryParse(args.Value?.ToString(), out var groupId))
{
return;
}
selectedGroupId = groupId;
errorMessage = null;
successMessage = null;
await LoadTemplates();
}
private async Task LoadTemplates()
{
campaignTemplates = null;
campaignTemplateModels = [];
var templates = await SessionService.GetCampaignTemplatesForGmAsync(selectedGroupId, telegramId);
if (templates is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
campaignTemplates = templates;
RebuildCampaignTemplateModels();
}
private async Task CreateCampaignTemplate()
{
errorMessage = null;
successMessage = null;
if (selectedGroupId == Guid.Empty)
{
errorMessage = "Выберите группу для шаблона.";
return;
}
if (!ValidateCampaignTemplate(templateModel))
{
errorMessage = "Шаблон должен иметь название, ссылку, 1-52 игр, шаг 1-365 дней и положительный лимит мест, если он указан.";
return;
}
isCreatingTemplate = true;
try
{
await SessionService.CreateCampaignTemplateForGmAsync(
selectedGroupId,
telegramId,
new CreateCampaignTemplateRequest(
templateModel.Name,
templateModel.Title,
templateModel.JoinLink,
templateModel.SessionCount,
templateModel.IntervalDays,
templateModel.MaxPlayers,
SessionNotificationModeExtensions.FromDatabaseValue(templateModel.NotificationMode)));
templateModel = new();
successMessage = "Шаблон кампании сохранён.";
await LoadTemplates();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось сохранить шаблон: " + ex.Message;
}
finally
{
isCreatingTemplate = false;
}
}
private async Task DeleteCampaignTemplate(CampaignTemplateManagementModel template)
{
errorMessage = null;
successMessage = null;
deletingTemplateId = template.Id;
try
{
await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId);
successMessage = "Шаблон кампании удалён.";
await LoadTemplates();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось удалить шаблон: " + ex.Message;
}
finally
{
deletingTemplateId = null;
}
}
private void RebuildCampaignTemplateModels()
{
campaignTemplateModels = campaignTemplates?
.OrderByDescending(template => template.UpdatedAt)
.ThenBy(template => template.Name)
.Select(template => new CampaignTemplateManagementModel
{
Id = template.Id,
Name = template.Name,
Title = template.Title,
JoinLink = template.JoinLink,
SessionCount = template.SessionCount,
IntervalDays = template.IntervalDays,
MaxPlayers = template.MaxPlayers,
NotificationMode = template.NotificationMode,
UpdatedAt = template.UpdatedAt
})
.ToList() ?? [];
}
private static bool ValidateCampaignTemplate(CampaignTemplateEditModel template)
{
template.Name = template.Name.Trim();
template.Title = template.Title.Trim();
template.JoinLink = template.JoinLink.Trim();
if (template.MaxPlayers.HasValue && template.MaxPlayers.Value <= 0)
{
return false;
}
return template.Name.Length > 0 &&
template.Title.Length > 0 &&
template.JoinLink.Length > 0 &&
template.SessionCount is >= 1 and <= 52 &&
template.IntervalDays is >= 1 and <= 365;
}
private static string FormatTemplateSummary(CampaignTemplateManagementModel template)
{
var seats = template.MaxPlayers.HasValue
? $"{template.MaxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)} мест"
: "без лимита";
return $"{template.Title} · {template.SessionCount} игр · каждые {template.IntervalDays} дн. · {seats} · {FormatNotificationMode(template.NotificationMode)}";
}
private static string FormatNotificationMode(string notificationMode) =>
SessionNotificationModeExtensions.FromDatabaseValue(notificationMode) switch
{
SessionNotificationMode.GroupOnly => "только группа",
_ => "группа и личка"
};
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
private static string FormatLocalMoscow(DateTime localMoscow) =>
localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
private sealed class CampaignTemplateEditModel
{
public string Name { get; set; } = "";
public string Title { get; set; } = "";
public string JoinLink { get; set; } = "";
public int SessionCount { get; set; } = 6;
public int IntervalDays { get; set; } = 7;
public int? MaxPlayers { get; set; }
public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue;
}
private sealed class CampaignTemplateManagementModel
{
public Guid Id { get; init; }
public string Name { get; init; } = "";
public string Title { get; init; } = "";
public string JoinLink { get; init; } = "";
public int SessionCount { get; init; }
public int IntervalDays { get; init; }
public int? MaxPlayers { get; init; }
public string NotificationMode { get; init; } = SessionNotificationModeExtensions.GroupAndDirectValue;
public DateTime UpdatedAt { get; init; }
}
}
@@ -20,6 +20,57 @@
<h2>📅 Предстоящие игры</h2> <h2>📅 Предстоящие игры</h2>
</div> </div>
@if (groupManagement is not null)
{
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Управление группой</h3>
<p>@groupManagement.Group.Name · @FormatRole(CurrentUserRole)</p>
</div>
<span class="status-badge status-info">@FormatRole(CurrentUserRole)</span>
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
@foreach (var manager in groupManagement.Managers)
{
<span class="status-badge @(manager.Role == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
@FormatManager(manager)
</span>
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
{
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.TelegramId)" @onclick="() => RemoveCoGm(manager.TelegramId)">
@(removingCoGmId == manager.TelegramId ? "⏳ Удаляем..." : "Убрать")
</button>
}
}
</div>
@if (groupManagement.CurrentUserIsOwner)
{
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
<div class="batch-bulk-fields">
<div class="gm-form-group">
<label class="gm-form-label">Telegram ID co-GM</label>
<InputNumber @bind-Value="coGmModel.TelegramId" class="gm-form-control" min="1" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Имя</label>
<InputText @bind-Value="coGmModel.DisplayName" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Username</label>
<InputText @bind-Value="coGmModel.TelegramUsername" class="gm-form-control" />
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
@(isAddingCoGm ? "⏳ Добавляем..." : " Добавить co-GM")
</button>
</EditForm>
}
</div>
}
@if (!string.IsNullOrEmpty(errorMessage)) @if (!string.IsNullOrEmpty(errorMessage))
{ {
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;"> <div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
@@ -34,6 +85,47 @@
</div> </div>
} }
@if (campaignTemplates is not null)
{
<div class="glass-card campaign-template-panel animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Применить шаблон</h3>
<p>@campaignTemplateModels.Count доступных для этой группы</p>
</div>
<a href="/templates" class="btn-gm btn-gm-outline">⚙️ Управлять шаблонами</a>
</div>
@if (campaignTemplateModels.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Шаблонов пока нет</div>
<p class="empty-state-text">Создайте шаблоны в отдельной вкладке, а здесь запускайте из них новые batch-расписания.</p>
</div>
}
else
{
<div class="campaign-template-list">
@foreach (var template in campaignTemplateModels)
{
<div class="campaign-template-row">
<div class="campaign-template-info">
<h3>@template.Name</h3>
<p>@FormatTemplateSummary(template)</p>
</div>
<div class="campaign-template-actions">
<input type="datetime-local" @bind="template.FirstScheduledAtLocal" class="gm-form-control" />
<button type="button" class="btn-gm btn-gm-success" disabled="@IsTemplateBusy(template)" @onclick="() => CreateBatchFromTemplate(template)">
@(processingTemplateId == template.Id ? "⏳ Создаём..." : "📅 Создать batch")
</button>
</div>
</div>
}
</div>
}
</div>
}
@if (sessions == null) @if (sessions == null)
{ {
<div class="glass-card" style="padding: 2rem;"> <div class="glass-card" style="padding: 2rem;">
@@ -212,12 +304,19 @@
@code { @code {
[Parameter] public Guid GroupId { get; set; } [Parameter] public Guid GroupId { get; set; }
private List<WebSession>? sessions; private List<WebSession>? sessions;
private List<WebCampaignTemplate>? campaignTemplates;
private WebGroupManagement? groupManagement;
private List<BatchBulkEditModel> batchModels = []; private List<BatchBulkEditModel> batchModels = [];
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
private Guid? promotingSessionId; private Guid? promotingSessionId;
private Guid? processingBatchId; private Guid? processingBatchId;
private Guid? processingTemplateId;
private long? removingCoGmId;
private bool isAddingCoGm;
private long telegramId; private long telegramId;
private string? errorMessage; private string? errorMessage;
private string? successMessage; private string? successMessage;
private CoGmEditModel coGmModel = new();
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -233,6 +332,13 @@
private async Task LoadSessions() private async Task LoadSessions()
{ {
groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId);
if (groupManagement is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId); sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
if (sessions is null) if (sessions is null)
{ {
@@ -240,7 +346,81 @@
return; return;
} }
campaignTemplates = await SessionService.GetCampaignTemplatesForGmAsync(GroupId, telegramId);
if (campaignTemplates is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
RebuildBatchModels(); RebuildBatchModels();
RebuildCampaignTemplateModels();
}
private async Task AddCoGm()
{
errorMessage = null;
successMessage = null;
if (!coGmModel.TelegramId.HasValue || coGmModel.TelegramId.Value <= 0)
{
errorMessage = "Telegram ID co-GM должен быть положительным числом.";
return;
}
isAddingCoGm = true;
try
{
await SessionService.AddCoGmForOwnerAsync(
GroupId,
telegramId,
coGmModel.TelegramId.Value,
coGmModel.DisplayName,
coGmModel.TelegramUsername);
coGmModel = new();
successMessage = "Co-GM добавлен.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось добавить co-GM: " + ex.Message;
}
finally
{
isAddingCoGm = false;
}
}
private async Task RemoveCoGm(long coGmTelegramId)
{
errorMessage = null;
successMessage = null;
removingCoGmId = coGmTelegramId;
try
{
await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId);
successMessage = "Co-GM удалён.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось удалить co-GM: " + ex.Message;
}
finally
{
removingCoGmId = null;
}
} }
private async Task PromoteWaitlisted(Guid sessionId) private async Task PromoteWaitlisted(Guid sessionId)
@@ -369,6 +549,39 @@
} }
} }
private async Task CreateBatchFromTemplate(CampaignTemplateUsageModel template)
{
errorMessage = null;
successMessage = null;
processingTemplateId = template.Id;
try
{
var utcTime = new DateTimeOffset(template.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
if (utcTime <= DateTime.UtcNow)
{
errorMessage = "Первая дата batch должна быть в будущем.";
return;
}
var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForGmAsync(template.Id, telegramId, utcTime);
successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось создать batch из шаблона: " + ex.Message;
}
finally
{
processingTemplateId = null;
}
}
private void RebuildBatchModels() private void RebuildBatchModels()
{ {
batchModels = sessions? batchModels = sessions?
@@ -395,6 +608,27 @@
.ToList() ?? []; .ToList() ?? [];
} }
private void RebuildCampaignTemplateModels()
{
var defaultStart = DateTime.UtcNow.AddDays(7).ToMoscow();
campaignTemplateModels = campaignTemplates?
.OrderByDescending(template => template.UpdatedAt)
.ThenBy(template => template.Name)
.Select(template => new CampaignTemplateUsageModel
{
Id = template.Id,
Name = template.Name,
Title = template.Title,
JoinLink = template.JoinLink,
SessionCount = template.SessionCount,
IntervalDays = template.IntervalDays,
MaxPlayers = template.MaxPlayers,
NotificationMode = template.NotificationMode,
FirstScheduledAtLocal = defaultStart
})
.ToList() ?? [];
}
private static bool ValidateBatchDetails(BatchBulkEditModel batch) private static bool ValidateBatchDetails(BatchBulkEditModel batch)
{ {
batch.Title = batch.Title.Trim(); batch.Title = batch.Title.Trim();
@@ -404,6 +638,24 @@
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId; private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
private string CurrentUserRole =>
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
?? GroupManagerRoleExtensions.CoGmValue;
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
private static string FormatManager(WebGroupManager manager)
{
var username = string.IsNullOrWhiteSpace(manager.TelegramUsername)
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
: "@" + manager.TelegramUsername;
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {username}";
}
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions) private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
{ {
if (orderedSessions.Count < 2) if (orderedSessions.Count < 2)
@@ -433,6 +685,22 @@
private static string FormatBatchSummary(BatchBulkEditModel batch) => private static string FormatBatchSummary(BatchBulkEditModel batch) =>
$"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}"; $"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}";
private static string FormatTemplateSummary(CampaignTemplateUsageModel template)
{
var seats = template.MaxPlayers.HasValue
? $"{template.MaxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)} мест"
: "без лимита";
return $"{template.Title} · {template.SessionCount} игр · каждые {template.IntervalDays} дн. · {seats} · {FormatNotificationMode(template.NotificationMode)}";
}
private static string FormatNotificationMode(string notificationMode) =>
SessionNotificationModeExtensions.FromDatabaseValue(notificationMode) switch
{
SessionNotificationMode.GroupOnly => "только группа",
_ => "группа и личка"
};
private static string FormatLocalMoscow(DateTime localMoscow) => private static string FormatLocalMoscow(DateTime localMoscow) =>
localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU")); localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
@@ -466,4 +734,24 @@
public int SessionCount { get; init; } public int SessionCount { get; init; }
public string CloneInterval { get; set; } = "week"; public string CloneInterval { get; set; } = "week";
} }
private sealed class CampaignTemplateUsageModel
{
public Guid Id { get; init; }
public string Name { get; init; } = "";
public string Title { get; init; } = "";
public string JoinLink { get; init; } = "";
public int SessionCount { get; init; }
public int IntervalDays { get; init; }
public int? MaxPlayers { get; init; }
public string NotificationMode { get; init; } = SessionNotificationModeExtensions.GroupAndDirectValue;
public DateTime FirstScheduledAtLocal { get; set; } = DateTime.Now;
}
private sealed class CoGmEditModel
{
public long? TelegramId { get; set; }
public string DisplayName { get; set; } = "";
public string? TelegramUsername { get; set; }
}
} }
@@ -1,6 +1,7 @@
@page "/" @page "/"
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using GmRelay.Shared.Domain
@using GmRelay.Web.Services @using GmRelay.Web.Services
@attribute [Authorize] @attribute [Authorize]
@inject AuthorizedSessionService SessionService @inject AuthorizedSessionService SessionService
@@ -43,6 +44,9 @@
<div class="group-card-icon">🎮</div> <div class="group-card-icon">🎮</div>
<h3 class="group-card-title">@group.Name</h3> <h3 class="group-card-title">@group.Name</h3>
<p class="group-card-id">ID: @group.TelegramChatId</p> <p class="group-card-id">ID: @group.TelegramChatId</p>
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;">
@FormatRole(group.ManagerRole)
</span>
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;"> <a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
Посмотреть игры → Посмотреть игры →
</a> </a>
@@ -97,4 +101,7 @@
groups = await SessionService.GetGroupsForGmAsync(telegramId); groups = await SessionService.GetGroupsForGmAsync(telegramId);
} }
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
} }
@@ -0,0 +1,70 @@
@page "/miniapp"
@using Microsoft.AspNetCore.Components.Authorization
@inject IJSRuntime JS
@inject NavigationManager Navigation
<PageTitle>Mini App Dashboard — GM-Relay</PageTitle>
<div class="mini-app-page">
<div class="mini-app-auth-card">
<div class="mini-app-logo">🎲</div>
<h1>GM-Relay</h1>
<p>@statusMessage</p>
@if (showFallback)
{
<a href="/login" class="btn-gm btn-gm-primary">Войти через Telegram</a>
}
</div>
</div>
@code {
private string statusMessage = "Открываем dashboard внутри Telegram...";
private bool showFallback;
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
protected override async Task OnInitializedAsync()
{
if (AuthStateTask is null)
{
return;
}
var user = (await AuthStateTask).User;
if (user.Identity?.IsAuthenticated == true)
{
Navigation.NavigateTo("/");
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
try
{
var authenticated = await JS.InvokeAsync<bool>(
"authenticateTelegramMiniApp",
"/auth/telegram-webapp",
"/");
if (!authenticated)
{
statusMessage = "Mini App доступен из Telegram. Для браузера используйте обычный вход.";
showFallback = true;
StateHasChanged();
}
}
catch (JSException)
{
statusMessage = "Не удалось получить данные Telegram Mini App. Попробуйте открыть dashboard из бота.";
showFallback = true;
StateHasChanged();
}
}
}
+38 -10
View File
@@ -87,23 +87,36 @@ app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService aut
{ {
if (authService.Verify(context.Request.Query, out var telegramId, out var name)) if (authService.Verify(context.Request.Query, out var telegramId, out var name))
{ {
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, telegramId.ToString()),
new Claim(ClaimTypes.Name, name),
new Claim("TelegramId", telegramId.ToString())
};
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var authProperties = new AuthenticationProperties { IsPersistent = true }; var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(
await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity), authProperties); CookieAuthenticationDefaults.AuthenticationScheme,
CreateTelegramPrincipal(telegramId, name),
authProperties);
return Results.Redirect("/"); return Results.Redirect("/");
} }
return Results.Redirect("/login?error=auth_failed"); return Results.Redirect("/login?error=auth_failed");
}); });
app.MapPost("/auth/telegram-webapp", async (
HttpContext context,
TelegramAuthService authService,
TelegramWebAppAuthRequest request) =>
{
if (!authService.VerifyWebAppInitData(request.InitData, out var telegramId, out var name))
{
return Results.Unauthorized();
}
var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
CreateTelegramPrincipal(telegramId, name),
authProperties);
return Results.Ok(new { redirectUrl = "/" });
}).DisableAntiforgery();
app.MapPost("/auth/logout", async (HttpContext context) => app.MapPost("/auth/logout", async (HttpContext context) =>
{ {
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
@@ -111,3 +124,18 @@ app.MapPost("/auth/logout", async (HttpContext context) =>
}); });
app.Run(); app.Run();
static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new(ClaimTypes.Name, name),
new("TelegramId", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture))
};
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
return new ClaimsPrincipal(claimsIdentity);
}
public sealed record TelegramWebAppAuthRequest(string InitData);
@@ -7,6 +7,24 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) => public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
sessionStore.GetGroupsForGmAsync(gmId); sessionStore.GetGroupsForGmAsync(gmId);
public async Task<WebGroupManagement?> GetGroupManagementForGmAsync(Guid groupId, long gmId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
return null;
}
var group = await sessionStore.GetGroupAsync(groupId);
if (group is null)
{
return null;
}
var managers = await sessionStore.GetGroupManagersAsync(groupId);
var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, gmId);
return new WebGroupManagement(group, managers, isOwner);
}
public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId) public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId)
{ {
if (!await GroupBelongsToGmAsync(groupId, gmId)) if (!await GroupBelongsToGmAsync(groupId, gmId))
@@ -110,9 +128,143 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval); return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval);
} }
public async Task<List<WebCampaignTemplate>?> GetCampaignTemplatesForGmAsync(Guid groupId, long gmId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
return null;
}
return await sessionStore.GetCampaignTemplatesAsync(groupId);
}
public async Task<WebCampaignTemplate> CreateCampaignTemplateForGmAsync(
Guid groupId,
long gmId,
CreateCampaignTemplateRequest request)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
throw new SessionAccessDeniedException(groupId, gmId);
}
var normalizedRequest = NormalizeCampaignTemplateRequest(request);
return await sessionStore.CreateCampaignTemplateAsync(groupId, normalizedRequest);
}
public async Task DeleteCampaignTemplateForGmAsync(Guid templateId, long gmId)
{
var template = await sessionStore.GetCampaignTemplateAsync(templateId);
if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId))
{
throw new SessionAccessDeniedException(templateId, gmId);
}
await sessionStore.DeleteCampaignTemplateAsync(templateId, template.GroupId);
}
public async Task<WebSessionBatch> CreateBatchFromCampaignTemplateForGmAsync(
Guid templateId,
long gmId,
DateTime firstScheduledAt)
{
if (firstScheduledAt <= DateTime.UtcNow)
{
throw new ArgumentOutOfRangeException(nameof(firstScheduledAt), firstScheduledAt, "First scheduled time must be in the future.");
}
var template = await sessionStore.GetCampaignTemplateAsync(templateId);
if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId))
{
throw new SessionAccessDeniedException(templateId, gmId);
}
return await sessionStore.CreateBatchFromTemplateAsync(templateId, template.GroupId, firstScheduledAt);
}
public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
{
if (coGmTelegramId <= 0)
{
throw new ArgumentOutOfRangeException(nameof(coGmTelegramId), coGmTelegramId, "Telegram id must be greater than zero.");
}
if (ownerTelegramId == coGmTelegramId)
{
throw new InvalidOperationException("Owner is already a group manager.");
}
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
{
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
}
var normalizedName = string.IsNullOrWhiteSpace(displayName)
? $"Telegram {coGmTelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
: displayName.Trim();
var normalizedUsername = string.IsNullOrWhiteSpace(telegramUsername)
? null
: telegramUsername.Trim().TrimStart('@');
await sessionStore.AddGroupCoGmAsync(groupId, ownerTelegramId, coGmTelegramId, normalizedName, normalizedUsername);
}
public async Task RemoveCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId)
{
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
{
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
}
await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId);
}
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId) private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
{ {
var group = await sessionStore.GetGroupAsync(groupId); return await sessionStore.IsGroupManagerAsync(groupId, gmId);
return group?.GmTelegramId == gmId; }
private static CreateCampaignTemplateRequest NormalizeCampaignTemplateRequest(CreateCampaignTemplateRequest request)
{
var name = request.Name.Trim();
var title = request.Title.Trim();
var joinLink = request.JoinLink.Trim();
if (name.Length == 0)
{
throw new ArgumentException("Template name must not be empty.", nameof(request));
}
if (title.Length == 0)
{
throw new ArgumentException("Session title must not be empty.", nameof(request));
}
if (joinLink.Length == 0)
{
throw new ArgumentException("Join link must not be empty.", nameof(request));
}
if (request.SessionCount is < 1 or > 52)
{
throw new ArgumentOutOfRangeException(nameof(request), request.SessionCount, "Session count must be between 1 and 52.");
}
if (request.IntervalDays is < 1 or > 365)
{
throw new ArgumentOutOfRangeException(nameof(request), request.IntervalDays, "Interval must be between 1 and 365 days.");
}
if (request.MaxPlayers is <= 0)
{
throw new ArgumentOutOfRangeException(nameof(request), request.MaxPlayers, "Seat limit must be greater than zero.");
}
return request with
{
Name = name,
Title = title,
JoinLink = joinLink
};
} }
} }
@@ -18,8 +18,33 @@ public sealed record WebSessionBatch(
int SessionCount, int SessionCount,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue); string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
public sealed record WebCampaignTemplate(
Guid Id,
Guid GroupId,
string Name,
string Title,
string JoinLink,
int SessionCount,
int IntervalDays,
int? MaxPlayers,
string NotificationMode,
DateTime CreatedAt,
DateTime UpdatedAt);
public sealed record CreateCampaignTemplateRequest(
string Name,
string Title,
string JoinLink,
int SessionCount,
int IntervalDays,
int? MaxPlayers,
SessionNotificationMode NotificationMode);
public static class BatchSchedulePlanner public static class BatchSchedulePlanner
{ {
private const int MaxTemplateSessionCount = 52;
private const int MaxTemplateIntervalDays = 365;
public static IReadOnlyList<DateTime> BuildFixedIntervalSchedule( public static IReadOnlyList<DateTime> BuildFixedIntervalSchedule(
IEnumerable<DateTime> currentSchedule, IEnumerable<DateTime> currentSchedule,
DateTime firstScheduledAt, DateTime firstScheduledAt,
@@ -36,6 +61,26 @@ public static class BatchSchedulePlanner
.ToList(); .ToList();
} }
public static IReadOnlyList<DateTime> BuildRecurringSchedule(
DateTime firstScheduledAt,
int sessionCount,
int intervalDays)
{
if (sessionCount is < 1 or > MaxTemplateSessionCount)
{
throw new ArgumentOutOfRangeException(nameof(sessionCount), sessionCount, "Session count must be between 1 and 52.");
}
if (intervalDays is < 1 or > MaxTemplateIntervalDays)
{
throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be between 1 and 365 days.");
}
return Enumerable.Range(0, sessionCount)
.Select(index => firstScheduledAt.AddDays(intervalDays * index))
.ToList();
}
public static DateTime ShiftForClone(DateTime scheduledAt, BatchCloneInterval interval) => public static DateTime ShiftForClone(DateTime scheduledAt, BatchCloneInterval interval) =>
interval switch interval switch
{ {
+10
View File
@@ -6,6 +6,9 @@ public interface ISessionStore
{ {
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId); Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
Task<WebGameGroup?> GetGroupAsync(Guid groupId); Task<WebGameGroup?> GetGroupAsync(Guid groupId);
Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId);
Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId);
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId); Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
Task<WebSession?> GetSessionAsync(Guid sessionId); Task<WebSession?> GetSessionAsync(Guid sessionId);
Task<WebSessionBatch?> GetBatchAsync(Guid batchId); Task<WebSessionBatch?> GetBatchAsync(Guid batchId);
@@ -15,4 +18,11 @@ public interface ISessionStore
Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode); Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode);
Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays); Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays);
Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval); Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval);
Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId);
Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId);
Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request);
Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId);
Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt);
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername);
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId);
} }
+377 -4
View File
@@ -6,7 +6,24 @@ using Telegram.Bot;
namespace GmRelay.Web.Services; namespace GmRelay.Web.Services;
public sealed record WebGameGroup(Guid Id, long TelegramChatId, string Name, long GmTelegramId); public sealed record WebGameGroup(
Guid Id,
long TelegramChatId,
string Name,
long GmTelegramId,
string ManagerRole = GroupManagerRoleExtensions.OwnerValue);
public sealed record WebGroupManager(
long TelegramId,
string DisplayName,
string? TelegramUsername,
string Role,
DateTime AddedAt);
public sealed record WebGroupManagement(
WebGameGroup Group,
IReadOnlyList<WebGroupManager> Managers,
bool CurrentUserIsOwner);
public sealed record WebSession( public sealed record WebSession(
Guid Id, Guid Id,
Guid GroupId, Guid GroupId,
@@ -46,6 +63,7 @@ internal sealed record WebBatchSessionRow(
long TelegramChatId, long TelegramChatId,
int? ThreadId, int? ThreadId,
string NotificationMode); string NotificationMode);
internal sealed record WebTemplateGroupDto(long TelegramChatId);
public sealed class SessionService( public sealed class SessionService(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
@@ -56,7 +74,18 @@ public sealed class SessionService(
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGameGroup>( return (await conn.QueryAsync<WebGameGroup>(
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE gm_telegram_id = @GmId", """
SELECT g.id,
g.telegram_chat_id AS TelegramChatId,
g.name,
g.gm_telegram_id AS GmTelegramId,
gm.role AS ManagerRole
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE p.telegram_id = @GmId
ORDER BY g.name
""",
new { GmId = gmId })).ToList(); new { GmId = gmId })).ToList();
} }
@@ -64,8 +93,145 @@ public sealed class SessionService(
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>( return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE id = @GroupId", """
new { GroupId = groupId }); SELECT g.id,
g.telegram_chat_id AS TelegramChatId,
g.name,
g.gm_telegram_id AS GmTelegramId,
@OwnerRole AS ManagerRole
FROM game_groups g
WHERE g.id = @GroupId
""",
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
}
public async Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
AND p.telegram_id = @TelegramId
)
""",
new { GroupId = groupId, TelegramId = telegramId });
}
public async Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
AND p.telegram_id = @TelegramId
AND gm.role = @OwnerRole
)
""",
new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
}
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGroupManager>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
gm.role AS Role,
gm.created_at AS AddedAt
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END,
gm.created_at,
p.display_name
""",
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList();
}
public async Task AddGroupCoGmAsync(
Guid groupId,
long ownerTelegramId,
long coGmTelegramId,
string displayName,
string? telegramUsername)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
await conn.ExecuteAsync(
"""
INSERT INTO players (telegram_id, display_name, telegram_username)
VALUES (@TelegramId, @DisplayName, @TelegramUsername)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username
""",
new
{
TelegramId = coGmTelegramId,
DisplayName = displayName,
TelegramUsername = telegramUsername
},
transaction);
await conn.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role, added_by_player_id)
SELECT @GroupId,
co_gm.id,
@CoGmRole,
owner_player.id
FROM players co_gm
LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId
WHERE co_gm.telegram_id = @CoGmTelegramId
ON CONFLICT (group_id, player_id) DO UPDATE
SET role = CASE
WHEN group_managers.role = @OwnerRole THEN group_managers.role
ELSE EXCLUDED.role
END,
added_by_player_id = EXCLUDED.added_by_player_id
""",
new
{
GroupId = groupId,
OwnerTelegramId = ownerTelegramId,
CoGmTelegramId = coGmTelegramId,
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
},
transaction);
await transaction.CommitAsync();
}
public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"""
DELETE FROM group_managers gm
USING players p
WHERE gm.player_id = p.id
AND gm.group_id = @GroupId
AND p.telegram_id = @CoGmTelegramId
AND gm.role = @CoGmRole
""",
new
{
GroupId = groupId,
CoGmTelegramId = coGmTelegramId,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
});
} }
public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
@@ -588,6 +754,213 @@ public sealed class SessionService(
sourceSessions[0].NotificationMode); sourceSessions[0].NotificationMode);
} }
public async Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebCampaignTemplate>(
"""
SELECT id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
FROM campaign_templates
WHERE group_id = @GroupId
ORDER BY created_at DESC, name
""",
new { GroupId = groupId })).ToList();
}
public async Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebCampaignTemplate>(
"""
SELECT id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
FROM campaign_templates
WHERE id = @TemplateId
""",
new { TemplateId = templateId });
}
public async Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleAsync<WebCampaignTemplate>(
"""
INSERT INTO campaign_templates (
group_id,
name,
title,
join_link,
session_count,
interval_days,
max_players,
notification_mode
)
VALUES (
@GroupId,
@Name,
@Title,
@JoinLink,
@SessionCount,
@IntervalDays,
@MaxPlayers,
@NotificationMode
)
ON CONFLICT (group_id, name) DO UPDATE
SET title = EXCLUDED.title,
join_link = EXCLUDED.join_link,
session_count = EXCLUDED.session_count,
interval_days = EXCLUDED.interval_days,
max_players = EXCLUDED.max_players,
notification_mode = EXCLUDED.notification_mode,
updated_at = now()
RETURNING id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
""",
new
{
GroupId = groupId,
request.Name,
request.Title,
request.JoinLink,
request.SessionCount,
request.IntervalDays,
request.MaxPlayers,
NotificationMode = request.NotificationMode.ToDatabaseValue()
});
}
public async Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"DELETE FROM campaign_templates WHERE id = @TemplateId AND group_id = @GroupId",
new { TemplateId = templateId, GroupId = groupId });
}
public async Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var template = await conn.QuerySingleOrDefaultAsync<WebCampaignTemplate>(
"""
SELECT id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
FROM campaign_templates
WHERE id = @TemplateId
AND group_id = @GroupId
FOR UPDATE
""",
new { TemplateId = templateId, GroupId = groupId },
transaction);
if (template is null)
{
throw new SessionAccessDeniedException(templateId, 0);
}
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
"SELECT telegram_chat_id AS TelegramChatId FROM game_groups WHERE id = @GroupId",
new { GroupId = groupId },
transaction);
if (group is null)
{
throw new SessionAccessDeniedException(groupId, 0);
}
var schedule = BatchSchedulePlanner.BuildRecurringSchedule(
firstScheduledAt,
template.SessionCount,
template.IntervalDays);
var batchId = Guid.NewGuid();
var renderedSessions = new List<SessionBatchDto>();
foreach (var scheduledAt in schedule)
{
var sessionId = await conn.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, notification_mode)
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @MaxPlayers, @NotificationMode)
RETURNING id
""",
new
{
BatchId = batchId,
GroupId = groupId,
template.Title,
template.JoinLink,
ScheduledAt = scheduledAt,
Status = SessionStatus.Planned,
template.MaxPlayers,
template.NotificationMode
},
transaction);
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers));
}
await transaction.CommitAsync();
var renderResult = SessionBatchRenderer.Render(template.Title, renderedSessions, Array.Empty<ParticipantBatchDto>());
var batchMessage = await bot.SendMessage(
chatId: group.TelegramChatId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup);
await conn.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MessageId WHERE batch_id = @BatchId",
new { MessageId = batchMessage.MessageId, BatchId = batchId });
return new WebSessionBatch(
batchId,
groupId,
template.Title,
template.JoinLink,
renderedSessions.Min(session => session.ScheduledAt),
renderedSessions.Max(session => session.ScheduledAt),
renderedSessions.Count,
template.NotificationMode);
}
private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync( private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync(
Npgsql.NpgsqlConnection conn, Npgsql.NpgsqlConnection conn,
Guid sessionId) Guid sessionId)
@@ -1,5 +1,7 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.WebUtilities;
namespace GmRelay.Web.Services; namespace GmRelay.Web.Services;
@@ -55,4 +57,98 @@ public sealed class TelegramAuthService(IConfiguration configuration)
return false; return false;
} }
public bool VerifyWebAppInitData(string initData, out long telegramId, out string name)
{
telegramId = 0;
name = string.Empty;
if (string.IsNullOrWhiteSpace(initData))
return false;
var token = configuration["Telegram__BotToken"] ?? configuration["Telegram:BotToken"];
if (string.IsNullOrEmpty(token))
return false;
var values = QueryHelpers.ParseQuery(initData);
if (!values.TryGetValue("hash", out var hash) || string.IsNullOrWhiteSpace(hash))
return false;
var dataCheckString = string.Join(
"\n",
values
.Where(pair => pair.Key != "hash")
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = HMACSHA256.HashData(Encoding.UTF8.GetBytes("WebAppData"), Encoding.UTF8.GetBytes(token));
var computedHashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
byte[] hashBytes;
try
{
hashBytes = Convert.FromHexString(hash.ToString());
}
catch (FormatException)
{
return false;
}
if (!CryptographicOperations.FixedTimeEquals(computedHashBytes, hashBytes))
return false;
if (!values.TryGetValue("auth_date", out var authDateStr) ||
!long.TryParse(authDateStr, out var authDate))
{
return false;
}
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (now - authDate > 86400)
return false;
if (!values.TryGetValue("user", out var userJson) || string.IsNullOrWhiteSpace(userJson))
return false;
return TryReadWebAppUser(userJson.ToString(), out telegramId, out name);
}
private static bool TryReadWebAppUser(string userJson, out long telegramId, out string name)
{
telegramId = 0;
name = string.Empty;
try
{
using var document = JsonDocument.Parse(userJson);
var root = document.RootElement;
if (!root.TryGetProperty("id", out var idElement) || !idElement.TryGetInt64(out telegramId))
return false;
var firstName = root.TryGetProperty("first_name", out var firstNameElement)
? firstNameElement.GetString() ?? string.Empty
: string.Empty;
var lastName = root.TryGetProperty("last_name", out var lastNameElement)
? lastNameElement.GetString() ?? string.Empty
: string.Empty;
var username = root.TryGetProperty("username", out var usernameElement)
? usernameElement.GetString()
: null;
name = (firstName, lastName) switch
{
({ Length: > 0 }, { Length: > 0 }) => $"{firstName} {lastName}",
({ Length: > 0 }, _) => firstName,
_ when !string.IsNullOrWhiteSpace(username) => "@" + username,
_ => $"Telegram {telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
};
return true;
}
catch (JsonException)
{
return false;
}
}
} }
+128 -3
View File
@@ -1,5 +1,5 @@
/* ============================================ /* ============================================
GM-Relay Design System v1.5.0 GM-Relay Design System v1.9.0
Dark RPG Dashboard Theme Dark RPG Dashboard Theme
============================================ */ ============================================ */
@@ -618,6 +618,78 @@ select option {
white-space: nowrap; white-space: nowrap;
} }
/* === Campaign templates === */
.campaign-template-panel {
margin-bottom: 1.5rem;
}
.campaign-template-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.campaign-template-list {
margin-top: 1rem;
border-top: 1px solid var(--border-color);
}
.campaign-template-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, auto);
gap: 1rem;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid var(--border-color);
}
.campaign-template-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.campaign-template-info h3 {
font-size: 0.9375rem;
margin-bottom: 0.25rem;
overflow-wrap: anywhere;
}
.campaign-template-info p {
margin: 0;
color: var(--text-muted);
font-size: 0.8125rem;
}
.campaign-template-actions {
display: grid;
grid-template-columns: minmax(190px, 1fr) auto;
gap: 0.75rem;
align-items: center;
}
.template-group-selector {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
}
.template-management-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.template-management-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.empty-state-compact {
padding: 1.5rem 1rem;
}
/* === Animations === */ /* === Animations === */
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); } from { opacity: 0; transform: translateY(8px); }
@@ -770,6 +842,46 @@ select option {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
/* === Telegram Mini App entry === */
.mini-app-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--bg-primary);
}
.mini-app-auth-card {
width: 100%;
max-width: 360px;
padding: 1.5rem;
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
background: var(--glass-bg);
text-align: center;
}
.mini-app-logo {
font-size: 2.25rem;
margin-bottom: 0.75rem;
}
.mini-app-auth-card h1 {
font-size: 1.375rem;
margin-bottom: 0.5rem;
}
.mini-app-auth-card p {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 1.25rem;
}
.telegram-mini-app .page-container {
max-width: 720px;
}
/* === Mobile Sessions Cards (instead of table) === */ /* === Mobile Sessions Cards (instead of table) === */
.session-card-mobile { .session-card-mobile {
display: none; display: none;
@@ -838,11 +950,16 @@ select option {
} }
.batch-bulk-fields, .batch-bulk-fields,
.batch-clone-row { .batch-clone-row,
.campaign-template-fields,
.campaign-template-row,
.campaign-template-actions,
.template-group-selector {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.batch-clone-row .btn-gm { .batch-clone-row .btn-gm,
.campaign-template-actions .btn-gm {
justify-content: center; justify-content: center;
width: 100%; width: 100%;
} }
@@ -851,6 +968,14 @@ select option {
padding: 1rem; padding: 1rem;
} }
.telegram-mini-app .content {
padding: 0.75rem;
}
.telegram-mini-app .page-container {
padding: 0.75rem;
}
h2 { h2 {
font-size: 1.25rem; font-size: 1.25rem;
} }
@@ -33,6 +33,32 @@ public sealed class NewSessionCommandParserTests
Assert.Empty(result.InvalidTimeInputs); Assert.Empty(result.InvalidTimeInputs);
} }
[Fact]
public void Parse_ShouldExpandRecurringSchedule_WhenRepeatCountAndIntervalProvided()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Kingmaker
Время: 30.04.2026 19:30
Игр: 4
Интервал: 14
Ссылка: https://example.test/kingmaker
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal(
[
new DateTimeOffset(2026, 4, 30, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 14, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 28, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 6, 11, 16, 30, 0, TimeSpan.Zero)
],
result.ScheduledTimes);
}
[Fact] [Fact]
public void Parse_ShouldCollectPastAndInvalidTimes() public void Parse_ShouldCollectPastAndInvalidTimes()
{ {
@@ -5,28 +5,115 @@ namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
public sealed class HandleRescheduleTimeInputHandlerTests public sealed class HandleRescheduleTimeInputHandlerTests
{ {
[Fact] [Fact]
public void BuildVotingMessage_ShouldShowApprovedAndPendingParticipants() public void TryParseVotingInput_ShouldAcceptTwoOptionsAndDeadline()
{ {
var approvedId = Guid.NewGuid(); var now = new DateTimeOffset(2026, 4, 24, 8, 0, 0, TimeSpan.Zero);
var pendingId = Guid.NewGuid();
var ok = RescheduleVotingInput.TryParse(
"""
25.04.2026 19:30
26.04.2026 18:00
Дедлайн: 25.04.2026 12:00
""",
now,
out var input,
out var error);
Assert.True(ok, error);
Assert.Equal(2, input.Options.Count);
Assert.Equal(new DateTimeOffset(2026, 4, 25, 16, 30, 0, TimeSpan.Zero), input.Options[0]);
Assert.Equal(new DateTimeOffset(2026, 4, 26, 15, 0, 0, TimeSpan.Zero), input.Options[1]);
Assert.Equal(new DateTimeOffset(2026, 4, 25, 9, 0, 0, TimeSpan.Zero), input.Deadline);
}
[Fact]
public void TryParseVotingInput_ShouldRejectSingleOption()
{
var now = new DateTimeOffset(2026, 4, 24, 8, 0, 0, TimeSpan.Zero);
var ok = RescheduleVotingInput.TryParse(
"""
25.04.2026 19:30
Дедлайн: 25.04.2026 12:00
""",
now,
out _,
out var error);
Assert.False(ok);
Assert.Equal("Укажите от 2 до 3 вариантов времени.", error);
}
[Fact]
public void BuildVotingMessage_ShouldShowOptionsDeadlineVotesAndPendingParticipants()
{
var firstOptionId = Guid.NewGuid();
var secondOptionId = Guid.NewGuid();
var aliceId = Guid.NewGuid();
var bobId = Guid.NewGuid();
var charlieId = Guid.NewGuid();
var currentTime = new DateTime(2026, 4, 25, 16, 30, 0, DateTimeKind.Utc); var currentTime = new DateTime(2026, 4, 25, 16, 30, 0, DateTimeKind.Utc);
var newTime = new DateTimeOffset(2026, 4, 26, 17, 0, 0, TimeSpan.Zero); var deadline = new DateTimeOffset(2026, 4, 25, 9, 0, 0, TimeSpan.Zero);
var options = new List<RescheduleOptionDto>
{
new(firstOptionId, 1, new DateTimeOffset(2026, 4, 26, 16, 0, 0, TimeSpan.Zero)),
new(secondOptionId, 2, new DateTimeOffset(2026, 4, 27, 17, 0, 0, TimeSpan.Zero))
};
var participants = new List<VoteParticipantDto> var participants = new List<VoteParticipantDto>
{ {
new(approvedId, "Alice", "alice"), new(aliceId, "Alice", "alice"),
new(pendingId, "Bob", null) new(bobId, "Bob", null),
new(charlieId, "Charlie", null)
};
var votes = new List<RescheduleOptionVoteDto>
{
new(firstOptionId, aliceId, "Alice", "alice"),
new(secondOptionId, bobId, "Bob", null)
}; };
var text = HandleRescheduleTimeInputHandler.BuildVotingMessage( var text = HandleRescheduleTimeInputHandler.BuildVotingMessage(
"Shadowrun", "Shadowrun",
currentTime, currentTime,
newTime, deadline,
options,
participants, participants,
[approvedId]); votes);
Assert.Contains("Shadowrun", text); Assert.Contains("Shadowrun", text);
Assert.Contains("✅ @alice", text); Assert.Contains("Дедлайн: <b>25 апреля 2026, 12:00</b> (МСК)", text);
Assert.Contains("⏳ Bob", text); Assert.Contains("1. <b>26 апреля 2026, 19:00</b> (МСК) — 1 голос", text);
Assert.Contains("Голоса: 1/2 ✅", text); Assert.Contains("@alice", text);
Assert.Contains("2. <b>27 апреля 2026, 20:00</b> (МСК) — 1 голос", text);
Assert.Contains("Bob", text);
Assert.Contains("Не проголосовали: Charlie", text);
Assert.Contains("Голосов: 2/3", text);
}
[Fact]
public void BuildVotingKeyboard_ShouldCreateOneButtonPerOption()
{
var firstOptionId = Guid.NewGuid();
var secondOptionId = Guid.NewGuid();
var options = new List<RescheduleOptionDto>
{
new(firstOptionId, 1, new DateTimeOffset(2026, 4, 26, 16, 0, 0, TimeSpan.Zero)),
new(secondOptionId, 2, new DateTimeOffset(2026, 4, 27, 17, 0, 0, TimeSpan.Zero))
};
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
var buttons = keyboard.InlineKeyboard.SelectMany(row => row).ToList();
Assert.Collection(
buttons,
button =>
{
Assert.Equal("1. 26.04 19:00", button.Text);
Assert.Equal($"reschedule_vote:{firstOptionId}", button.CallbackData);
},
button =>
{
Assert.Equal("2. 27.04 20:00", button.Text);
Assert.Equal($"reschedule_vote:{secondOptionId}", button.CallbackData);
});
} }
} }
@@ -5,32 +5,50 @@ namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
public sealed class RescheduleVoteRulesTests public sealed class RescheduleVoteRulesTests
{ {
[Fact] [Fact]
public void Evaluate_ShouldReject_WhenParticipantVotesNo() public void SelectWinner_ShouldApproveSingleTopOption()
{ {
var decision = RescheduleVoteRules.Evaluate("no", totalParticipants: 4, approvedParticipants: 3); var winningOptionId = Guid.NewGuid();
var otherOptionId = Guid.NewGuid();
Assert.Equal(RescheduleVoteOutcome.Rejected, decision.Outcome); var decision = RescheduleVoteRules.SelectWinner(
Assert.False(decision.ShouldRescheduleSession); [
Assert.Equal("Вы проголосовали против переноса.", decision.CallbackText); new RescheduleOptionVoteCount(winningOptionId, 3),
} new RescheduleOptionVoteCount(otherOptionId, 1)
]);
[Fact]
public void Evaluate_ShouldApprove_WhenEveryoneVotedYes()
{
var decision = RescheduleVoteRules.Evaluate("yes", totalParticipants: 3, approvedParticipants: 3);
Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome); Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome);
Assert.True(decision.ShouldRescheduleSession); Assert.Equal(winningOptionId, decision.SelectedOptionId);
Assert.True(decision.ShouldResetParticipantRsvps); Assert.Equal("Победил вариант с большинством голосов.", decision.Reason);
} }
[Fact] [Fact]
public void Evaluate_ShouldStayPending_WhileVotesOutstanding() public void SelectWinner_ShouldRejectTie()
{ {
var decision = RescheduleVoteRules.Evaluate("yes", totalParticipants: 5, approvedParticipants: 2); var firstOptionId = Guid.NewGuid();
var secondOptionId = Guid.NewGuid();
Assert.Equal(RescheduleVoteOutcome.Pending, decision.Outcome); var decision = RescheduleVoteRules.SelectWinner(
Assert.False(decision.ShouldRescheduleSession); [
Assert.False(decision.ShouldResetParticipantRsvps); new RescheduleOptionVoteCount(firstOptionId, 2),
new RescheduleOptionVoteCount(secondOptionId, 2)
]);
Assert.Equal(RescheduleVoteOutcome.Rejected, decision.Outcome);
Assert.Null(decision.SelectedOptionId);
Assert.Equal("Голоса разделились поровну, перенос не применяется.", decision.Reason);
}
[Fact]
public void SelectWinner_ShouldRejectWhenNobodyVoted()
{
var decision = RescheduleVoteRules.SelectWinner(
[
new RescheduleOptionVoteCount(Guid.NewGuid(), 0),
new RescheduleOptionVoteCount(Guid.NewGuid(), 0)
]);
Assert.Equal(RescheduleVoteOutcome.Rejected, decision.Outcome);
Assert.Null(decision.SelectedOptionId);
Assert.Equal("Никто не проголосовал до дедлайна, перенос не применяется.", decision.Reason);
} }
} }
@@ -0,0 +1,50 @@
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
public sealed class TelegramMiniAppEntryPointTests
{
[Fact]
public async Task UpdateRouter_ShouldExposeMiniAppButtonInStartCommand()
{
var updateRouter = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs"));
Assert.Contains("Telegram:MiniAppUrl", updateRouter, StringComparison.Ordinal);
Assert.Contains("InlineKeyboardButton.WithWebApp", updateRouter, StringComparison.Ordinal);
Assert.Contains("Открыть dashboard", updateRouter, StringComparison.Ordinal);
}
[Fact]
public async Task BotStartup_ShouldRegisterMiniAppMenuButtonService()
{
var program = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Bot/Program.cs"));
Assert.Contains("TelegramMiniAppMenuButtonService", program, StringComparison.Ordinal);
Assert.Contains("AddHostedService<TelegramMiniAppMenuButtonService>", program, StringComparison.Ordinal);
}
[Fact]
public async Task MiniAppMenuButtonService_ShouldSetTelegramWebAppMenuButtonWhenConfigured()
{
var service = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Bot/Infrastructure/Telegram/TelegramMiniAppMenuButtonService.cs"));
Assert.Contains("SetChatMenuButton", service, StringComparison.Ordinal);
Assert.Contains("MenuButtonWebApp", service, StringComparison.Ordinal);
Assert.Contains("Telegram:MiniAppUrl", service, StringComparison.Ordinal);
}
private static string FindRepositoryFile(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -28,6 +28,34 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal("Session A", sessions[0].Title); Assert.Equal("Session A", sessions[0].Title);
} }
[Fact]
public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenUserIsCoGm()
{
var ownerId = 1001L;
var coGmId = 2002L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", ownerId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
],
managers:
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, coGmId);
Assert.NotNull(sessions);
Assert.Single(sessions);
Assert.Equal("Session A", sessions[0].Title);
}
[Fact] [Fact]
public async Task GetUpcomingSessionsForGmAsync_ReturnsNull_WhenGroupBelongsToAnotherGm() public async Task GetUpcomingSessionsForGmAsync_ReturnsNull_WhenGroupBelongsToAnotherGm()
{ {
@@ -211,6 +239,105 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal("https://example.test/b", store.LastUpdatedBatchJoinLink); Assert.Equal("https://example.test/b", store.LastUpdatedBatchJoinLink);
} }
[Fact]
public async Task UpdateBatchDetailsForGmAsync_UpdatesBatch_WhenUserIsCoGm()
{
var ownerId = 1001L;
var coGmId = 2002L;
var groupId = Guid.NewGuid();
var batchId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", ownerId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
],
managers:
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
await service.UpdateBatchDetailsForGmAsync(batchId, coGmId, "Updated", "https://example.test/b");
Assert.True(store.UpdateBatchDetailsCalled);
Assert.Equal(batchId, store.LastUpdatedBatchId);
Assert.Equal(groupId, store.LastUpdatedBatchGroupId);
}
[Fact]
public async Task AddCoGmForOwnerAsync_AddsCoGm_WhenUserIsOwner()
{
var ownerId = 1001L;
var coGmId = 2002L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", ownerId)
]);
var service = new AuthorizedSessionService(store);
await service.AddCoGmForOwnerAsync(groupId, ownerId, coGmId, "Assistant GM", "assistant");
Assert.True(store.AddCoGmCalled);
Assert.Equal(groupId, store.LastAddedCoGmGroupId);
Assert.Equal(coGmId, store.LastAddedCoGmTelegramId);
Assert.Equal("Assistant GM", store.LastAddedCoGmDisplayName);
Assert.Equal("assistant", store.LastAddedCoGmUsername);
}
[Fact]
public async Task AddCoGmForOwnerAsync_Throws_WhenUserIsCoGm()
{
var ownerId = 1001L;
var coGmId = 2002L;
var newCoGmId = 3003L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", ownerId)
],
managers:
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.AddCoGmForOwnerAsync(groupId, coGmId, newCoGmId, "Second Assistant", null);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.AddCoGmCalled);
}
[Fact]
public async Task RemoveCoGmForOwnerAsync_RemovesCoGm_WhenUserIsOwner()
{
var ownerId = 1001L;
var coGmId = 2002L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", ownerId)
],
managers:
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
await service.RemoveCoGmForOwnerAsync(groupId, ownerId, coGmId);
Assert.True(store.RemoveCoGmCalled);
Assert.Equal(groupId, store.LastRemovedCoGmGroupId);
Assert.Equal(coGmId, store.LastRemovedCoGmTelegramId);
}
[Fact] [Fact]
public async Task UpdateBatchNotificationModeForGmAsync_Throws_WhenBatchBelongsToAnotherGm() public async Task UpdateBatchNotificationModeForGmAsync_Throws_WhenBatchBelongsToAnotherGm()
{ {
@@ -333,12 +460,154 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal(BatchCloneInterval.NextWeek, store.LastCloneInterval); Assert.Equal(BatchCloneInterval.NextWeek, store.LastCloneInterval);
} }
[Fact]
public async Task CreateCampaignTemplateForGmAsync_CreatesTemplate_WhenGroupBelongsToGm()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
]);
var service = new AuthorizedSessionService(store);
await service.CreateCampaignTemplateForGmAsync(
groupId,
gmId,
new CreateCampaignTemplateRequest(
" Weekly arc ",
" Kingmaker ",
" https://example.test/kingmaker ",
SessionCount: 6,
IntervalDays: 7,
MaxPlayers: 5,
SessionNotificationMode.GroupOnly));
Assert.True(store.CreateCampaignTemplateCalled);
Assert.Equal(groupId, store.LastCreatedCampaignTemplateGroupId);
Assert.Equal("Weekly arc", store.LastCreatedCampaignTemplateRequest?.Name);
Assert.Equal("Kingmaker", store.LastCreatedCampaignTemplateRequest?.Title);
Assert.Equal("https://example.test/kingmaker", store.LastCreatedCampaignTemplateRequest?.JoinLink);
Assert.Equal(6, store.LastCreatedCampaignTemplateRequest?.SessionCount);
Assert.Equal(7, store.LastCreatedCampaignTemplateRequest?.IntervalDays);
Assert.Equal(5, store.LastCreatedCampaignTemplateRequest?.MaxPlayers);
Assert.Equal(SessionNotificationMode.GroupOnly, store.LastCreatedCampaignTemplateRequest?.NotificationMode);
}
[Fact]
public async Task CreateCampaignTemplateForGmAsync_AllowsNoSeatLimit()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
]);
var service = new AuthorizedSessionService(store);
await service.CreateCampaignTemplateForGmAsync(
groupId,
gmId,
new CreateCampaignTemplateRequest(
"Open table",
"West Marches",
"https://example.test/west",
8,
7,
null,
SessionNotificationMode.GroupAndDirect));
Assert.True(store.CreateCampaignTemplateCalled);
Assert.Null(store.LastCreatedCampaignTemplateRequest?.MaxPlayers);
}
[Fact]
public async Task CreateCampaignTemplateForGmAsync_Throws_WhenGroupBelongsToAnotherGm()
{
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.CreateCampaignTemplateForGmAsync(
groupId,
1001L,
new CreateCampaignTemplateRequest(
"Weekly arc",
"Kingmaker",
"https://example.test/kingmaker",
SessionCount: 6,
IntervalDays: 7,
MaxPlayers: 5,
SessionNotificationMode.GroupOnly));
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.CreateCampaignTemplateCalled);
}
[Fact]
public async Task CreateBatchFromCampaignTemplateForGmAsync_CreatesBatch_WhenTemplateGroupBelongsToGm()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var templateId = Guid.NewGuid();
var firstScheduledAt = DateTime.UtcNow.AddDays(3);
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
templates:
[
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
]);
var service = new AuthorizedSessionService(store);
await service.CreateBatchFromCampaignTemplateForGmAsync(templateId, gmId, firstScheduledAt);
Assert.True(store.CreateBatchFromTemplateCalled);
Assert.Equal(templateId, store.LastCreatedBatchTemplateId);
Assert.Equal(groupId, store.LastCreatedBatchTemplateGroupId);
Assert.Equal(firstScheduledAt, store.LastCreatedBatchFirstScheduledAt);
}
[Fact]
public async Task CreateBatchFromCampaignTemplateForGmAsync_Throws_WhenTemplateGroupBelongsToAnotherGm()
{
var groupId = Guid.NewGuid();
var templateId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
],
templates:
[
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.CreateBatchFromCampaignTemplateForGmAsync(templateId, 1001L, DateTime.UtcNow.AddDays(3));
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.CreateBatchFromTemplateCalled);
}
private sealed class FakeSessionStore( private sealed class FakeSessionStore(
IEnumerable<WebGameGroup>? groups = null, IEnumerable<WebGameGroup>? groups = null,
IEnumerable<WebSession>? sessions = null) : ISessionStore IEnumerable<WebSession>? sessions = null,
IEnumerable<FakeGroupManager>? managers = null,
IEnumerable<WebCampaignTemplate>? templates = null) : ISessionStore
{ {
private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? []; private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? [];
private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.Id) ?? []; private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.Id) ?? [];
private readonly Dictionary<Guid, WebCampaignTemplate> templatesById = templates?.ToDictionary(template => template.Id) ?? [];
private readonly List<FakeGroupManager> managers = managers?.ToList() ?? [];
public bool UpdateCalled { get; private set; } public bool UpdateCalled { get; private set; }
public bool PromoteCalled { get; private set; } public bool PromoteCalled { get; private set; }
@@ -346,6 +615,11 @@ public sealed class AuthorizedSessionServiceTests
public bool UpdateBatchNotificationModeCalled { get; private set; } public bool UpdateBatchNotificationModeCalled { get; private set; }
public bool RescheduleBatchCalled { get; private set; } public bool RescheduleBatchCalled { get; private set; }
public bool CloneBatchCalled { get; private set; } public bool CloneBatchCalled { get; private set; }
public bool CreateCampaignTemplateCalled { get; private set; }
public bool DeleteCampaignTemplateCalled { get; private set; }
public bool CreateBatchFromTemplateCalled { get; private set; }
public bool AddCoGmCalled { get; private set; }
public bool RemoveCoGmCalled { get; private set; }
public Guid? LastUpdatedSessionId { get; private set; } public Guid? LastUpdatedSessionId { get; private set; }
public Guid? LastUpdatedGroupId { get; private set; } public Guid? LastUpdatedGroupId { get; private set; }
public string? LastUpdatedTitle { get; private set; } public string? LastUpdatedTitle { get; private set; }
@@ -368,9 +642,22 @@ public sealed class AuthorizedSessionServiceTests
public Guid? LastClonedBatchId { get; private set; } public Guid? LastClonedBatchId { get; private set; }
public Guid? LastClonedBatchGroupId { get; private set; } public Guid? LastClonedBatchGroupId { get; private set; }
public BatchCloneInterval? LastCloneInterval { get; private set; } public BatchCloneInterval? LastCloneInterval { get; private set; }
public Guid? LastCreatedCampaignTemplateGroupId { get; private set; }
public CreateCampaignTemplateRequest? LastCreatedCampaignTemplateRequest { get; private set; }
public Guid? LastDeletedCampaignTemplateId { get; private set; }
public Guid? LastDeletedCampaignTemplateGroupId { get; private set; }
public Guid? LastCreatedBatchTemplateId { get; private set; }
public Guid? LastCreatedBatchTemplateGroupId { get; private set; }
public DateTime? LastCreatedBatchFirstScheduledAt { get; private set; }
public Guid? LastAddedCoGmGroupId { get; private set; }
public long? LastAddedCoGmTelegramId { get; private set; }
public string? LastAddedCoGmDisplayName { get; private set; }
public string? LastAddedCoGmUsername { get; private set; }
public Guid? LastRemovedCoGmGroupId { get; private set; }
public long? LastRemovedCoGmTelegramId { get; private set; }
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) => public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList()); Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, gmId)).ToList());
public Task<WebGameGroup?> GetGroupAsync(Guid groupId) public Task<WebGameGroup?> GetGroupAsync(Guid groupId)
{ {
@@ -378,6 +665,36 @@ public sealed class AuthorizedSessionServiceTests
return Task.FromResult(group); return Task.FromResult(group);
} }
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsManager(groupId, telegramId));
public Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsOwner(groupId, telegramId));
public Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
{
if (!groupsById.TryGetValue(groupId, out var group))
{
return Task.FromResult(new List<WebGroupManager>());
}
var result = new List<WebGroupManager>
{
new(group.GmTelegramId, "Owner GM", null, GroupManagerRoleExtensions.OwnerValue, DateTime.UtcNow)
};
result.AddRange(managers
.Where(manager => manager.GroupId == groupId)
.Select(manager => new WebGroupManager(
manager.TelegramId,
$"Co-GM {manager.TelegramId}",
null,
manager.Role.ToDatabaseValue(),
DateTime.UtcNow)));
return Task.FromResult(result);
}
public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) => public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) =>
Task.FromResult(sessionsById.Values.Where(session => session.GroupId == groupId).ToList()); Task.FromResult(sessionsById.Values.Where(session => session.GroupId == groupId).ToList());
@@ -474,5 +791,92 @@ public sealed class AuthorizedSessionServiceTests
DateTime.UtcNow.AddDays(7), DateTime.UtcNow.AddDays(7),
1)); 1));
} }
public Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId) =>
Task.FromResult(templatesById.Values.Where(template => template.GroupId == groupId).ToList());
public Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId)
{
templatesById.TryGetValue(templateId, out var template);
return Task.FromResult(template);
} }
public Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request)
{
CreateCampaignTemplateCalled = true;
LastCreatedCampaignTemplateGroupId = groupId;
LastCreatedCampaignTemplateRequest = request;
var template = new WebCampaignTemplate(
Guid.NewGuid(),
groupId,
request.Name,
request.Title,
request.JoinLink,
request.SessionCount,
request.IntervalDays,
request.MaxPlayers,
request.NotificationMode.ToDatabaseValue(),
DateTime.UtcNow,
DateTime.UtcNow);
templatesById[template.Id] = template;
return Task.FromResult(template);
}
public Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId)
{
DeleteCampaignTemplateCalled = true;
LastDeletedCampaignTemplateId = templateId;
LastDeletedCampaignTemplateGroupId = groupId;
templatesById.Remove(templateId);
return Task.CompletedTask;
}
public Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt)
{
CreateBatchFromTemplateCalled = true;
LastCreatedBatchTemplateId = templateId;
LastCreatedBatchTemplateGroupId = groupId;
LastCreatedBatchFirstScheduledAt = firstScheduledAt;
var template = templatesById[templateId];
return Task.FromResult(new WebSessionBatch(
Guid.NewGuid(),
groupId,
template.Title,
template.JoinLink,
firstScheduledAt,
firstScheduledAt.AddDays(template.IntervalDays * (template.SessionCount - 1)),
template.SessionCount,
template.NotificationMode));
}
public Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
{
AddCoGmCalled = true;
LastAddedCoGmGroupId = groupId;
LastAddedCoGmTelegramId = coGmTelegramId;
LastAddedCoGmDisplayName = displayName;
LastAddedCoGmUsername = telegramUsername;
return Task.CompletedTask;
}
public Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
{
RemoveCoGmCalled = true;
LastRemovedCoGmGroupId = groupId;
LastRemovedCoGmTelegramId = coGmTelegramId;
return Task.CompletedTask;
}
private bool IsManager(Guid groupId, long telegramId) =>
IsOwner(groupId, telegramId) ||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
private bool IsOwner(Guid groupId, long telegramId) =>
groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId == telegramId;
}
private sealed record FakeGroupManager(Guid GroupId, long TelegramId, GroupManagerRole Role);
} }
@@ -37,6 +37,35 @@ public sealed class BatchSchedulePlannerTests
Assert.Throws<ArgumentOutOfRangeException>(action); Assert.Throws<ArgumentOutOfRangeException>(action);
} }
[Fact]
public void BuildRecurringSchedule_CreatesFixedIntervalScheduleFromFirstDate()
{
var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc);
var result = BatchSchedulePlanner.BuildRecurringSchedule(firstScheduledAt, sessionCount: 3, intervalDays: 14);
Assert.Equal(
[
firstScheduledAt,
firstScheduledAt.AddDays(14),
firstScheduledAt.AddDays(28)
],
result);
}
[Theory]
[InlineData(0, 7)]
[InlineData(53, 7)]
[InlineData(3, 0)]
public void BuildRecurringSchedule_RejectsInvalidTemplateShape(int sessionCount, int intervalDays)
{
var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc);
var action = () => BatchSchedulePlanner.BuildRecurringSchedule(firstScheduledAt, sessionCount, intervalDays);
Assert.Throws<ArgumentOutOfRangeException>(action);
}
[Theory] [Theory]
[InlineData(BatchCloneInterval.NextWeek, 2026, 5, 8)] [InlineData(BatchCloneInterval.NextWeek, 2026, 5, 8)]
[InlineData(BatchCloneInterval.NextMonth, 2026, 6, 1)] [InlineData(BatchCloneInterval.NextMonth, 2026, 6, 1)]
@@ -0,0 +1,64 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class CampaignTemplatesNavigationTests
{
[Fact]
public async Task NavMenu_ShouldExposeTemplatesTab()
{
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
Assert.Contains("href=\"templates\"", navMenu, StringComparison.Ordinal);
Assert.Contains("Шаблоны", navMenu, StringComparison.Ordinal);
}
[Fact]
public async Task NavMenuStyles_ShouldStyleNavLinkAnchorsAsStackedRows()
{
var navCss = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor.css"));
Assert.Contains("::deep .nav-item", navCss, StringComparison.Ordinal);
Assert.Matches(
@"\.nav-section\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*gap:\s*0\.25rem;",
navCss);
Assert.Matches(
@"::deep\s+\.nav-item\s*\{[^}]*display:\s*flex;[^}]*width:\s*100%;",
navCss);
}
[Fact]
public async Task GroupDetails_ShouldApplyTemplatesWithoutManagingThem()
{
var groupDetails = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Pages/GroupDetails.razor"));
Assert.Contains("CreateBatchFromTemplate", groupDetails, StringComparison.Ordinal);
Assert.DoesNotContain("OnValidSubmit=\"CreateCampaignTemplate\"", groupDetails, StringComparison.Ordinal);
Assert.DoesNotContain("DeleteCampaignTemplate", groupDetails, StringComparison.Ordinal);
}
[Fact]
public async Task CampaignTemplatesPage_ShouldOwnTemplateManagement()
{
var templatesPage = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Pages/CampaignTemplates.razor"));
Assert.Contains("@page \"/templates\"", templatesPage, StringComparison.Ordinal);
Assert.Contains("OnValidSubmit=\"CreateCampaignTemplate\"", templatesPage, StringComparison.Ordinal);
Assert.Contains("DeleteCampaignTemplate", templatesPage, StringComparison.Ordinal);
}
private static string FindRepositoryFile(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -0,0 +1,60 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class MiniAppDashboardTests
{
[Fact]
public async Task MiniAppPage_ShouldExposeTelegramWebAppDashboardEntryPoint()
{
var miniAppPage = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Pages/MiniApp.razor"));
Assert.Contains("@page \"/miniapp\"", miniAppPage, StringComparison.Ordinal);
Assert.Contains("authenticateTelegramMiniApp", miniAppPage, StringComparison.Ordinal);
Assert.Contains("/auth/telegram-webapp", miniAppPage, StringComparison.Ordinal);
}
[Fact]
public async Task AppShell_ShouldLoadTelegramWebAppScriptAndAuthenticator()
{
var appShell = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/App.razor"));
Assert.Contains("telegram-web-app.js", appShell, StringComparison.Ordinal);
Assert.Contains("window.authenticateTelegramMiniApp", appShell, StringComparison.Ordinal);
Assert.Contains("Telegram.WebApp.initData", appShell, StringComparison.Ordinal);
}
[Fact]
public async Task Program_ShouldMapTelegramWebAppAuthEndpoint()
{
var program = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Program.cs"));
Assert.Contains("MapPost(\"/auth/telegram-webapp\"", program, StringComparison.Ordinal);
Assert.Contains("VerifyWebAppInitData", program, StringComparison.Ordinal);
}
[Fact]
public async Task Styles_ShouldIncludeMiniAppMobileDashboardRules()
{
var css = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/wwwroot/app.css"));
Assert.Contains("mini-app-page", css, StringComparison.Ordinal);
Assert.Contains("mini-app-auth-card", css, StringComparison.Ordinal);
Assert.Contains("@media (max-width: 768px)", css, StringComparison.Ordinal);
}
private static string FindRepositoryFile(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -77,6 +77,64 @@ public sealed class TelegramAuthServiceTests
Assert.False(verified); Assert.False(verified);
} }
[Fact]
public void VerifyWebAppInitData_ShouldAcceptValidTelegramWebAppPayload()
{
const string botToken = "test-bot-token";
var initData = CreateWebAppInitData(
botToken,
new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
["query_id"] = "AAHdF6IQAAAAAN0XohDhrOrc",
["user"] = """{"id":424242,"first_name":"Ada","last_name":"Lovelace","username":"ada"}"""
});
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(initData, out var telegramId, out var name);
Assert.True(verified);
Assert.Equal(424242L, telegramId);
Assert.Equal("Ada Lovelace", name);
}
[Fact]
public void VerifyWebAppInitData_ShouldRejectTamperedHash()
{
const string botToken = "test-bot-token";
var initData = CreateWebAppInitData(
botToken,
new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
["user"] = """{"id":424242,"first_name":"Ada"}"""
});
var tamperedInitData = initData.Replace("hash=", "hash=00", StringComparison.Ordinal);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(tamperedInitData, out _, out _);
Assert.False(verified);
}
[Fact]
public void VerifyWebAppInitData_ShouldRejectExpiredPayload()
{
const string botToken = "test-bot-token";
var initData = CreateWebAppInitData(
botToken,
new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds().ToString(),
["user"] = """{"id":424242,"first_name":"Ada"}"""
});
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(initData, out _, out _);
Assert.False(verified);
}
private static IConfiguration CreateConfiguration(string botToken) => private static IConfiguration CreateConfiguration(string botToken) =>
new ConfigurationBuilder() new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> .AddInMemoryCollection(new Dictionary<string, string?>
@@ -106,4 +164,27 @@ public sealed class TelegramAuthServiceTests
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString)); var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant(); return Convert.ToHexString(hashBytes).ToLowerInvariant();
} }
private static string CreateWebAppInitData(string botToken, IReadOnlyDictionary<string, string> values)
{
var hash = ComputeTelegramWebAppHash(botToken, values);
var encodedPairs = values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")
.Append($"hash={hash}");
return string.Join("&", encodedPairs);
}
private static string ComputeTelegramWebAppHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = HMACSHA256.HashData(Encoding.UTF8.GetBytes("WebAppData"), Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
} }