Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41f2ea6e90 | |||
| 5082dd4fcf | |||
| cfbda4ca05 | |||
| 0218890a7a | |||
| a1ec688ec8 | |||
| 2529df4157 | |||
| a8f2b10956 | |||
| 3228e77c7f | |||
| 621ef553e7 | |||
| 5f3516e703 | |||
| 2eb7d86e48 | |||
| 3e291b0ed5 | |||
| a5ba4111cf | |||
| f45985041b | |||
| 9c91057798 | |||
| 675ac1226e | |||
| b80002aa36 | |||
| bb8cbb7a40 |
@@ -6,5 +6,12 @@ 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
|
||||||
|
|
||||||
|
# Локальный порт веб-интерфейса GM-Relay
|
||||||
|
GMRELAY_WEB_PORT=8080
|
||||||
|
|||||||
+28
-23
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 1.1.2
|
VERSION: 1.9.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
@@ -23,29 +23,33 @@ jobs:
|
|||||||
username: toutsu
|
username: toutsu
|
||||||
password: ${{ secrets.GIT_TOKEN }}
|
password: ${{ secrets.GIT_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Bot
|
- name: Build Bot image
|
||||||
uses: docker/build-push-action@v5
|
run: |
|
||||||
with:
|
docker build \
|
||||||
context: .
|
--label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
|
||||||
file: src/GmRelay.Bot/Dockerfile
|
-f src/GmRelay.Bot/Dockerfile \
|
||||||
push: true
|
-t git.codeanddice.ru/toutsu/gmrelay-bot:latest \
|
||||||
tags: |
|
-t git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }} \
|
||||||
git.codeanddice.ru/toutsu/gmrelay-bot:latest
|
.
|
||||||
git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
|
|
||||||
labels: |
|
|
||||||
org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}
|
|
||||||
|
|
||||||
- name: Build and push Web
|
- name: Push Bot image
|
||||||
uses: docker/build-push-action@v5
|
run: |
|
||||||
with:
|
docker push git.codeanddice.ru/toutsu/gmrelay-bot:latest
|
||||||
context: .
|
docker push git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
|
||||||
file: src/GmRelay.Web/Dockerfile
|
|
||||||
push: true
|
- name: Build Web image
|
||||||
tags: |
|
run: |
|
||||||
git.codeanddice.ru/toutsu/gmrelay-web:latest
|
docker build \
|
||||||
git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
|
--label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
|
||||||
labels: |
|
-f src/GmRelay.Web/Dockerfile \
|
||||||
org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}
|
-t git.codeanddice.ru/toutsu/gmrelay-web:latest \
|
||||||
|
-t git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }} \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push Web image
|
||||||
|
run: |
|
||||||
|
docker push git.codeanddice.ru/toutsu/gmrelay-web:latest
|
||||||
|
docker push git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
|
||||||
|
|
||||||
# ЧАСТЬ 2: Запускаем эти образы на самом сервере
|
# ЧАСТЬ 2: Запускаем эти образы на самом сервере
|
||||||
deploy:
|
deploy:
|
||||||
@@ -60,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,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.1.2</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>
|
||||||
|
|||||||
@@ -4,21 +4,33 @@
|
|||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||||
|
|
||||||
|
**Текущая версия:** `v1.9.0`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Ключевые возможности
|
## ✨ Ключевые возможности
|
||||||
|
|
||||||
### 🤖 Telegram Бот
|
### 🤖 Telegram Бот
|
||||||
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
|
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
|
||||||
- **✋ Интерактивная запись**: Игроки записываются на конкретные даты нажатием одной кнопки.
|
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
|
||||||
|
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
|
||||||
|
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
|
||||||
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
|
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
|
||||||
- **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания.
|
- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры прямо из Telegram.
|
||||||
|
- **🔄 Голосование за перенос**: При переносе сессии GM предлагает 2-3 новых времени и дедлайн, игроки голосуют кнопками, а бот показывает текущие результаты и применяет победивший вариант.
|
||||||
|
- **🔔 Персональные уведомления**: Игроки получают 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 на следующую неделю или месяц.
|
||||||
|
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
|
||||||
|
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
|
||||||
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
|
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
|
||||||
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
|
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
|
||||||
|
|
||||||
@@ -63,21 +75,31 @@ 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=ваш_надежный_пароль
|
||||||
|
|
||||||
|
# Локальный порт веб-интерфейса GM-Relay
|
||||||
|
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
|
||||||
docker compose up -d -build
|
docker compose up -d
|
||||||
```
|
```
|
||||||
Инфраструктура автоматически:
|
Инфраструктура автоматически:
|
||||||
- Поднимет PostgreSQL.
|
- Создаст локальную Docker-сеть и volume PostgreSQL, если их ещё нет.
|
||||||
|
- Поднимет PostgreSQL, доступный для контейнеров как `db:5432`.
|
||||||
- Запустит бота (применив миграции БД).
|
- Запустит бота (применив миграции БД).
|
||||||
- Запустит веб-интерфейс (доступен по умолчанию на порту **8080** внутри контейнера).
|
- Запустит веб-интерфейс на `http://localhost:8080` или другом порту из `GMRELAY_WEB_PORT`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -92,7 +114,7 @@ docker compose up -d -build
|
|||||||
* `Закрепление сообщений` — рекомендуется.
|
* `Закрепление сообщений` — рекомендуется.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Колонку "Мастер" (GM) бот определяет по первому человеку, который создал сессию в этой группе. Только этот пользователь сможет отменять игры через кнопки бота и редактировать их в веб-интерфейсе.
|
> Owner группы определяется по первому человеку, который создал сессию в этой группе. Owner может назначать co-GM в Web Dashboard; owner и co-GM могут управлять сессиями через кнопки бота и веб-интерфейс.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -106,12 +128,64 @@ docker compose up -d -build
|
|||||||
Название: Легенды Берега Мечей (D&D 5e)
|
Название: Легенды Берега Мечей (D&D 5e)
|
||||||
Время: 15.05.2024 19:30
|
Время: 15.05.2024 19:30
|
||||||
Время: 22.05.2024 19:00
|
Время: 22.05.2024 19:00
|
||||||
|
Мест: 4
|
||||||
Ссылка: https://discord.gg/invite-link
|
Ссылка: https://discord.gg/invite-link
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard.
|
||||||
|
|
||||||
|
Для регулярной кампании можно не перечислять все даты вручную. Укажите одну строку `Время:`, количество игр и интервал в днях:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/newsession
|
||||||
|
Название: Kingmaker
|
||||||
|
Время: 30.04.2026 19:30
|
||||||
|
Игр: 6
|
||||||
|
Интервал: 7
|
||||||
|
Мест: 5
|
||||||
|
Ссылка: https://discord.gg/invite-link
|
||||||
|
```
|
||||||
|
|
||||||
|
Бот создаст 6 игр с недельным шагом. Вместо `Игр:` также принимается `Сессий:` или `Повторов:`, вместо `Интервал:` — `Шаг:`.
|
||||||
|
|
||||||
|
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
|
||||||
|
|
||||||
|
### Делегирование управления
|
||||||
|
На странице группы Web Dashboard показывает owner и список co-GM. Owner может добавить помощника по Telegram ID, имени и username, а также снять роль co-GM. Назначенный co-GM видит группу в панели управления и может редактировать сессии, управлять batch-операциями, очередью, переносами и удалением игр, но не может назначать других co-GM.
|
||||||
|
|
||||||
|
### Перенос сессии голосованием
|
||||||
|
Owner или co-GM нажимает кнопку `⏰ Перенести` у нужной сессии и отправляет в чат 2-3 варианта нового времени вместе с дедлайном:
|
||||||
|
|
||||||
|
```text
|
||||||
|
25.04.2026 19:30
|
||||||
|
26.04.2026 18:00
|
||||||
|
Дедлайн: 25.04.2026 12:00
|
||||||
|
```
|
||||||
|
|
||||||
|
Дедлайн должен быть в будущем и раньше первого предложенного времени. Участники выбирают один вариант кнопкой в Telegram, могут изменить голос до дедлайна и видят текущие результаты в сообщении голосования. По дедлайну бот выбирает вариант с наибольшим числом голосов, переносит сессию, сбрасывает RSVP и обновляет batch-сообщение. Если голосов нет или есть ничья, перенос отклоняется, а время сессии остаётся прежним.
|
||||||
|
|
||||||
|
### Шаблоны и bulk-операции в Web Dashboard
|
||||||
|
Вкладка `Шаблоны` в левом меню вынесена отдельно от страницы группы. Owner и co-GM выбирают группу, сохраняют шаблон кампании с названием, ссылкой, количеством игр, интервалом, лимитом мест и режимом уведомлений, а также удаляют устаревшие шаблоны.
|
||||||
|
|
||||||
|
На странице группы Web Dashboard показывает только применение сохранённых шаблонов и отдельный блок для каждой пачки игр. Owner и co-GM могут:
|
||||||
|
- создать новый batch из шаблона, выбрав только первую дату расписания;
|
||||||
|
- обновить общий `title` и `link` сразу у всех сессий batch;
|
||||||
|
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
|
||||||
|
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
|
||||||
|
- клонировать batch на следующую неделю или следующий календарный месяц.
|
||||||
|
|
||||||
|
После создания из шаблона или клонирования появляется новая пачка с новым Telegram-сообщением и пустым составом игроков. После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается.
|
||||||
|
|
||||||
|
Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам.
|
||||||
|
|
||||||
|
### Telegram Mini App Dashboard
|
||||||
|
Owner и co-GM могут открыть мобильный dashboard прямо из Telegram через кнопку меню бота или кнопку `Открыть dashboard` после `/start`. Страница `/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` — Справка по формату.
|
||||||
|
|||||||
+24
-18
@@ -1,16 +1,15 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
container_name: gmrelay_db
|
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: gmrelay
|
POSTGRES_USER: gmrelay
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||||
POSTGRES_DB: gmrelay_db
|
POSTGRES_DB: gmrelay_db
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
ports:
|
networks:
|
||||||
- "5432:5432"
|
- gmrelay
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "pg_isready -U gmrelay -d gmrelay_db" ]
|
test: [ "CMD-SHELL", "pg_isready -U gmrelay -d gmrelay_db" ]
|
||||||
interval: 3s
|
interval: 3s
|
||||||
@@ -18,35 +17,42 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.2
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.0
|
||||||
container_name: gmrelay_bot
|
|
||||||
restart: always
|
restart: always
|
||||||
network_mode: host
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
- "ConnectionStrings__gmrelaydb=Host=127.0.0.1;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD}"
|
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
||||||
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}"
|
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
|
||||||
|
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
|
||||||
|
networks:
|
||||||
|
- gmrelay
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.2
|
image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.0
|
||||||
container_name: gmrelay_web
|
|
||||||
restart: always
|
restart: always
|
||||||
network_mode: host
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
- "ConnectionStrings__gmrelaydb=Host=127.0.0.1;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD}"
|
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
||||||
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}"
|
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
|
||||||
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME}"
|
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}"
|
||||||
|
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
|
||||||
|
ports:
|
||||||
|
- "${GMRELAY_WEB_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- web_keys:/app/dataprotection-keys
|
- web_keys:/app/dataprotection-keys
|
||||||
|
networks:
|
||||||
|
- gmrelay
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
external: true
|
name: ${POSTGRES_VOLUME_NAME:-game_pgdata}
|
||||||
name: game_pgdata
|
|
||||||
web_keys:
|
web_keys:
|
||||||
name: gmrelay_web_keys
|
name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
gmrelay:
|
||||||
|
driver: bridge
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||||
|
|
||||||
// ── Command ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public sealed record HandleRsvpCommand(
|
public sealed record HandleRsvpCommand(
|
||||||
Guid SessionId,
|
Guid SessionId,
|
||||||
long TelegramUserId,
|
long TelegramUserId,
|
||||||
@@ -17,13 +14,12 @@ public sealed record HandleRsvpCommand(
|
|||||||
long ChatId,
|
long ChatId,
|
||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
|
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
|
||||||
|
|
||||||
internal sealed record SessionContext(
|
internal sealed record SessionContext(
|
||||||
string Title,
|
string Title,
|
||||||
DateTime ScheduledAt,
|
DateTime ScheduledAt,
|
||||||
|
string Status,
|
||||||
long GmTelegramId,
|
long GmTelegramId,
|
||||||
long TelegramChatId);
|
long TelegramChatId);
|
||||||
|
|
||||||
@@ -33,21 +29,6 @@ internal sealed record ParticipantRsvp(
|
|||||||
string? TelegramUsername,
|
string? TelegramUsername,
|
||||||
string RsvpStatus);
|
string RsvpStatus);
|
||||||
|
|
||||||
// ── Handler ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles the "Буду" / "Не смогу" callback query.
|
|
||||||
///
|
|
||||||
/// Flow:
|
|
||||||
/// 1. Validate that the user is a participant in this session
|
|
||||||
/// 2. Record or update their RSVP (idempotent)
|
|
||||||
/// 3. If declined → alert GM privately, revert session if was Confirmed
|
|
||||||
/// 4. If all non-GM players confirmed → mark session Confirmed, notify group + GM
|
|
||||||
/// 5. Update the inline keyboard to show current RSVP status
|
|
||||||
///
|
|
||||||
/// Concurrency: two simultaneous clicks on different rows don't conflict (MVCC).
|
|
||||||
/// The last EditMessage wins, which is fine — both reflect up-to-date state.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class HandleRsvpHandler(
|
public sealed class HandleRsvpHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
@@ -58,19 +39,19 @@ public sealed class HandleRsvpHandler(
|
|||||||
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. Validate participant ──────────────────────────────────
|
|
||||||
|
|
||||||
var participantExists = await connection.ExecuteScalarAsync<bool>(
|
var participantExists = await connection.ExecuteScalarAsync<bool>(
|
||||||
"""
|
"""
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT 1 FROM session_participants sp
|
SELECT 1
|
||||||
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
WHERE sp.session_id = @SessionId
|
WHERE sp.session_id = @SessionId
|
||||||
AND p.telegram_id = @TelegramUserId
|
AND p.telegram_id = @TelegramUserId
|
||||||
AND sp.is_gm = false
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
new { command.SessionId, command.TelegramUserId },
|
new { command.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
if (!participantExists)
|
if (!participantExists)
|
||||||
@@ -82,8 +63,6 @@ public sealed class HandleRsvpHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Record RSVP (idempotent) ─────────────────────────────
|
|
||||||
|
|
||||||
var updated = await connection.ExecuteAsync(
|
var updated = await connection.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
UPDATE session_participants
|
UPDATE session_participants
|
||||||
@@ -91,14 +70,14 @@ public sealed class HandleRsvpHandler(
|
|||||||
responded_at = now()
|
responded_at = now()
|
||||||
WHERE session_id = @SessionId
|
WHERE session_id = @SessionId
|
||||||
AND player_id = (SELECT id FROM players WHERE telegram_id = @TelegramUserId)
|
AND player_id = (SELECT id FROM players WHERE telegram_id = @TelegramUserId)
|
||||||
|
AND registration_status = @Active
|
||||||
AND rsvp_status != @Status
|
AND rsvp_status != @Status
|
||||||
""",
|
""",
|
||||||
new { command.SessionId, command.TelegramUserId, command.Status },
|
new { command.SessionId, command.TelegramUserId, command.Status, Active = ParticipantRegistrationStatus.Active },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
if (updated == 0)
|
if (updated == 0)
|
||||||
{
|
{
|
||||||
// Already in this state — just dismiss the loading spinner
|
|
||||||
var alreadyText = command.Status == RsvpStatus.Confirmed
|
var alreadyText = command.Status == RsvpStatus.Confirmed
|
||||||
? "Вы уже подтвердили участие."
|
? "Вы уже подтвердили участие."
|
||||||
: "Вы уже отказались от участия.";
|
: "Вы уже отказались от участия.";
|
||||||
@@ -110,11 +89,11 @@ public sealed class HandleRsvpHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Load session context ─────────────────────────────────
|
|
||||||
|
|
||||||
var session = await connection.QuerySingleAsync<SessionContext>(
|
var session = await connection.QuerySingleAsync<SessionContext>(
|
||||||
"""
|
"""
|
||||||
SELECT s.title, s.scheduled_at AS ScheduledAt,
|
SELECT s.title,
|
||||||
|
s.scheduled_at AS ScheduledAt,
|
||||||
|
s.status AS Status,
|
||||||
g.gm_telegram_id AS GmTelegramId,
|
g.gm_telegram_id AS GmTelegramId,
|
||||||
g.telegram_chat_id AS TelegramChatId
|
g.telegram_chat_id AS TelegramChatId
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
@@ -124,26 +103,27 @@ public sealed class HandleRsvpHandler(
|
|||||||
new { command.SessionId },
|
new { command.SessionId },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
// ── 4. Handle decline ───────────────────────────────────────
|
|
||||||
|
|
||||||
if (command.Status == RsvpStatus.Declined)
|
if (command.Status == RsvpStatus.Declined)
|
||||||
{
|
{
|
||||||
// Revert session to ConfirmationSent if it was Confirmed
|
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0);
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
if (decision.ShouldRevertSessionToConfirmationSent)
|
||||||
UPDATE sessions
|
{
|
||||||
SET status = @ConfirmationSent, updated_at = now()
|
await connection.ExecuteAsync(
|
||||||
WHERE id = @SessionId AND status = @Confirmed
|
"""
|
||||||
""",
|
UPDATE sessions
|
||||||
new
|
SET status = @ConfirmationSent, updated_at = now()
|
||||||
{
|
WHERE id = @SessionId AND status = @Confirmed
|
||||||
command.SessionId,
|
""",
|
||||||
ConfirmationSent = SessionStatus.ConfirmationSent,
|
new
|
||||||
Confirmed = SessionStatus.Confirmed
|
{
|
||||||
},
|
command.SessionId,
|
||||||
transaction);
|
ConfirmationSent = SessionStatus.ConfirmationSent,
|
||||||
|
Confirmed = SessionStatus.Confirmed
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
|
||||||
// Alert GM immediately via private message
|
|
||||||
var declinedPlayer = await connection.QuerySingleAsync<string>(
|
var declinedPlayer = await connection.QuerySingleAsync<string>(
|
||||||
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
|
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
|
||||||
new { command.TelegramUserId },
|
new { command.TelegramUserId },
|
||||||
@@ -151,7 +131,6 @@ public sealed class HandleRsvpHandler(
|
|||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
// Send alert outside transaction (network call)
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
@@ -161,38 +140,38 @@ public sealed class HandleRsvpHandler(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}",
|
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", command.SessionId);
|
||||||
command.SessionId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await bot.AnswerCallbackQuery(
|
await bot.AnswerCallbackQuery(
|
||||||
callbackQueryId: command.CallbackQueryId,
|
callbackQueryId: command.CallbackQueryId,
|
||||||
text: "Вы отказались от участия.",
|
text: decision.CallbackText,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
}
|
}
|
||||||
// ── 5. Handle confirm — check if ALL confirmed ──────────────
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var counts = await connection.QuerySingleAsync<RsvpCounts>(
|
var counts = await connection.QuerySingleAsync<RsvpCounts>(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
count(*) AS Total,
|
count(*) AS Total,
|
||||||
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
|
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
|
||||||
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
|
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
|
||||||
FROM session_participants
|
FROM session_participants
|
||||||
WHERE session_id = @SessionId AND is_gm = false
|
WHERE session_id = @SessionId AND is_gm = false
|
||||||
|
AND registration_status = @Active
|
||||||
""",
|
""",
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
command.SessionId,
|
command.SessionId,
|
||||||
Confirmed = RsvpStatus.Confirmed,
|
Confirmed = RsvpStatus.Confirmed,
|
||||||
Declined = RsvpStatus.Declined
|
Declined = RsvpStatus.Declined,
|
||||||
|
Active = ParticipantRegistrationStatus.Active
|
||||||
},
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
var allConfirmed = counts.Confirmed == counts.Total;
|
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed);
|
||||||
|
|
||||||
if (allConfirmed)
|
if (decision.ShouldMarkSessionConfirmed)
|
||||||
{
|
{
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
@@ -206,9 +185,8 @@ public sealed class HandleRsvpHandler(
|
|||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
if (allConfirmed)
|
if (decision.ShouldNotifyGroup)
|
||||||
{
|
{
|
||||||
// Notify group
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
@@ -218,11 +196,12 @@ public sealed class HandleRsvpHandler(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}",
|
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", command.SessionId);
|
||||||
command.SessionId);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Notify GM privately
|
if (decision.ShouldNotifyGm)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
@@ -232,27 +211,20 @@ public sealed class HandleRsvpHandler(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}",
|
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", command.SessionId);
|
||||||
command.SessionId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await bot.AnswerCallbackQuery(
|
await bot.AnswerCallbackQuery(
|
||||||
callbackQueryId: command.CallbackQueryId,
|
callbackQueryId: command.CallbackQueryId,
|
||||||
text: "Вы подтвердили участие!",
|
text: decision.CallbackText,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 6. Update inline keyboard message ───────────────────────
|
|
||||||
|
|
||||||
await UpdateConfirmationMessage(command, session, ct);
|
await UpdateConfirmationMessage(command, session, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private async Task UpdateConfirmationMessage(HandleRsvpCommand command, SessionContext session, CancellationToken ct)
|
||||||
/// Re-renders the confirmation message with current RSVP statuses.
|
|
||||||
/// </summary>
|
|
||||||
private async Task UpdateConfirmationMessage(
|
|
||||||
HandleRsvpCommand command, SessionContext session, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -260,16 +232,18 @@ public sealed class HandleRsvpHandler(
|
|||||||
|
|
||||||
var participants = (await connection.QueryAsync<ParticipantRsvp>(
|
var participants = (await connection.QueryAsync<ParticipantRsvp>(
|
||||||
"""
|
"""
|
||||||
SELECT p.telegram_id AS TelegramId,
|
SELECT p.telegram_id AS TelegramId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
p.telegram_username AS TelegramUsername,
|
||||||
sp.rsvp_status AS RsvpStatus
|
sp.rsvp_status AS RsvpStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
WHERE sp.session_id = @SessionId AND sp.is_gm = false
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
ORDER BY sp.responded_at NULLS LAST
|
ORDER BY sp.responded_at NULLS LAST
|
||||||
""",
|
""",
|
||||||
new { command.SessionId })).ToList();
|
new { command.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||||||
|
|
||||||
var confirmed = participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
|
var confirmed = participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
|
||||||
var declined = participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
|
var declined = participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
|
||||||
@@ -279,34 +253,47 @@ public sealed class HandleRsvpHandler(
|
|||||||
{
|
{
|
||||||
$"🎲 Подтвердите участие в «{session.Title}»",
|
$"🎲 Подтвердите участие в «{session.Title}»",
|
||||||
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
|
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
|
||||||
""
|
string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var p in confirmed)
|
foreach (var participant in confirmed)
|
||||||
lines.Add($" ✅ {FormatName(p)}");
|
{
|
||||||
foreach (var p in declined)
|
lines.Add($" ✅ {FormatName(participant)}");
|
||||||
lines.Add($" ❌ ~~{FormatName(p)}~~");
|
}
|
||||||
foreach (var p in pending)
|
|
||||||
lines.Add($" ⏳ {FormatName(p)}");
|
|
||||||
|
|
||||||
lines.Add("");
|
foreach (var participant in declined)
|
||||||
|
{
|
||||||
|
lines.Add($" ❌ ~~{FormatName(participant)}~~");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var participant in pending)
|
||||||
|
{
|
||||||
|
lines.Add($" ⏳ {FormatName(participant)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.Add(string.Empty);
|
||||||
|
|
||||||
if (confirmed.Count == participants.Count)
|
if (confirmed.Count == participants.Count)
|
||||||
|
{
|
||||||
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})");
|
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})");
|
||||||
|
}
|
||||||
else if (declined.Count > 0)
|
else if (declined.Count > 0)
|
||||||
|
{
|
||||||
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)");
|
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)");
|
||||||
|
}
|
||||||
else
|
else
|
||||||
|
{
|
||||||
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})");
|
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})");
|
||||||
|
}
|
||||||
|
|
||||||
var text = string.Join("\n", lines);
|
var text = string.Join("\n", lines);
|
||||||
|
|
||||||
// Keep buttons unless everyone confirmed
|
|
||||||
var replyMarkup = confirmed.Count == participants.Count
|
var replyMarkup = confirmed.Count == participants.Count
|
||||||
? null
|
? null
|
||||||
: new InlineKeyboardMarkup([
|
: new InlineKeyboardMarkup([
|
||||||
[
|
[
|
||||||
InlineKeyboardButton.WithCallbackData("\u2705 Буду", $"rsvp:confirm:{command.SessionId}"),
|
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{command.SessionId}"),
|
||||||
InlineKeyboardButton.WithCallbackData("\u274c Не смогу", $"rsvp:decline:{command.SessionId}")
|
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{command.SessionId}")
|
||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -319,12 +306,10 @@ public sealed class HandleRsvpHandler(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// EditMessage can fail if message is too old or unchanged — non-critical
|
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId);
|
||||||
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}",
|
|
||||||
command.SessionId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatName(ParticipantRsvp p) =>
|
private static string FormatName(ParticipantRsvp participant) =>
|
||||||
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
|
participant.TelegramUsername is not null ? $"@{participant.TelegramUsername}" : participant.DisplayName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||||
|
|
||||||
|
internal sealed record RsvpFlowDecision(
|
||||||
|
string CallbackText,
|
||||||
|
bool ShouldAlertGm,
|
||||||
|
bool ShouldRevertSessionToConfirmationSent,
|
||||||
|
bool ShouldMarkSessionConfirmed,
|
||||||
|
bool ShouldNotifyGroup,
|
||||||
|
bool ShouldNotifyGm);
|
||||||
|
|
||||||
|
internal static class RsvpFlowRules
|
||||||
|
{
|
||||||
|
public static RsvpFlowDecision Evaluate(
|
||||||
|
string requestedStatus,
|
||||||
|
string currentSessionStatus,
|
||||||
|
int totalParticipants,
|
||||||
|
int confirmedParticipants)
|
||||||
|
{
|
||||||
|
if (requestedStatus == RsvpStatus.Declined)
|
||||||
|
{
|
||||||
|
return new RsvpFlowDecision(
|
||||||
|
CallbackText: "\u0412\u044b \u043e\u0442\u043a\u0430\u0437\u0430\u043b\u0438\u0441\u044c \u043e\u0442 \u0443\u0447\u0430\u0441\u0442\u0438\u044f.",
|
||||||
|
ShouldAlertGm: true,
|
||||||
|
ShouldRevertSessionToConfirmationSent: currentSessionStatus == SessionStatus.Confirmed,
|
||||||
|
ShouldMarkSessionConfirmed: false,
|
||||||
|
ShouldNotifyGroup: false,
|
||||||
|
ShouldNotifyGm: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var everyoneConfirmed = confirmedParticipants == totalParticipants;
|
||||||
|
|
||||||
|
return new RsvpFlowDecision(
|
||||||
|
CallbackText: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u0443\u0447\u0430\u0441\u0442\u0438\u0435!",
|
||||||
|
ShouldAlertGm: false,
|
||||||
|
ShouldRevertSessionToConfirmationSent: false,
|
||||||
|
ShouldMarkSessionConfirmed: everyoneConfirmed,
|
||||||
|
ShouldNotifyGroup: everyoneConfirmed,
|
||||||
|
ShouldNotifyGm: everyoneConfirmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Features.Notifications;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
@@ -13,7 +14,8 @@ internal sealed record SessionInfo(
|
|||||||
string Title,
|
string Title,
|
||||||
DateTime ScheduledAt,
|
DateTime ScheduledAt,
|
||||||
Guid GroupId,
|
Guid GroupId,
|
||||||
long TelegramChatId);
|
long TelegramChatId,
|
||||||
|
string NotificationMode);
|
||||||
|
|
||||||
internal sealed record ParticipantInfo(
|
internal sealed record ParticipantInfo(
|
||||||
long TelegramId,
|
long TelegramId,
|
||||||
@@ -29,6 +31,7 @@ internal sealed record ParticipantInfo(
|
|||||||
public sealed class SendConfirmationHandler(
|
public sealed class SendConfirmationHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
|
DirectSessionNotificationSender directSender,
|
||||||
ILogger<SendConfirmationHandler> logger)
|
ILogger<SendConfirmationHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||||
@@ -39,7 +42,8 @@ public sealed class SendConfirmationHandler(
|
|||||||
var session = await connection.QuerySingleOrDefaultAsync<SessionInfo>(
|
var session = await connection.QuerySingleOrDefaultAsync<SessionInfo>(
|
||||||
"""
|
"""
|
||||||
SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId,
|
SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId,
|
||||||
g.telegram_chat_id AS TelegramChatId
|
g.telegram_chat_id AS TelegramChatId,
|
||||||
|
s.notification_mode AS NotificationMode
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @SessionId AND s.status = @Planned
|
WHERE s.id = @SessionId AND s.status = @Planned
|
||||||
@@ -60,9 +64,11 @@ public sealed class SendConfirmationHandler(
|
|||||||
p.telegram_username AS TelegramUsername
|
p.telegram_username AS TelegramUsername
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
WHERE sp.session_id = @SessionId AND sp.is_gm = false
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
""",
|
""",
|
||||||
new { SessionId = sessionId })).ToList();
|
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||||||
|
|
||||||
if (participants.Count == 0)
|
if (participants.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -113,6 +119,26 @@ public sealed class SendConfirmationHandler(
|
|||||||
MessageId = message.MessageId
|
MessageId = message.MessageId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
||||||
|
if (mode.ShouldSendDirectMessages())
|
||||||
|
{
|
||||||
|
var directText = $"""
|
||||||
|
🎲 <b>Подтвердите участие в игре</b>
|
||||||
|
|
||||||
|
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
|
||||||
|
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
|
||||||
|
|
||||||
|
Ответьте кнопкой в групповом сообщении расписания.
|
||||||
|
""";
|
||||||
|
|
||||||
|
await directSender.SendAsync(
|
||||||
|
participants.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)),
|
||||||
|
directText,
|
||||||
|
"confirmation",
|
||||||
|
sessionId,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Confirmation sent for session {SessionId} ({Title}), message_id={MessageId}",
|
"Confirmation sent for session {SessionId} ({Title}), message_id={MessageId}",
|
||||||
sessionId, session.Title, message.MessageId);
|
sessionId, session.Title, message.MessageId);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types.Enums;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Notifications;
|
||||||
|
|
||||||
|
public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName);
|
||||||
|
|
||||||
|
public sealed class DirectSessionNotificationSender(
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
ILogger<DirectSessionNotificationSender> logger)
|
||||||
|
{
|
||||||
|
public async Task SendAsync(
|
||||||
|
IEnumerable<DirectNotificationRecipient> recipients,
|
||||||
|
string htmlText,
|
||||||
|
string notificationKind,
|
||||||
|
Guid sessionId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
foreach (var recipient in recipients)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await bot.SendMessage(
|
||||||
|
chatId: recipient.TelegramId,
|
||||||
|
text: htmlText,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"Failed to send {NotificationKind} DM for session {SessionId} to player {TelegramId} ({DisplayName})",
|
||||||
|
notificationKind,
|
||||||
|
sessionId,
|
||||||
|
recipient.TelegramId,
|
||||||
|
recipient.DisplayName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Features.Notifications;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
@@ -12,7 +13,8 @@ internal sealed record JoinLinkSession(
|
|||||||
string Title,
|
string Title,
|
||||||
string JoinLink,
|
string JoinLink,
|
||||||
DateTime ScheduledAt,
|
DateTime ScheduledAt,
|
||||||
long TelegramChatId);
|
long TelegramChatId,
|
||||||
|
string NotificationMode);
|
||||||
|
|
||||||
internal sealed record ConfirmedPlayer(
|
internal sealed record ConfirmedPlayer(
|
||||||
long TelegramId,
|
long TelegramId,
|
||||||
@@ -28,6 +30,7 @@ internal sealed record ConfirmedPlayer(
|
|||||||
public sealed class SendJoinLinkHandler(
|
public sealed class SendJoinLinkHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
|
DirectSessionNotificationSender directSender,
|
||||||
ILogger<SendJoinLinkHandler> logger)
|
ILogger<SendJoinLinkHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||||
@@ -38,7 +41,8 @@ public sealed class SendJoinLinkHandler(
|
|||||||
var session = await connection.QuerySingleOrDefaultAsync<JoinLinkSession>(
|
var session = await connection.QuerySingleOrDefaultAsync<JoinLinkSession>(
|
||||||
"""
|
"""
|
||||||
SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt,
|
SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt,
|
||||||
g.telegram_chat_id AS TelegramChatId
|
g.telegram_chat_id AS TelegramChatId,
|
||||||
|
s.notification_mode AS NotificationMode
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @SessionId
|
WHERE s.id = @SessionId
|
||||||
@@ -63,8 +67,14 @@ public sealed class SendJoinLinkHandler(
|
|||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
WHERE sp.session_id = @SessionId
|
WHERE sp.session_id = @SessionId
|
||||||
AND sp.rsvp_status = @Confirmed
|
AND sp.rsvp_status = @Confirmed
|
||||||
|
AND sp.registration_status = @Active
|
||||||
""",
|
""",
|
||||||
new { SessionId = sessionId, Confirmed = RsvpStatus.Confirmed })).ToList();
|
new
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
Confirmed = RsvpStatus.Confirmed,
|
||||||
|
Active = ParticipantRegistrationStatus.Active
|
||||||
|
})).ToList();
|
||||||
|
|
||||||
// 3. Build message with player mentions
|
// 3. Build message with player mentions
|
||||||
var mentions = string.Join(", ", players.Select(p =>
|
var mentions = string.Join(", ", players.Select(p =>
|
||||||
@@ -96,6 +106,24 @@ public sealed class SendJoinLinkHandler(
|
|||||||
""",
|
""",
|
||||||
new { SessionId = sessionId, MessageId = message.MessageId });
|
new { SessionId = sessionId, MessageId = message.MessageId });
|
||||||
|
|
||||||
|
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
||||||
|
if (mode.ShouldSendDirectMessages())
|
||||||
|
{
|
||||||
|
var directText = $"""
|
||||||
|
🎮 <b>Игра начинается через 5 минут</b>
|
||||||
|
|
||||||
|
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
|
||||||
|
🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)}
|
||||||
|
""";
|
||||||
|
|
||||||
|
await directSender.SendAsync(
|
||||||
|
players.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)),
|
||||||
|
directText,
|
||||||
|
"join-link",
|
||||||
|
sessionId,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Join link sent for session {SessionId} ({Title}), message_id={MessageId}",
|
"Join link sent for session {SessionId} ({Title}), message_id={MessageId}",
|
||||||
sessionId, session.Title, message.MessageId);
|
sessionId, session.Title, message.MessageId);
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Features.Notifications;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
||||||
|
|
||||||
|
internal sealed record OneHourReminderSession(
|
||||||
|
Guid Id,
|
||||||
|
string Title,
|
||||||
|
string JoinLink,
|
||||||
|
DateTime ScheduledAt,
|
||||||
|
string NotificationMode);
|
||||||
|
|
||||||
|
public sealed class SendOneHourReminderHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
DirectSessionNotificationSender directSender,
|
||||||
|
ILogger<SendOneHourReminderHandler> logger)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
|
var session = await connection.QuerySingleOrDefaultAsync<OneHourReminderSession>(
|
||||||
|
"""
|
||||||
|
SELECT id,
|
||||||
|
title,
|
||||||
|
join_link AS JoinLink,
|
||||||
|
scheduled_at AS ScheduledAt,
|
||||||
|
notification_mode AS NotificationMode
|
||||||
|
FROM sessions
|
||||||
|
WHERE id = @SessionId
|
||||||
|
AND status IN (@Confirmed, @ConfirmationSent)
|
||||||
|
AND one_hour_reminder_processed_at IS NULL
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
Confirmed = SessionStatus.Confirmed,
|
||||||
|
ConfirmationSent = SessionStatus.ConfirmationSent
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Session {SessionId} not eligible for one-hour reminder", sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var recipients = (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
|
||||||
|
AND sp.rsvp_status != @Declined
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
|
Declined = RsvpStatus.Declined
|
||||||
|
})).ToList();
|
||||||
|
|
||||||
|
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
||||||
|
if (mode.ShouldSendDirectMessages() && recipients.Count > 0)
|
||||||
|
{
|
||||||
|
var text = $"""
|
||||||
|
⏰ <b>Игра начнётся примерно через 1 час</b>
|
||||||
|
|
||||||
|
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
|
||||||
|
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
|
||||||
|
🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)}
|
||||||
|
""";
|
||||||
|
|
||||||
|
await directSender.SendAsync(recipients, text, "one-hour-reminder", session.Id, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET one_hour_reminder_processed_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = @SessionId
|
||||||
|
AND one_hour_reminder_processed_at IS NULL
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId });
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"One-hour reminder processed for session {SessionId} ({Title}) with mode {NotificationMode}",
|
||||||
|
sessionId,
|
||||||
|
session.Title,
|
||||||
|
session.NotificationMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Features.Notifications;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
@@ -15,11 +16,12 @@ 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);
|
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, bool CanManage, string NotificationMode);
|
||||||
|
|
||||||
public sealed class CancelSessionHandler(
|
public sealed class CancelSessionHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
|
DirectSessionNotificationSender directSender,
|
||||||
ILogger<CancelSessionHandler> logger)
|
ILogger<CancelSessionHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(CancelSessionCommand command, CancellationToken ct)
|
public async Task HandleAsync(CancelSessionCommand command, CancellationToken ct)
|
||||||
@@ -27,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
|
"""
|
||||||
FROM sessions s
|
SELECT s.title AS Title,
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
s.batch_id AS BatchId,
|
||||||
WHERE s.id = @SessionId",
|
s.notification_mode AS NotificationMode,
|
||||||
new { command.SessionId }, transaction);
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
WHERE gm.group_id = s.group_id
|
||||||
|
AND p.telegram_id = @TelegramUserId
|
||||||
|
) AS CanManage
|
||||||
|
FROM sessions s
|
||||||
|
WHERE s.id = @SessionId
|
||||||
|
""",
|
||||||
|
new { command.SessionId, command.TelegramUserId }, transaction);
|
||||||
|
|
||||||
if (session == null)
|
if (session == null)
|
||||||
{
|
{
|
||||||
@@ -41,29 +53,51 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Отменяем сессию
|
// 2. Отменяем сессию
|
||||||
await connection.ExecuteAsync("UPDATE sessions SET status = 'Cancelled' WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
await connection.ExecuteAsync(
|
||||||
|
"UPDATE sessions SET status = @Status WHERE id = @Id",
|
||||||
|
new { Id = command.SessionId, Status = SessionStatus.Cancelled },
|
||||||
|
transaction);
|
||||||
|
|
||||||
// 3. Загружаем весь батч для перерисовки
|
// 3. Загружаем весь батч для перерисовки
|
||||||
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
||||||
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
@"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 { BatchId = session.BatchId }, transaction);
|
new { BatchId = session.BatchId }, transaction);
|
||||||
|
|
||||||
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
|
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
@"SELECT sp.session_id as SessionId, p.display_name as DisplayName, p.telegram_username as TelegramUsername
|
@"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
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
JOIN sessions s ON sp.session_id = s.id
|
JOIN sessions s ON sp.session_id = s.id
|
||||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||||
ORDER BY sp.responded_at ASC, p.created_at ASC",
|
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
|
||||||
new { BatchId = session.BatchId }, transaction);
|
new { BatchId = session.BatchId }, transaction);
|
||||||
|
|
||||||
|
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
|
||||||
|
"""
|
||||||
|
SELECT p.telegram_id AS TelegramId,
|
||||||
|
p.display_name AS DisplayName
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON sp.player_id = p.id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
|
""",
|
||||||
|
new { command.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
// 4. Перерисовываем сообщение
|
// 4. Перерисовываем сообщение
|
||||||
@@ -83,6 +117,17 @@ public sealed class CancelSessionHandler(
|
|||||||
|
|
||||||
// Опционально: написать отдельное сообщение в чат
|
// Опционально: написать отдельное сообщение в чат
|
||||||
await bot.SendMessage(command.ChatId, $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct);
|
await bot.SendMessage(command.ChatId, $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct);
|
||||||
|
|
||||||
|
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
||||||
|
if (mode.ShouldSendDirectMessages())
|
||||||
|
{
|
||||||
|
await directSender.SendAsync(
|
||||||
|
directRecipients,
|
||||||
|
$"❌ <b>Сессия отменена</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>",
|
||||||
|
"session-cancelled",
|
||||||
|
command.SessionId,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
using System.Text.RegularExpressions;
|
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -16,49 +16,55 @@ public sealed class CreateSessionHandler(
|
|||||||
{
|
{
|
||||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var text = message.Text ?? "";
|
var parseResult = NewSessionCommandParser.Parse(message.Text, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
string? title = null;
|
|
||||||
string? link = null;
|
|
||||||
var scheduledTimes = new List<DateTimeOffset>();
|
|
||||||
|
|
||||||
foreach (var line in text.Split('\n'))
|
foreach (var timeInput in parseResult.PastTimeInputs)
|
||||||
{
|
{
|
||||||
var trimmed = line.Trim();
|
await botClient.SendMessage(
|
||||||
if (trimmed.StartsWith("Название:", StringComparison.OrdinalIgnoreCase))
|
message.Chat.Id,
|
||||||
title = trimmed["Название:".Length..].Trim();
|
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
|
||||||
else if (trimmed.StartsWith("Ссылка:", StringComparison.OrdinalIgnoreCase))
|
cancellationToken: cancellationToken);
|
||||||
link = trimmed["Ссылка:".Length..].Trim();
|
|
||||||
else if (trimmed.StartsWith("Время:", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var timeStr = trimmed["Время:".Length..].Trim();
|
|
||||||
if (MoscowTime.TryParseMoscow(timeStr, out var scheduledAt))
|
|
||||||
{
|
|
||||||
if (scheduledAt > DateTimeOffset.UtcNow)
|
|
||||||
scheduledTimes.Add(scheduledAt);
|
|
||||||
else
|
|
||||||
await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Дата {timeStr} находится в прошлом и будет пропущена.", cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Некорректный формат времени '{timeStr}'. Пропущено.", cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(link) || scheduledTimes.Count == 0)
|
foreach (var timeInput in parseResult.InvalidTimeInputs)
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
message.Chat.Id,
|
||||||
|
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
message.Chat.Id,
|
||||||
|
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
message.Chat.Id,
|
||||||
|
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parseResult.IsValid)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
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Ссылка: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var title = parseResult.Title!;
|
||||||
|
var link = parseResult.Link!;
|
||||||
var gmId = message.From!.Id;
|
var gmId = message.From!.Id;
|
||||||
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}");
|
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
|
||||||
var gmUsername = message.From.Username;
|
var gmUsername = message.From.Username;
|
||||||
|
|
||||||
var chatId = message.Chat.Id;
|
var chatId = message.Chat.Id;
|
||||||
var chatTitle = message.Chat.Title ?? "Private Chat";
|
var chatTitle = message.Chat.Title ?? "Private Chat";
|
||||||
|
|
||||||
@@ -67,23 +73,75 @@ public sealed class CreateSessionHandler(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. Убеждаемся, что GM зарегистрирован
|
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
@"INSERT INTO players (telegram_id, display_name, telegram_username)
|
"""
|
||||||
VALUES (@TgId, @Name, @Username)
|
INSERT INTO players (telegram_id, display_name, telegram_username)
|
||||||
ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = EXCLUDED.telegram_username;",
|
VALUES (@TgId, @Name, @Username)
|
||||||
|
ON CONFLICT (telegram_id) DO UPDATE
|
||||||
|
SET display_name = EXCLUDED.display_name,
|
||||||
|
telegram_username = EXCLUDED.telegram_username;
|
||||||
|
""",
|
||||||
new { TgId = gmId, Name = gmName, Username = gmUsername },
|
new { TgId = gmId, Name = gmName, Username = gmUsername },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
// 2. Убеждаемся, что Группа зарегистрирована
|
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
||||||
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
"""
|
||||||
@"INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
|
SELECT g.id AS GroupId,
|
||||||
VALUES (@ChatId, @ChatName, @GmId)
|
EXISTS (
|
||||||
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
|
SELECT 1
|
||||||
RETURNING id;",
|
FROM group_managers gm
|
||||||
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
|
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);
|
transaction);
|
||||||
|
|
||||||
|
Guid groupId;
|
||||||
|
if (existingGroup is null)
|
||||||
|
{
|
||||||
|
groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
|
"""
|
||||||
|
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
|
||||||
|
VALUES (@ChatId, @ChatName, @GmId)
|
||||||
|
RETURNING id;
|
||||||
|
""",
|
||||||
|
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO group_managers (group_id, player_id, role)
|
||||||
|
SELECT @GroupId, p.id, @OwnerRole
|
||||||
|
FROM players p
|
||||||
|
WHERE p.telegram_id = @GmId
|
||||||
|
ON CONFLICT (group_id, player_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
new { GroupId = groupId, GmId = gmId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!existingGroup.CanManage)
|
||||||
|
{
|
||||||
|
await 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)
|
||||||
{
|
{
|
||||||
@@ -94,29 +152,38 @@ public sealed class CreateSessionHandler(
|
|||||||
messageThreadId = topic.MessageThreadId;
|
messageThreadId = topic.MessageThreadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Создаем сессии в цикле с общим batch_id
|
|
||||||
var batchId = Guid.NewGuid();
|
var batchId = Guid.NewGuid();
|
||||||
var sessions = new List<SessionBatchDto>();
|
var sessions = new List<SessionBatchDto>();
|
||||||
|
|
||||||
foreach (var dt in scheduledTimes.OrderBy(d => d))
|
foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value))
|
||||||
{
|
{
|
||||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id)
|
"""
|
||||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, 'Planned', @ThreadId)
|
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players)
|
||||||
RETURNING id;",
|
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @MaxPlayers)
|
||||||
new { BatchId = batchId, GroupId = groupId, Title = title, Link = link, ScheduledAt = dt, ThreadId = messageThreadId },
|
RETURNING id;
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
BatchId = batchId,
|
||||||
|
GroupId = groupId,
|
||||||
|
Title = title,
|
||||||
|
Link = link,
|
||||||
|
ScheduledAt = scheduledAt,
|
||||||
|
ThreadId = messageThreadId,
|
||||||
|
MaxPlayers = parseResult.MaxPlayers,
|
||||||
|
Status = SessionStatus.Planned
|
||||||
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
sessions.Add(new SessionBatchDto(sessionId, dt.UtcDateTime, "Planned"));
|
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers));
|
||||||
}
|
}
|
||||||
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
|
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
|
||||||
|
|
||||||
// 4. Отправляем сообщение в чат
|
|
||||||
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
|
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||||
|
|
||||||
|
|
||||||
var batchMessage = await botClient.SendMessage(
|
var batchMessage = await botClient.SendMessage(
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
messageThreadId: messageThreadId,
|
messageThreadId: messageThreadId,
|
||||||
@@ -125,12 +192,10 @@ public sealed class CreateSessionHandler(
|
|||||||
replyMarkup: renderResult.Markup,
|
replyMarkup: renderResult.Markup,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
// 4b. Сохраняем message_id батч-сообщения для дальнейшего редактирования
|
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
|
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
|
||||||
new { MsgId = batchMessage.MessageId, BatchId = batchId });
|
new { MsgId = batchMessage.MessageId, BatchId = batchId });
|
||||||
|
|
||||||
// 5. Удаляем исходное сообщение с командой /newsession, чтобы не спамить
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await botClient.DeleteMessage(
|
await botClient.DeleteMessage(
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using Dapper;
|
|||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
@@ -18,7 +17,7 @@ public sealed record JoinSessionCommand(
|
|||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
// DTOs for AOT compilation
|
// DTOs for AOT compilation
|
||||||
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title);
|
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxPlayers);
|
||||||
|
|
||||||
public sealed class JoinSessionHandler(
|
public sealed class JoinSessionHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
@@ -29,6 +28,7 @@ public sealed class JoinSessionHandler(
|
|||||||
{
|
{
|
||||||
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);
|
||||||
|
var transactionCommitted = false;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -41,12 +41,68 @@ public sealed class JoinSessionHandler(
|
|||||||
new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername },
|
new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
// 2. Добавляем в участники сессии (статус Pending, так как за 24 часа нужно будет финальное подтверждение)
|
// 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав.
|
||||||
|
var batchInfo = await connection.QuerySingleOrDefaultAsync<JoinSessionBatchDto>(
|
||||||
|
@"SELECT batch_id as BatchId, title as Title, max_players as MaxPlayers
|
||||||
|
FROM sessions
|
||||||
|
WHERE id = @SessionId
|
||||||
|
FOR UPDATE",
|
||||||
|
new { command.SessionId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (batchInfo is null)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
|
||||||
|
"""
|
||||||
|
SELECT sp.registration_status
|
||||||
|
FROM session_participants sp
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND sp.player_id = @PlayerId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
""",
|
||||||
|
new { command.SessionId, PlayerId = playerId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (existingRegistrationStatus is not null)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||||
|
? "Вы уже в листе ожидания!"
|
||||||
|
: "Вы уже записаны!";
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, alreadyText, cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeParticipants = await connection.ExecuteScalarAsync<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM session_participants
|
||||||
|
WHERE session_id = @SessionId
|
||||||
|
AND is_gm = false
|
||||||
|
AND registration_status = @Active
|
||||||
|
""",
|
||||||
|
new { command.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
var registrationStatus = SessionCapacityRules.DecideJoinStatus(batchInfo.MaxPlayers, activeParticipants);
|
||||||
|
|
||||||
|
// 3. Добавляем в основной состав или лист ожидания. RSVP остается Pending до финального подтверждения.
|
||||||
var inserted = await connection.ExecuteAsync(
|
var inserted = await connection.ExecuteAsync(
|
||||||
@"INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status)
|
@"INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status, registration_status)
|
||||||
VALUES (@SessionId, @PlayerId, false, 'Pending')
|
VALUES (@SessionId, @PlayerId, false, @Pending, @RegistrationStatus)
|
||||||
ON CONFLICT (session_id, player_id) DO NOTHING;",
|
ON CONFLICT (session_id, player_id) DO NOTHING;",
|
||||||
new { SessionId = command.SessionId, PlayerId = playerId },
|
new
|
||||||
|
{
|
||||||
|
command.SessionId,
|
||||||
|
PlayerId = playerId,
|
||||||
|
Pending = RsvpStatus.Pending,
|
||||||
|
RegistrationStatus = registrationStatus
|
||||||
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
if (inserted == 0)
|
if (inserted == 0)
|
||||||
@@ -56,26 +112,28 @@ public sealed class JoinSessionHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Получаем batch_id по session_id
|
|
||||||
var batchInfo = await connection.QuerySingleAsync<JoinSessionBatchDto>(
|
|
||||||
@"SELECT batch_id as BatchId, title as Title FROM sessions WHERE id = @SessionId",
|
|
||||||
new { command.SessionId }, transaction);
|
|
||||||
|
|
||||||
// Загружаем весь батч для перерисовки
|
// Загружаем весь батч для перерисовки
|
||||||
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
||||||
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
@"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 { BatchId = batchInfo.BatchId }, transaction);
|
new { BatchId = batchInfo.BatchId }, transaction);
|
||||||
|
|
||||||
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
|
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
@"SELECT sp.session_id as SessionId, p.display_name as DisplayName, p.telegram_username as TelegramUsername
|
@"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
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
JOIN sessions s ON sp.session_id = s.id
|
JOIN sessions s ON sp.session_id = s.id
|
||||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||||
ORDER BY sp.responded_at ASC, p.created_at ASC",
|
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
|
||||||
new { BatchId = batchInfo.BatchId }, transaction);
|
new { BatchId = batchInfo.BatchId }, transaction);
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
transactionCommitted = true;
|
||||||
|
|
||||||
// 4. Перерисовываем сообщение
|
// 4. Перерисовываем сообщение
|
||||||
var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||||
@@ -88,13 +146,23 @@ public sealed class JoinSessionHandler(
|
|||||||
replyMarkup: renderResult.Markup,
|
replyMarkup: renderResult.Markup,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
|
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы успешно записаны!", cancellationToken: ct);
|
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||||
|
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
||||||
|
: "Вы успешно записаны!";
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Ошибка при добавлении игрока к сессии");
|
logger.LogError(ex, "Ошибка при добавлении игрока к сессии");
|
||||||
await transaction.RollbackAsync(ct);
|
if (!transactionCommitted)
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Произошла ошибка при регистрации.", cancellationToken: ct);
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorText = transactionCommitted
|
||||||
|
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
|
||||||
|
: "Произошла ошибка при регистрации.";
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed record LeaveSessionCommand(
|
||||||
|
Guid SessionId,
|
||||||
|
long TelegramUserId,
|
||||||
|
string CallbackQueryId,
|
||||||
|
long ChatId,
|
||||||
|
int MessageId);
|
||||||
|
|
||||||
|
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
|
||||||
|
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
|
||||||
|
internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string DisplayName);
|
||||||
|
|
||||||
|
public sealed class LeaveSessionHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
ILogger<LeaveSessionHandler> logger)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
var transactionCommitted = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var session = await connection.QuerySingleOrDefaultAsync<LeaveSessionInfoDto>(
|
||||||
|
"""
|
||||||
|
SELECT title AS Title,
|
||||||
|
batch_id AS BatchId,
|
||||||
|
status AS Status,
|
||||||
|
max_players AS MaxPlayers
|
||||||
|
FROM sessions
|
||||||
|
WHERE id = @SessionId
|
||||||
|
FOR UPDATE
|
||||||
|
""",
|
||||||
|
new { command.SessionId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SessionStatus.IsCancelled(session.Status))
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия уже отменена.", cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var participant = await connection.QuerySingleOrDefaultAsync<LeaveSessionParticipantDto>(
|
||||||
|
"""
|
||||||
|
SELECT sp.id AS ParticipantRowId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
sp.registration_status AS RegistrationStatus
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND p.telegram_id = @TelegramUserId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
FOR UPDATE OF sp
|
||||||
|
""",
|
||||||
|
new { command.SessionId, command.TelegramUserId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (participant is null)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы не записаны на эту сессию.", cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
DELETE FROM session_participants
|
||||||
|
WHERE id = @ParticipantRowId
|
||||||
|
""",
|
||||||
|
new { participant.ParticipantRowId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
var activeParticipantsAfterLeave = await connection.ExecuteScalarAsync<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM session_participants
|
||||||
|
WHERE session_id = @SessionId
|
||||||
|
AND is_gm = false
|
||||||
|
AND registration_status = @Active
|
||||||
|
""",
|
||||||
|
new { command.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
var waitlistedParticipants = await connection.ExecuteScalarAsync<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM session_participants
|
||||||
|
WHERE session_id = @SessionId
|
||||||
|
AND is_gm = false
|
||||||
|
AND registration_status = @Waitlisted
|
||||||
|
""",
|
||||||
|
new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
string? promotedDisplayName = null;
|
||||||
|
if (SessionCapacityRules.ShouldPromoteAfterParticipantLeaves(
|
||||||
|
participant.RegistrationStatus,
|
||||||
|
session.MaxPlayers,
|
||||||
|
activeParticipantsAfterLeave,
|
||||||
|
waitlistedParticipants))
|
||||||
|
{
|
||||||
|
var promoted = await connection.QuerySingleAsync<LeaveSessionPromotionDto>(
|
||||||
|
"""
|
||||||
|
SELECT sp.id AS ParticipantRowId,
|
||||||
|
p.display_name AS DisplayName
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Waitlisted
|
||||||
|
ORDER BY sp.created_at ASC, sp.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE OF sp
|
||||||
|
""",
|
||||||
|
new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE session_participants
|
||||||
|
SET registration_status = @Active,
|
||||||
|
rsvp_status = @Pending,
|
||||||
|
responded_at = NULL
|
||||||
|
WHERE id = @ParticipantRowId
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
promoted.ParticipantRowId,
|
||||||
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
|
Pending = RsvpStatus.Pending
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
promotedDisplayName = promoted.DisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { session.BatchId },
|
||||||
|
transaction)).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 { session.BatchId },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
transactionCommitted = true;
|
||||||
|
|
||||||
|
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
|
||||||
|
|
||||||
|
await bot.EditMessageText(
|
||||||
|
chatId: command.ChatId,
|
||||||
|
messageId: command.MessageId,
|
||||||
|
text: renderResult.Text,
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
replyMarkup: renderResult.Markup,
|
||||||
|
cancellationToken: ct);
|
||||||
|
|
||||||
|
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||||
|
? "Вы удалены из листа ожидания."
|
||||||
|
: promotedDisplayName is null
|
||||||
|
? "Вы отписались от сессии."
|
||||||
|
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
|
||||||
|
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, callbackText, cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Ошибка при самостоятельной отмене записи на сессию {SessionId}", command.SessionId);
|
||||||
|
if (!transactionCommitted)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorText = transactionCommitted
|
||||||
|
? "Запись снята, но не удалось обновить сообщение расписания."
|
||||||
|
: "Произошла ошибка при отмене записи.";
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
internal sealed record NewSessionParseResult(
|
||||||
|
string? Title,
|
||||||
|
string? Link,
|
||||||
|
int? MaxPlayers,
|
||||||
|
IReadOnlyList<DateTimeOffset> ScheduledTimes,
|
||||||
|
IReadOnlyList<string> PastTimeInputs,
|
||||||
|
IReadOnlyList<string> InvalidTimeInputs,
|
||||||
|
IReadOnlyList<string> InvalidSeatLimitInputs,
|
||||||
|
IReadOnlyList<string> InvalidRecurringInputs)
|
||||||
|
{
|
||||||
|
public bool IsValid =>
|
||||||
|
!string.IsNullOrWhiteSpace(Title) &&
|
||||||
|
!string.IsNullOrWhiteSpace(Link) &&
|
||||||
|
ScheduledTimes.Count > 0 &&
|
||||||
|
InvalidSeatLimitInputs.Count == 0 &&
|
||||||
|
InvalidRecurringInputs.Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class NewSessionCommandParser
|
||||||
|
{
|
||||||
|
private const int MaxRecurringSessionCount = 52;
|
||||||
|
private const int MaxRecurringIntervalDays = 365;
|
||||||
|
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
|
||||||
|
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
|
||||||
|
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
|
||||||
|
private static readonly string[] SeatLimitPrefixes =
|
||||||
|
[
|
||||||
|
"\u041c\u0435\u0441\u0442:",
|
||||||
|
"\u041b\u0438\u043c\u0438\u0442:",
|
||||||
|
"\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:"
|
||||||
|
];
|
||||||
|
private static readonly string[] RecurringCountPrefixes =
|
||||||
|
[
|
||||||
|
"\u0418\u0433\u0440:",
|
||||||
|
"\u0421\u0435\u0441\u0441\u0438\u0439:",
|
||||||
|
"\u041f\u043e\u0432\u0442\u043e\u0440\u043e\u0432:"
|
||||||
|
];
|
||||||
|
private static readonly string[] RecurringIntervalPrefixes =
|
||||||
|
[
|
||||||
|
"\u0428\u0430\u0433:",
|
||||||
|
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b:"
|
||||||
|
];
|
||||||
|
|
||||||
|
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
|
||||||
|
{
|
||||||
|
string? title = null;
|
||||||
|
string? link = null;
|
||||||
|
int? maxPlayers = null;
|
||||||
|
int? recurringCount = null;
|
||||||
|
var recurringIntervalDays = 7;
|
||||||
|
var scheduledTimes = new List<DateTimeOffset>();
|
||||||
|
var pastTimeInputs = new List<string>();
|
||||||
|
var invalidTimeInputs = new List<string>();
|
||||||
|
var invalidSeatLimitInputs = new List<string>();
|
||||||
|
var invalidRecurringInputs = new List<string>();
|
||||||
|
|
||||||
|
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
if (line.StartsWith(TitlePrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
title = line[TitlePrefix.Length..].Trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.StartsWith(LinkPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
link = line[LinkPrefix.Length..].Trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix =>
|
||||||
|
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (seatLimitPrefix is not null)
|
||||||
|
{
|
||||||
|
var seatLimitInput = line[seatLimitPrefix.Length..].Trim();
|
||||||
|
if (int.TryParse(seatLimitInput, out var parsedMaxPlayers) && parsedMaxPlayers > 0)
|
||||||
|
{
|
||||||
|
maxPlayers = parsedMaxPlayers;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
invalidSeatLimitInputs.Add(seatLimitInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var recurringCountPrefix = RecurringCountPrefixes.FirstOrDefault(prefix =>
|
||||||
|
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (recurringCountPrefix is not null)
|
||||||
|
{
|
||||||
|
var recurringInput = line[recurringCountPrefix.Length..].Trim();
|
||||||
|
if (int.TryParse(recurringInput, out var parsedCount) &&
|
||||||
|
parsedCount is >= 1 and <= MaxRecurringSessionCount)
|
||||||
|
{
|
||||||
|
recurringCount = parsedCount;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
invalidRecurringInputs.Add(recurringInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var recurringIntervalPrefix = RecurringIntervalPrefixes.FirstOrDefault(prefix =>
|
||||||
|
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (recurringIntervalPrefix is not null)
|
||||||
|
{
|
||||||
|
var recurringInput = line[recurringIntervalPrefix.Length..].Trim();
|
||||||
|
if (int.TryParse(recurringInput, out var parsedInterval) &&
|
||||||
|
parsedInterval is >= 1 and <= MaxRecurringIntervalDays)
|
||||||
|
{
|
||||||
|
recurringIntervalDays = parsedInterval;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
invalidRecurringInputs.Add(recurringInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeInput = line[TimePrefix.Length..].Trim();
|
||||||
|
if (!MoscowTime.TryParseMoscow(timeInput, out var scheduledAt))
|
||||||
|
{
|
||||||
|
invalidTimeInputs.Add(timeInput);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheduledAt <= nowUtc)
|
||||||
|
{
|
||||||
|
pastTimeInputs.Add(timeInput);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduledTimes.Add(scheduledAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurringCount.HasValue && scheduledTimes.Count == 1)
|
||||||
|
{
|
||||||
|
var firstScheduledTime = scheduledTimes[0];
|
||||||
|
scheduledTimes = Enumerable.Range(0, recurringCount.Value)
|
||||||
|
.Select(index => firstScheduledTime.AddDays(recurringIntervalDays * index))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NewSessionParseResult(
|
||||||
|
title,
|
||||||
|
link,
|
||||||
|
maxPlayers,
|
||||||
|
scheduledTimes,
|
||||||
|
pastTimeInputs,
|
||||||
|
invalidTimeInputs,
|
||||||
|
invalidSeatLimitInputs,
|
||||||
|
invalidRecurringInputs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed record PromoteWaitlistedPlayerCommand(
|
||||||
|
Guid SessionId,
|
||||||
|
long TelegramUserId,
|
||||||
|
string CallbackQueryId,
|
||||||
|
long ChatId,
|
||||||
|
int MessageId);
|
||||||
|
|
||||||
|
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, bool CanManage, int? MaxPlayers);
|
||||||
|
internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName);
|
||||||
|
|
||||||
|
public sealed class PromoteWaitlistedPlayerHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
ILogger<PromoteWaitlistedPlayerHandler> logger)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
var transactionCommitted = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var session = await connection.QuerySingleOrDefaultAsync<PromoteWaitlistSessionDto>(
|
||||||
|
"""
|
||||||
|
SELECT s.title AS Title,
|
||||||
|
s.batch_id AS BatchId,
|
||||||
|
s.max_players AS MaxPlayers,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
WHERE gm.group_id = s.group_id
|
||||||
|
AND p.telegram_id = @TelegramUserId
|
||||||
|
) AS CanManage
|
||||||
|
FROM sessions s
|
||||||
|
WHERE s.id = @SessionId
|
||||||
|
FOR UPDATE
|
||||||
|
""",
|
||||||
|
new { command.SessionId, command.TelegramUserId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.CanManage)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeParticipants = await connection.ExecuteScalarAsync<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM session_participants
|
||||||
|
WHERE session_id = @SessionId
|
||||||
|
AND is_gm = false
|
||||||
|
AND registration_status = @Active
|
||||||
|
""",
|
||||||
|
new { command.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
var waitlistedParticipants = await connection.ExecuteScalarAsync<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM session_participants
|
||||||
|
WHERE session_id = @SessionId
|
||||||
|
AND is_gm = false
|
||||||
|
AND registration_status = @Waitlisted
|
||||||
|
""",
|
||||||
|
new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (waitlistedParticipants == 0)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Лист ожидания пуст.", cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", showAlert: true, cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var promoted = await connection.QuerySingleAsync<WaitlistedParticipantDto>(
|
||||||
|
"""
|
||||||
|
SELECT sp.id AS ParticipantRowId,
|
||||||
|
p.display_name AS DisplayName
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Waitlisted
|
||||||
|
ORDER BY sp.created_at ASC, sp.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE OF sp
|
||||||
|
""",
|
||||||
|
new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE session_participants
|
||||||
|
SET registration_status = @Active,
|
||||||
|
rsvp_status = @Pending,
|
||||||
|
responded_at = NULL
|
||||||
|
WHERE id = @ParticipantRowId
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
promoted.ParticipantRowId,
|
||||||
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
|
Pending = RsvpStatus.Pending
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
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 { session.BatchId },
|
||||||
|
transaction)).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 { session.BatchId },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
transactionCommitted = true;
|
||||||
|
|
||||||
|
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
|
||||||
|
|
||||||
|
await bot.EditMessageText(
|
||||||
|
chatId: command.ChatId,
|
||||||
|
messageId: command.MessageId,
|
||||||
|
text: renderResult.Text,
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
replyMarkup: renderResult.Markup,
|
||||||
|
cancellationToken: ct);
|
||||||
|
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Ошибка при повышении игрока из листа ожидания для сессии {SessionId}", command.SessionId);
|
||||||
|
if (!transactionCommitted)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorText = transactionCommitted
|
||||||
|
? "Игрок повышен, но не удалось обновить сообщение расписания."
|
||||||
|
: "Ошибка при обновлении листа ожидания.";
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
@@ -21,10 +22,10 @@ public sealed class ExportCalendarHandler(
|
|||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
JOIN game_groups g ON s.group_id = g.id
|
||||||
WHERE g.telegram_chat_id = @ChatId
|
WHERE g.telegram_chat_id = @ChatId
|
||||||
AND s.status = 'Planned'
|
AND s.status = @Planned
|
||||||
AND s.scheduled_at > NOW()
|
AND s.scheduled_at > NOW()
|
||||||
ORDER BY s.scheduled_at ASC",
|
ORDER BY s.scheduled_at ASC",
|
||||||
new { ChatId = message.Chat.Id });
|
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
|
||||||
|
|
||||||
var sessionsList = sessions.ToList();
|
var sessionsList = sessions.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
"""
|
||||||
FROM sessions s
|
SELECT s.title AS Title,
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
s.batch_id AS BatchId,
|
||||||
WHERE s.id = @SessionId",
|
s.thread_id AS ThreadId,
|
||||||
new { command.SessionId }, transaction);
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
WHERE gm.group_id = s.group_id
|
||||||
|
AND p.telegram_id = @TelegramUserId
|
||||||
|
) AS CanManage
|
||||||
|
FROM sessions s
|
||||||
|
WHERE s.id = @SessionId
|
||||||
|
""",
|
||||||
|
new { command.SessionId, command.TelegramUserId }, transaction);
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,16 +84,30 @@ public sealed class DeleteSessionHandler(
|
|||||||
// A simple way is to re-render the list:
|
// A simple way is to re-render the list:
|
||||||
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
|
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
|
||||||
var sessions = await readConnection.QueryAsync<SessionListItemDto>(
|
var sessions = await readConnection.QueryAsync<SessionListItemDto>(
|
||||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
|
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false) as PlayerCount,
|
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
||||||
g.gm_telegram_id as GmId
|
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||||
|
WHERE gm.group_id = s.group_id
|
||||||
|
AND manager_player.telegram_id = @TelegramUserId
|
||||||
|
) AS CanManage
|
||||||
FROM sessions s
|
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, 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 { ChatId = command.ChatId });
|
new
|
||||||
|
{
|
||||||
|
ChatId = command.ChatId,
|
||||||
|
command.TelegramUserId,
|
||||||
|
Cancelled = SessionStatus.Cancelled,
|
||||||
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
|
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||||
|
});
|
||||||
|
|
||||||
var sessionsList = sessions.ToList();
|
var sessionsList = sessions.ToList();
|
||||||
|
|
||||||
@@ -96,11 +120,15 @@ public sealed class DeleteSessionHandler(
|
|||||||
var text = "📅 <b>Ближайшие игры:</b>\n\n";
|
var text = "📅 <b>Ближайшие игры:</b>\n\n";
|
||||||
foreach (var s in sessionsList)
|
foreach (var s in sessionsList)
|
||||||
{
|
{
|
||||||
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Участников: {s.PlayerCount})\n";
|
var seats = s.MaxPlayers.HasValue
|
||||||
|
? $"{s.PlayerCount}/{s.MaxPlayers.Value}"
|
||||||
|
: s.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
var waitlist = s.WaitlistCount > 0 ? $", ожидание: {s.WaitlistCount}" : string.Empty;
|
||||||
|
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 PlayerCount, 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,
|
||||||
@@ -17,16 +17,30 @@ public sealed class ListSessionsHandler(
|
|||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
|
||||||
var sessions = await connection.QueryAsync<SessionListItemDto>(
|
var sessions = await connection.QueryAsync<SessionListItemDto>(
|
||||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
|
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false) as PlayerCount,
|
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
||||||
g.gm_telegram_id as GmId
|
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||||
|
WHERE gm.group_id = s.group_id
|
||||||
|
AND manager_player.telegram_id = @TelegramUserId
|
||||||
|
) AS CanManage
|
||||||
FROM sessions s
|
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, 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 { ChatId = message.Chat.Id });
|
new
|
||||||
|
{
|
||||||
|
ChatId = message.Chat.Id,
|
||||||
|
TelegramUserId = message.From?.Id,
|
||||||
|
Cancelled = SessionStatus.Cancelled,
|
||||||
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
|
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||||
|
});
|
||||||
|
|
||||||
var sessionsList = sessions.ToList();
|
var sessionsList = sessions.ToList();
|
||||||
|
|
||||||
@@ -42,11 +56,15 @@ public sealed class ListSessionsHandler(
|
|||||||
var text = "📅 <b>Ближайшие игры:</b>\n\n";
|
var text = "📅 <b>Ближайшие игры:</b>\n\n";
|
||||||
foreach (var s in sessionsList)
|
foreach (var s in sessionsList)
|
||||||
{
|
{
|
||||||
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Участников: {s.PlayerCount})\n";
|
var seats = s.MaxPlayers.HasValue
|
||||||
|
? $"{s.PlayerCount}/{s.MaxPlayers.Value}"
|
||||||
|
: s.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
var waitlist = s.WaitlistCount > 0 ? $", ожидание: {s.WaitlistCount}" : string.Empty;
|
||||||
|
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;
|
||||||
|
|||||||
+192
-47
@@ -1,4 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Features.Notifications;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
@@ -12,20 +13,26 @@ namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
|||||||
|
|
||||||
internal sealed record AwaitingProposalDto(
|
internal sealed record AwaitingProposalDto(
|
||||||
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
|
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
|
||||||
Guid BatchId, int? BatchMessageId, long TelegramChatId);
|
Guid BatchId, int? BatchMessageId, long TelegramChatId, string NotificationMode);
|
||||||
|
|
||||||
internal sealed record VoteParticipantDto(Guid PlayerId, string DisplayName, string? TelegramUsername);
|
internal sealed record VoteParticipantDto(
|
||||||
|
Guid PlayerId,
|
||||||
|
string DisplayName,
|
||||||
|
string? TelegramUsername,
|
||||||
|
long TelegramId = 0);
|
||||||
|
|
||||||
// ── Handler ──────────────────────────────────────────────────────────
|
// ── Handler ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <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(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
|
DirectSessionNotificationSender directSender,
|
||||||
ILogger<HandleRescheduleTimeInputHandler> logger)
|
ILogger<HandleRescheduleTimeInputHandler> logger)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -48,13 +55,21 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
"""
|
"""
|
||||||
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
||||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||||
g.telegram_chat_id AS TelegramChatId
|
g.telegram_chat_id AS TelegramChatId,
|
||||||
|
s.notification_mode AS NotificationMode
|
||||||
FROM reschedule_proposals rp
|
FROM reschedule_proposals rp
|
||||||
JOIN sessions s ON s.id = rp.session_id
|
JOIN sessions s ON s.id = rp.session_id
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE rp.proposed_by = @GmId
|
WHERE rp.proposed_by = @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
|
||||||
""",
|
""",
|
||||||
@@ -63,68 +78,85 @@ 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>(
|
||||||
"""
|
"""
|
||||||
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
|
SELECT p.id AS PlayerId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.telegram_username AS TelegramUsername,
|
||||||
|
p.telegram_id AS TelegramId
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
WHERE sp.session_id = @SessionId AND sp.is_gm = false
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
""",
|
""",
|
||||||
new { proposal.SessionId })).ToList();
|
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -133,12 +165,46 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
replyMarkup: keyboard,
|
replyMarkup: keyboard,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
|
|
||||||
|
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
|
||||||
|
if (mode.ShouldSendDirectMessages())
|
||||||
|
{
|
||||||
|
var optionsText = string.Join(
|
||||||
|
"\n",
|
||||||
|
options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
|
||||||
|
var directText = $"""
|
||||||
|
🔄 <b>Голосование за перенос сессии</b>
|
||||||
|
|
||||||
|
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
|
||||||
|
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
||||||
|
🗳 Варианты:
|
||||||
|
{optionsText}
|
||||||
|
|
||||||
|
⏳ Дедлайн: <b>{votingInput.Deadline.FormatMoscow()}</b> (МСК)
|
||||||
|
|
||||||
|
Проголосуйте кнопкой в групповом сообщении.
|
||||||
|
""";
|
||||||
|
|
||||||
|
await directSender.SendAsync(
|
||||||
|
participants.Select(p => new DirectNotificationRecipient(
|
||||||
|
p.TelegramId,
|
||||||
|
p.DisplayName)),
|
||||||
|
directText,
|
||||||
|
"reschedule-vote",
|
||||||
|
proposal.SessionId,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
// Store vote message ID
|
// Store vote message ID
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"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);
|
||||||
@@ -154,10 +220,14 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
|
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
UPDATE sessions SET scheduled_at = @NewTime, status = 'Planned', updated_at = now()
|
UPDATE sessions
|
||||||
|
SET scheduled_at = @NewTime,
|
||||||
|
status = @Status,
|
||||||
|
one_hour_reminder_processed_at = NULL,
|
||||||
|
updated_at = now()
|
||||||
WHERE id = @SessionId
|
WHERE id = @SessionId
|
||||||
""",
|
""",
|
||||||
new { NewTime = newTime, proposal.SessionId },
|
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
@@ -180,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
|
||||||
@@ -214,17 +356,20 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
var batchSessions = (await conn.QueryAsync<SessionBatchDto>(
|
var batchSessions = (await conn.QueryAsync<SessionBatchDto>(
|
||||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
"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();
|
new { proposal.BatchId })).ToList();
|
||||||
|
|
||||||
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
|
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
|
||||||
"""
|
"""
|
||||||
SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
|
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
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
JOIN sessions s ON sp.session_id = s.id
|
JOIN sessions s ON sp.session_id = s.id
|
||||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||||
ORDER BY sp.responded_at ASC, p.created_at ASC
|
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
|
||||||
""",
|
""",
|
||||||
new { proposal.BatchId })).ToList();
|
new { proposal.BatchId })).ToList();
|
||||||
|
|
||||||
|
|||||||
+97
-234
@@ -1,48 +1,24 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
|
||||||
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;
|
||||||
|
|
||||||
// ── Command ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public sealed record HandleRescheduleVoteCommand(
|
public sealed record HandleRescheduleVoteCommand(
|
||||||
Guid ProposalId,
|
Guid OptionId,
|
||||||
string Vote, // "yes" or "no"
|
|
||||||
long TelegramUserId,
|
long TelegramUserId,
|
||||||
string CallbackQueryId,
|
string CallbackQueryId,
|
||||||
long ChatId,
|
long ChatId,
|
||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
internal sealed record VoteCountDto(int Total, int Approved);
|
|
||||||
|
|
||||||
// ── Handler ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles "✅ Согласен" / "❌ Против" votes on a reschedule proposal.
|
|
||||||
///
|
|
||||||
/// If anyone votes no → proposal rejected, old time stays.
|
|
||||||
/// If all vote yes → session time updated, batch message re-rendered,
|
|
||||||
/// session status reset to Planned so confirmation triggers work correctly.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class HandleRescheduleVoteHandler(
|
public sealed class HandleRescheduleVoteHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
@@ -53,31 +29,40 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
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. Load proposal + session info
|
|
||||||
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
|
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
|
||||||
"""
|
"""
|
||||||
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.proposed_at AS ProposedAt,
|
SELECT rp.id AS Id,
|
||||||
s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
rp.session_id AS SessionId,
|
||||||
s.batch_id AS BatchId, s.status AS SessionStatus,
|
rp.voting_deadline_at AS VotingDeadlineAt,
|
||||||
s.confirmation_message_id AS ConfirmationMessageId,
|
s.title AS Title,
|
||||||
s.batch_message_id AS BatchMessageId,
|
s.scheduled_at AS CurrentScheduledAt
|
||||||
g.telegram_chat_id AS TelegramChatId
|
FROM reschedule_options ro
|
||||||
FROM reschedule_proposals rp
|
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
|
||||||
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)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
await bot.AnswerCallbackQuery(
|
||||||
"Голосование уже завершено или не найдено.", cancellationToken: ct);
|
command.CallbackQueryId,
|
||||||
|
"Голосование уже завершено или не найдено.",
|
||||||
|
cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
await bot.AnswerCallbackQuery(
|
||||||
|
command.CallbackQueryId,
|
||||||
|
"Дедлайн уже прошёл. Результаты скоро будут применены.",
|
||||||
|
showAlert: true,
|
||||||
|
cancellationToken: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Verify voter is a participant of this session
|
|
||||||
var playerId = await connection.ExecuteScalarAsync<Guid?>(
|
var playerId = await connection.ExecuteScalarAsync<Guid?>(
|
||||||
"""
|
"""
|
||||||
SELECT p.id
|
SELECT p.id
|
||||||
@@ -86,229 +71,107 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
WHERE sp.session_id = @SessionId
|
WHERE sp.session_id = @SessionId
|
||||||
AND p.telegram_id = @TelegramUserId
|
AND p.telegram_id = @TelegramUserId
|
||||||
AND sp.is_gm = false
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
""",
|
""",
|
||||||
new { proposal.SessionId, command.TelegramUserId },
|
new { proposal.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
if (playerId is null)
|
if (playerId is null)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
await bot.AnswerCallbackQuery(
|
||||||
"Вы не являетесь участником этой сессии.", cancellationToken: ct);
|
command.CallbackQueryId,
|
||||||
|
"Вы не являетесь участником этой сессии.",
|
||||||
|
cancellationToken: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Record vote (upsert)
|
await connection.ExecuteAsync(
|
||||||
var inserted = 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 SET vote = EXCLUDED.vote, voted_at = now()
|
ON CONFLICT (proposal_id, player_id) DO UPDATE
|
||||||
|
SET option_id = EXCLUDED.option_id,
|
||||||
|
voted_at = now()
|
||||||
""",
|
""",
|
||||||
new { command.ProposalId, PlayerId = playerId.Value, command.Vote },
|
new
|
||||||
|
{
|
||||||
|
ProposalId = proposal.Id,
|
||||||
|
PlayerId = playerId.Value,
|
||||||
|
command.OptionId
|
||||||
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
// 4. Handle "no" vote — immediately reject
|
|
||||||
if (command.Vote == "no")
|
|
||||||
{
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
|
|
||||||
new { Id = command.ProposalId },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
|
||||||
|
|
||||||
// Get voter's name
|
|
||||||
var voterName = await connection.QuerySingleOrDefaultAsync<string>(
|
|
||||||
"SELECT display_name FROM players WHERE telegram_id = @TgId",
|
|
||||||
new { TgId = command.TelegramUserId });
|
|
||||||
|
|
||||||
// Update voting message — show rejection
|
|
||||||
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, "Вы проголосовали против переноса.", cancellationToken: ct);
|
|
||||||
logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Handle "yes" vote — check if all approved
|
|
||||||
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
||||||
"""
|
"""
|
||||||
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
|
SELECT p.id AS PlayerId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.telegram_username AS TelegramUsername,
|
||||||
|
p.telegram_id AS TelegramId
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
WHERE sp.session_id = @SessionId AND sp.is_gm = false
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
|
ORDER BY p.display_name
|
||||||
""",
|
""",
|
||||||
new { proposal.SessionId },
|
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||||
transaction)).ToList();
|
transaction)).ToList();
|
||||||
|
|
||||||
var approvedPlayerIds = (await connection.QueryAsync<Guid>(
|
var options = (await connection.QueryAsync<RescheduleOptionDto>(
|
||||||
"""
|
"""
|
||||||
SELECT player_id FROM reschedule_votes
|
SELECT id AS OptionId,
|
||||||
WHERE proposal_id = @ProposalId AND vote = 'yes'
|
display_order AS DisplayOrder,
|
||||||
|
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 allApproved = approvedPlayerIds.Count == participants.Count;
|
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();
|
||||||
|
|
||||||
if (allApproved)
|
await transaction.CommitAsync(ct);
|
||||||
{
|
|
||||||
// 6. All approved — reschedule!
|
|
||||||
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero); // ProposedAt is stored in UTC
|
|
||||||
|
|
||||||
// Update session time and reset status to Planned for fresh notification cycle
|
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||||
await connection.ExecuteAsync(
|
proposal.Title,
|
||||||
"""
|
proposal.CurrentScheduledAt,
|
||||||
UPDATE sessions
|
proposal.VotingDeadlineAt,
|
||||||
SET scheduled_at = @NewTime,
|
options,
|
||||||
status = 'Planned',
|
participants,
|
||||||
confirmation_message_id = NULL,
|
votes);
|
||||||
link_message_id = NULL,
|
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
|
||||||
updated_at = now()
|
|
||||||
WHERE id = @SessionId
|
|
||||||
""",
|
|
||||||
new { NewTime = newTime, proposal.SessionId },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"UPDATE reschedule_proposals SET status = 'Approved' WHERE id = @Id",
|
|
||||||
new { Id = command.ProposalId },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
// Reset all participant RSVP to Pending for the new confirmation cycle
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
|
||||||
UPDATE session_participants
|
|
||||||
SET rsvp_status = 'Pending', responded_at = NULL
|
|
||||||
WHERE session_id = @SessionId AND is_gm = false
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
|
||||||
|
|
||||||
// Update voting message — show approval
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-render batch message
|
|
||||||
await TryUpdateBatchMessage(proposal, ct);
|
|
||||||
|
|
||||||
logger.LogInformation("Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
|
|
||||||
proposal.SessionId, newTime, command.ProposalId);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Not all voted yet — update the voting message to show progress
|
|
||||||
await transaction.CommitAsync(ct);
|
|
||||||
|
|
||||||
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
|
||||||
proposal.Title, proposal.CurrentScheduledAt,
|
|
||||||
new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero),
|
|
||||||
participants, approvedPlayerIds);
|
|
||||||
|
|
||||||
var keyboard = new InlineKeyboardMarkup([
|
|
||||||
[
|
|
||||||
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{command.ProposalId}"),
|
|
||||||
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{command.ProposalId}")
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await bot.EditMessageText(
|
|
||||||
chatId: command.ChatId,
|
|
||||||
messageId: command.MessageId,
|
|
||||||
text: voteText,
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: keyboard,
|
|
||||||
cancellationToken: ct);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Failed to update vote message with progress");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
|
||||||
allApproved ? "Вы подтвердили перенос! Все согласны — время обновлено." : "Вы подтвердили перенос!",
|
|
||||||
cancellationToken: ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Re-renders the batch schedule message to reflect the updated session time.
|
|
||||||
/// If batch_message_id is stored, edits the original message. Otherwise sends a notification.
|
|
||||||
/// </summary>
|
|
||||||
private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct)
|
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await bot.EditMessageText(
|
||||||
|
chatId: command.ChatId,
|
||||||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
messageId: command.MessageId,
|
||||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
text: voteText,
|
||||||
new { proposal.BatchId })).ToList();
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
replyMarkup: keyboard,
|
||||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
cancellationToken: ct);
|
||||||
"""
|
|
||||||
SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
|
|
||||||
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.responded_at ASC, p.created_at ASC
|
|
||||||
""",
|
|
||||||
new { proposal.BatchId })).ToList();
|
|
||||||
|
|
||||||
if (proposal.BatchMessageId.HasValue)
|
|
||||||
{
|
|
||||||
// Edit the original batch schedule message in-place
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Fallback for sessions created before V005 migration (no batch_message_id)
|
|
||||||
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to update batch message for proposal {ProposalId}", proposal.Id);
|
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await bot.AnswerCallbackQuery(
|
||||||
|
command.CallbackQueryId,
|
||||||
|
"Ваш голос учтён. До дедлайна его можно изменить.",
|
||||||
|
cancellationToken: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
|
|
||||||
@@ -15,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,
|
||||||
@@ -33,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 });
|
new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled });
|
||||||
|
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
@@ -49,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
internal enum RescheduleVoteOutcome
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Rejected,
|
||||||
|
Approved
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RescheduleVoteDecision(
|
||||||
|
RescheduleVoteOutcome Outcome,
|
||||||
|
string Reason,
|
||||||
|
Guid? SelectedOptionId = null,
|
||||||
|
string CallbackText = "",
|
||||||
|
bool ShouldRescheduleSession = false,
|
||||||
|
bool ShouldResetParticipantRsvps = false);
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (string.Equals(vote, "no", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new RescheduleVoteDecision(
|
||||||
|
Outcome: RescheduleVoteOutcome.Rejected,
|
||||||
|
Reason: "\u041e\u0434\u0438\u043d \u0438\u0437 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u043e\u0432 \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b \u043f\u0435\u0440\u0435\u043d\u043e\u0441.",
|
||||||
|
CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var everyoneApproved = approvedParticipants == totalParticipants;
|
||||||
|
|
||||||
|
return new RescheduleVoteDecision(
|
||||||
|
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending,
|
||||||
|
Reason: everyoneApproved
|
||||||
|
? "\u0412\u0441\u0435 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u0438 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b."
|
||||||
|
: "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0430\u0435\u0442\u0441\u044f.",
|
||||||
|
CallbackText: everyoneApproved
|
||||||
|
? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e."
|
||||||
|
: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!",
|
||||||
|
ShouldRescheduleSession: everyoneApproved,
|
||||||
|
ShouldResetParticipantRsvps: everyoneApproved);
|
||||||
|
}
|
||||||
|
}
|
||||||
+361
@@ -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);
|
||||||
@@ -2,6 +2,7 @@ using Dapper;
|
|||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||||
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||||
|
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Infrastructure.Scheduling;
|
namespace GmRelay.Bot.Infrastructure.Scheduling;
|
||||||
@@ -17,11 +18,13 @@ namespace GmRelay.Bot.Infrastructure.Scheduling;
|
|||||||
public sealed class SessionSchedulerService(
|
public sealed class SessionSchedulerService(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
SendConfirmationHandler confirmationHandler,
|
SendConfirmationHandler confirmationHandler,
|
||||||
|
SendOneHourReminderHandler oneHourReminderHandler,
|
||||||
SendJoinLinkHandler joinLinkHandler,
|
SendJoinLinkHandler joinLinkHandler,
|
||||||
ILogger<SessionSchedulerService> logger) : BackgroundService
|
ILogger<SessionSchedulerService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
||||||
private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24);
|
private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24);
|
||||||
|
private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1);
|
||||||
private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5);
|
private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
@@ -36,6 +39,7 @@ public sealed class SessionSchedulerService(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ProcessConfirmationTriggers(stoppingToken);
|
await ProcessConfirmationTriggers(stoppingToken);
|
||||||
|
await ProcessOneHourReminderTriggers(stoppingToken);
|
||||||
await ProcessJoinLinkTriggers(stoppingToken);
|
await ProcessJoinLinkTriggers(stoppingToken);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
@@ -52,6 +56,42 @@ public sealed class SessionSchedulerService(
|
|||||||
logger.LogInformation("Session scheduler stopped");
|
logger.LogInformation("Session scheduler stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// T-1h trigger: process direct reminders according to the session notification mode.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ProcessOneHourReminderTriggers(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
|
var sessionIds = await connection.QueryAsync<Guid>(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM sessions
|
||||||
|
WHERE status IN (@Confirmed, @ConfirmationSent)
|
||||||
|
AND scheduled_at - @LeadTime <= now()
|
||||||
|
AND one_hour_reminder_processed_at IS NULL
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Confirmed = SessionStatus.Confirmed,
|
||||||
|
ConfirmationSent = SessionStatus.ConfirmationSent,
|
||||||
|
LeadTime = OneHourReminderLeadTime
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (var sessionId in sessionIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await oneHourReminderHandler.HandleAsync(sessionId, ct);
|
||||||
|
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// T-24h trigger: find sessions that need confirmation requests sent.
|
/// T-24h trigger: find sessions that need confirmation requests sent.
|
||||||
/// Condition: status='Planned' AND scheduled_at minus 24h is in the past.
|
/// Condition: status='Planned' AND scheduled_at minus 24h is in the past.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ public sealed class UpdateRouter(
|
|||||||
HandleRsvpHandler rsvpHandler,
|
HandleRsvpHandler rsvpHandler,
|
||||||
CreateSessionHandler createSessionHandler,
|
CreateSessionHandler createSessionHandler,
|
||||||
JoinSessionHandler joinSessionHandler,
|
JoinSessionHandler joinSessionHandler,
|
||||||
|
LeaveSessionHandler leaveSessionHandler,
|
||||||
|
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
|
||||||
CancelSessionHandler cancelSessionHandler,
|
CancelSessionHandler cancelSessionHandler,
|
||||||
DeleteSessionHandler deleteSessionHandler,
|
DeleteSessionHandler deleteSessionHandler,
|
||||||
ListSessionsHandler listSessionsHandler,
|
ListSessionsHandler listSessionsHandler,
|
||||||
@@ -28,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)
|
||||||
@@ -72,6 +76,19 @@ public sealed class UpdateRouter(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action == "leave_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var leaveSessionId))
|
||||||
|
{
|
||||||
|
var command = new LeaveSessionCommand(
|
||||||
|
SessionId: leaveSessionId,
|
||||||
|
TelegramUserId: query.From.Id,
|
||||||
|
CallbackQueryId: query.Id,
|
||||||
|
ChatId: message.Chat.Id,
|
||||||
|
MessageId: message.MessageId);
|
||||||
|
|
||||||
|
await leaveSessionHandler.HandleAsync(command, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (action == "cancel_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var cancelSessionId))
|
if (action == "cancel_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var cancelSessionId))
|
||||||
{
|
{
|
||||||
var command = new CancelSessionCommand(
|
var command = new CancelSessionCommand(
|
||||||
@@ -85,6 +102,19 @@ public sealed class UpdateRouter(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action == "promote_waitlist" && parts.Length >= 2 && Guid.TryParse(parts[1], out var promoteSessionId))
|
||||||
|
{
|
||||||
|
var command = new PromoteWaitlistedPlayerCommand(
|
||||||
|
SessionId: promoteSessionId,
|
||||||
|
TelegramUserId: query.From.Id,
|
||||||
|
CallbackQueryId: query.Id,
|
||||||
|
ChatId: message.Chat.Id,
|
||||||
|
MessageId: message.MessageId);
|
||||||
|
|
||||||
|
await promoteWaitlistedPlayerHandler.HandleAsync(command, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (action == "delete_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var deleteSessionId))
|
if (action == "delete_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var deleteSessionId))
|
||||||
{
|
{
|
||||||
var command = new DeleteSessionCommand(
|
var command = new DeleteSessionCommand(
|
||||||
@@ -111,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,
|
||||||
@@ -165,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":
|
||||||
@@ -192,9 +214,16 @@ public sealed class UpdateRouter(
|
|||||||
/newsession
|
/newsession
|
||||||
Название: My Game
|
Название: My Game
|
||||||
Время: 15.05.2026 19:30
|
Время: 15.05.2026 19:30
|
||||||
|
Мест: 4
|
||||||
Ссылка: https://link
|
Ссылка: https://link
|
||||||
|
|
||||||
|
Для регулярного расписания можно указать одну дату:
|
||||||
|
Игр: 4
|
||||||
|
Интервал: 7
|
||||||
|
|
||||||
/listsessions — список предстоящих сессий
|
/listsessions — список предстоящих сессий
|
||||||
|
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
|
||||||
|
Owner и co-GM могут переносить сессии кнопкой «Перенести»: бот попросит 2-3 варианта времени и дедлайн голосования.
|
||||||
/help — эта справка
|
/help — эта справка
|
||||||
""",
|
""",
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
@@ -206,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,16 @@
|
|||||||
|
-- Add per-session seat limits and participant waitlist support.
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN max_players INTEGER,
|
||||||
|
ADD CONSTRAINT ck_sessions_max_players CHECK (max_players IS NULL OR max_players > 0);
|
||||||
|
|
||||||
|
ALTER TABLE session_participants
|
||||||
|
ADD COLUMN registration_status VARCHAR(50) NOT NULL DEFAULT 'Active'
|
||||||
|
CHECK (registration_status IN ('Active', 'Waitlisted')),
|
||||||
|
ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
CREATE INDEX ix_session_participants_session_registration_status
|
||||||
|
ON session_participants (session_id, registration_status);
|
||||||
|
|
||||||
|
CREATE INDEX ix_session_participants_waitlist_order
|
||||||
|
ON session_participants (session_id, created_at, id)
|
||||||
|
WHERE registration_status = 'Waitlisted';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN notification_mode VARCHAR(50) NOT NULL DEFAULT 'GroupAndDirect'
|
||||||
|
CHECK (notification_mode IN ('GroupAndDirect', 'GroupOnly')),
|
||||||
|
ADD COLUMN one_hour_reminder_processed_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE INDEX ix_sessions_one_hour_reminders ON sessions (scheduled_at)
|
||||||
|
WHERE status IN ('Confirmed', 'ConfirmationSent')
|
||||||
|
AND one_hour_reminder_processed_at IS NULL;
|
||||||
@@ -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);
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||||
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||||
|
using GmRelay.Bot.Features.Notifications;
|
||||||
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||||
|
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
using GmRelay.Bot.Infrastructure.Database;
|
using GmRelay.Bot.Infrastructure.Database;
|
||||||
@@ -50,10 +52,14 @@ builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
|
|||||||
|
|
||||||
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
||||||
builder.Services.AddSingleton<SendConfirmationHandler>();
|
builder.Services.AddSingleton<SendConfirmationHandler>();
|
||||||
|
builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
||||||
builder.Services.AddSingleton<HandleRsvpHandler>();
|
builder.Services.AddSingleton<HandleRsvpHandler>();
|
||||||
builder.Services.AddSingleton<SendJoinLinkHandler>();
|
builder.Services.AddSingleton<SendJoinLinkHandler>();
|
||||||
|
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
||||||
builder.Services.AddSingleton<CreateSessionHandler>();
|
builder.Services.AddSingleton<CreateSessionHandler>();
|
||||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||||
|
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||||
|
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
|
||||||
builder.Services.AddSingleton<CancelSessionHandler>();
|
builder.Services.AddSingleton<CancelSessionHandler>();
|
||||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
|
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
|
||||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>();
|
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>();
|
||||||
@@ -65,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();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Telegram": {
|
"Telegram": {
|
||||||
"BotToken": ""
|
"BotToken": "",
|
||||||
|
"MiniAppUrl": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.2.0" />
|
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.2.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.2.0" />
|
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.2.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0" />
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0" />
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
public static class ParticipantRegistrationStatus
|
||||||
|
{
|
||||||
|
public const string Active = "Active";
|
||||||
|
public const string Waitlisted = "Waitlisted";
|
||||||
|
|
||||||
|
public static readonly string[] All = [Active, Waitlisted];
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
namespace GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
public static class SessionCapacityRules
|
||||||
|
{
|
||||||
|
public static string DecideJoinStatus(int? maxPlayers, int activeParticipants)
|
||||||
|
{
|
||||||
|
if (!maxPlayers.HasValue)
|
||||||
|
{
|
||||||
|
return ParticipantRegistrationStatus.Active;
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeParticipants < maxPlayers.Value
|
||||||
|
? ParticipantRegistrationStatus.Active
|
||||||
|
: ParticipantRegistrationStatus.Waitlisted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool CanPromoteWaitlistedPlayer(int? maxPlayers, int activeParticipants, int waitlistedParticipants)
|
||||||
|
{
|
||||||
|
if (waitlistedParticipants <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !maxPlayers.HasValue || activeParticipants < maxPlayers.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ShouldPromoteAfterParticipantLeaves(
|
||||||
|
string removedRegistrationStatus,
|
||||||
|
int? maxPlayers,
|
||||||
|
int activeParticipantsAfterLeave,
|
||||||
|
int waitlistedParticipants)
|
||||||
|
{
|
||||||
|
return removedRegistrationStatus == ParticipantRegistrationStatus.Active
|
||||||
|
&& CanPromoteWaitlistedPlayer(maxPlayers, activeParticipantsAfterLeave, waitlistedParticipants);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
public enum SessionNotificationMode
|
||||||
|
{
|
||||||
|
GroupAndDirect,
|
||||||
|
GroupOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SessionNotificationModeExtensions
|
||||||
|
{
|
||||||
|
public const string GroupAndDirectValue = nameof(SessionNotificationMode.GroupAndDirect);
|
||||||
|
public const string GroupOnlyValue = nameof(SessionNotificationMode.GroupOnly);
|
||||||
|
|
||||||
|
public static bool ShouldSendDirectMessages(this SessionNotificationMode mode) =>
|
||||||
|
mode == SessionNotificationMode.GroupAndDirect;
|
||||||
|
|
||||||
|
public static string ToDatabaseValue(this SessionNotificationMode mode) =>
|
||||||
|
mode switch
|
||||||
|
{
|
||||||
|
SessionNotificationMode.GroupAndDirect => GroupAndDirectValue,
|
||||||
|
SessionNotificationMode.GroupOnly => GroupOnlyValue,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown notification mode.")
|
||||||
|
};
|
||||||
|
|
||||||
|
public static SessionNotificationMode FromDatabaseValue(string? value) =>
|
||||||
|
value switch
|
||||||
|
{
|
||||||
|
null or "" => SessionNotificationMode.GroupAndDirect,
|
||||||
|
GroupAndDirectValue => SessionNotificationMode.GroupAndDirect,
|
||||||
|
GroupOnlyValue => SessionNotificationMode.GroupOnly,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown notification mode.")
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
|
|
||||||
namespace GmRelay.Shared.Domain;
|
namespace GmRelay.Shared.Domain;
|
||||||
|
|
||||||
public static class SessionStatus
|
public static class SessionStatus
|
||||||
@@ -6,4 +8,13 @@ public static class SessionStatus
|
|||||||
public const string ConfirmationSent = "ConfirmationSent";
|
public const string ConfirmationSent = "ConfirmationSent";
|
||||||
public const string Confirmed = "Confirmed";
|
public const string Confirmed = "Confirmed";
|
||||||
public const string Cancelled = "Cancelled";
|
public const string Cancelled = "Cancelled";
|
||||||
|
|
||||||
|
public static IReadOnlySet<string> All { get; } =
|
||||||
|
new[] { Planned, ConfirmationSent, Confirmed, Cancelled }
|
||||||
|
.ToFrozenSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public static bool IsKnown(string status) => All.Contains(status);
|
||||||
|
|
||||||
|
public static bool IsCancelled(string status) =>
|
||||||
|
string.Equals(status, Cancelled, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ using Telegram.Bot.Types.ReplyMarkups;
|
|||||||
|
|
||||||
namespace GmRelay.Shared.Rendering;
|
namespace GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status);
|
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers);
|
||||||
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername);
|
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
|
||||||
|
|
||||||
public static class SessionBatchRenderer
|
public static class SessionBatchRenderer
|
||||||
{
|
{
|
||||||
@@ -22,10 +22,17 @@ public static class SessionBatchRenderer
|
|||||||
|
|
||||||
foreach (var session in activeSessions)
|
foreach (var session in activeSessions)
|
||||||
{
|
{
|
||||||
var sessionPlayers = participants.Where(p => p.SessionId == session.SessionId).ToList();
|
var sessionPlayers = participants
|
||||||
|
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Active)
|
||||||
|
.ToList();
|
||||||
|
var waitlistedPlayers = participants
|
||||||
|
.Where(p => p.SessionId == session.SessionId && p.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
||||||
messageText += $"👥 Игроки ({sessionPlayers.Count}):\n";
|
messageText += session.MaxPlayers.HasValue
|
||||||
|
? $"👥 Места: {sessionPlayers.Count}/{session.MaxPlayers.Value}\n"
|
||||||
|
: $"👥 Игроки ({sessionPlayers.Count}):\n";
|
||||||
|
|
||||||
if (sessionPlayers.Count > 0)
|
if (sessionPlayers.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -36,27 +43,48 @@ public static class SessionBatchRenderer
|
|||||||
messageText += " <i>Пока никто не записался</i>\n";
|
messageText += " <i>Пока никто не записался</i>\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.Status == "Cancelled")
|
if (waitlistedPlayers.Count > 0)
|
||||||
|
{
|
||||||
|
messageText += $"⏳ Лист ожидания ({waitlistedPlayers.Count}):\n";
|
||||||
|
messageText += string.Join("\n", waitlistedPlayers.Select(p => $" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SessionStatus.IsCancelled(session.Status))
|
||||||
{
|
{
|
||||||
messageText += "❌ <i>Сессия отменена</i>\n\n";
|
messageText += "❌ <i>Сессия отменена</i>\n\n";
|
||||||
}
|
}
|
||||||
else if (session.Status == "RecruitmentClosed")
|
|
||||||
{
|
|
||||||
messageText += "🔒 <i>Набор завершен</i>\n\n";
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
messageText += "\n";
|
messageText += "\n";
|
||||||
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
||||||
buttons.Add(new[]
|
buttons.Add(new[]
|
||||||
{
|
{
|
||||||
InlineKeyboardButton.WithCallbackData($"✋ На {dateTitle}", $"join_session:{session.SessionId}"),
|
InlineKeyboardButton.WithCallbackData(GetJoinButtonText(session, sessionPlayers.Count, dateTitle), $"join_session:{session.SessionId}"),
|
||||||
|
InlineKeyboardButton.WithCallbackData($"🚪 Выйти {dateTitle}", $"leave_session:{session.SessionId}")
|
||||||
|
});
|
||||||
|
|
||||||
|
buttons.Add(new[]
|
||||||
|
{
|
||||||
InlineKeyboardButton.WithCallbackData($"❌ Отменить {dateTitle} (ГМ)", $"cancel_session:{session.SessionId}"),
|
InlineKeyboardButton.WithCallbackData($"❌ Отменить {dateTitle} (ГМ)", $"cancel_session:{session.SessionId}"),
|
||||||
InlineKeyboardButton.WithCallbackData($"⏰ (ГМ)", $"reschedule_session:{session.SessionId}")
|
InlineKeyboardButton.WithCallbackData($"⏰ (ГМ)", $"reschedule_session:{session.SessionId}")
|
||||||
});
|
}
|
||||||
|
.Concat(SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, sessionPlayers.Count, waitlistedPlayers.Count)
|
||||||
|
? [InlineKeyboardButton.WithCallbackData($"⬆️ Из ожидания {dateTitle} (ГМ)", $"promote_waitlist:{session.SessionId}")]
|
||||||
|
: [])
|
||||||
|
.ToArray());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (messageText, new InlineKeyboardMarkup(buttons));
|
return (messageText, new InlineKeyboardMarkup(buttons));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetJoinButtonText(SessionBatchDto session, int activePlayers, string dateTitle)
|
||||||
|
{
|
||||||
|
if (session.MaxPlayers.HasValue && activePlayers >= session.MaxPlayers.Value)
|
||||||
|
{
|
||||||
|
return $"⏳ В лист ожидания {dateTitle}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"✋ На {dateTitle}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.1.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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,12 @@
|
|||||||
<InputText @bind-Value="model.JoinLink" class="gm-form-control" placeholder="Ссылка на Discord или VTT" />
|
<InputText @bind-Value="model.JoinLink" class="gm-form-control" placeholder="Ссылка на Discord или VTT" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Лимит мест</label>
|
||||||
|
<InputNumber @bind-Value="model.MaxPlayers" class="gm-form-control" min="1" placeholder="Без лимита" />
|
||||||
|
<div class="gm-form-hint">Пустое значение означает запись без лимита. Если лимит заполнен, новые игроки попадут в лист ожидания.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
||||||
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
|
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
|
||||||
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
|
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
|
||||||
@@ -97,6 +103,7 @@
|
|||||||
model.Title = session.Title;
|
model.Title = session.Title;
|
||||||
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
|
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
|
||||||
model.JoinLink = session.JoinLink;
|
model.JoinLink = session.JoinLink;
|
||||||
|
model.MaxPlayers = session.MaxPlayers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleSubmit()
|
private async Task HandleSubmit()
|
||||||
@@ -115,7 +122,7 @@
|
|||||||
|
|
||||||
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
||||||
|
|
||||||
await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink);
|
await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
|
||||||
Navigation.NavigateTo($"/group/{session!.GroupId}");
|
Navigation.NavigateTo($"/group/{session!.GroupId}");
|
||||||
}
|
}
|
||||||
catch (SessionAccessDeniedException)
|
catch (SessionAccessDeniedException)
|
||||||
@@ -139,5 +146,6 @@
|
|||||||
public string Title { get; set; } = "";
|
public string Title { get; set; } = "";
|
||||||
public DateTime ScheduledAtLocal { get; set; } = DateTime.Now;
|
public DateTime ScheduledAtLocal { get; set; } = DateTime.Now;
|
||||||
public string JoinLink { get; set; } = "";
|
public string JoinLink { get; set; } = "";
|
||||||
|
public int? MaxPlayers { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,112 @@
|
|||||||
<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))
|
||||||
|
{
|
||||||
|
<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 (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;">
|
||||||
@@ -41,6 +147,72 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
<div class="batch-bulk-grid animate-slide-up">
|
||||||
|
@foreach (var batch in batchModels)
|
||||||
|
{
|
||||||
|
<div class="batch-bulk-card">
|
||||||
|
<div class="batch-bulk-header">
|
||||||
|
<div>
|
||||||
|
<h3>@batch.Title</h3>
|
||||||
|
<p>@FormatBatchSummary(batch)</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge status-info">Batch</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="@batch" OnValidSubmit="@(() => UpdateBatchDetails(batch))">
|
||||||
|
<div class="batch-bulk-fields">
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Общее название</label>
|
||||||
|
<InputText @bind-Value="batch.Title" class="gm-form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Общая ссылка</label>
|
||||||
|
<InputText @bind-Value="batch.JoinLink" class="gm-form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Уведомления игрокам</label>
|
||||||
|
<select @bind="batch.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="@IsBatchBusy(batch)">
|
||||||
|
@(IsBatchBusy(batch) ? "⏳ Обновляем..." : "💾 Обновить batch")
|
||||||
|
</button>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<div class="batch-bulk-divider"></div>
|
||||||
|
|
||||||
|
<EditForm Model="@batch" OnValidSubmit="@(() => RescheduleBatch(batch))">
|
||||||
|
<div class="batch-bulk-fields">
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Первая дата пачки (МСК, UTC+3)</label>
|
||||||
|
<input type="datetime-local" @bind="batch.FirstScheduledAtLocal" class="gm-form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Шаг между играми, дней</label>
|
||||||
|
<InputNumber @bind-Value="batch.IntervalDays" class="gm-form-control" min="1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-gm btn-gm-outline" disabled="@IsBatchBusy(batch)">
|
||||||
|
@(IsBatchBusy(batch) ? "⏳ Переносим..." : "📅 Перенести пачку")
|
||||||
|
</button>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<div class="batch-clone-row">
|
||||||
|
<select @bind="batch.CloneInterval" class="gm-form-control">
|
||||||
|
<option value="week">Следующая неделя</option>
|
||||||
|
<option value="month">Следующий месяц</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn-gm btn-gm-success" disabled="@IsBatchBusy(batch)" @onclick="() => CloneBatch(batch)">
|
||||||
|
@(IsBatchBusy(batch) ? "⏳ Клонируем..." : "🧬 Клонировать")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
@* Desktop table *@
|
@* Desktop table *@
|
||||||
<div class="glass-card session-table-desktop animate-slide-up" style="padding: 0; overflow: hidden;">
|
<div class="glass-card session-table-desktop animate-slide-up" style="padding: 0; overflow: hidden;">
|
||||||
<table class="gm-table">
|
<table class="gm-table">
|
||||||
@@ -48,6 +220,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Название</th>
|
<th>Название</th>
|
||||||
<th>Время (МСК)</th>
|
<th>Время (МСК)</th>
|
||||||
|
<th>Места</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
<th>Ссылка</th>
|
<th>Ссылка</th>
|
||||||
<th>Действие</th>
|
<th>Действие</th>
|
||||||
@@ -59,6 +232,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="color: var(--text-primary); font-weight: 500;">@session.Title</td>
|
<td style="color: var(--text-primary); font-weight: 500;">@session.Title</td>
|
||||||
<td>@session.ScheduledAt.FormatMoscow()</td>
|
<td>@session.ScheduledAt.FormatMoscow()</td>
|
||||||
|
<td>@FormatSeats(session)</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -69,9 +243,17 @@
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;">
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
✏️ Изменить
|
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;">
|
||||||
</a>
|
✏️ Изменить
|
||||||
|
</a>
|
||||||
|
@if (CanPromote(session))
|
||||||
|
{
|
||||||
|
<button type="button" class="btn-gm btn-gm-success" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
|
||||||
|
@(promotingSessionId == session.Id ? "⏳ Поднимаем..." : "⬆️ Из ожидания")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -93,6 +275,10 @@
|
|||||||
<span>🕐 Время</span>
|
<span>🕐 Время</span>
|
||||||
<span style="color: var(--text-primary);">@session.ScheduledAt.FormatMoscow()</span>
|
<span style="color: var(--text-primary);">@session.ScheduledAt.FormatMoscow()</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="session-card-row">
|
||||||
|
<span>👥 Места</span>
|
||||||
|
<span style="color: var(--text-primary);">@FormatSeats(session)</span>
|
||||||
|
</div>
|
||||||
<div class="session-card-row">
|
<div class="session-card-row">
|
||||||
<span>🔗 Ссылка</span>
|
<span>🔗 Ссылка</span>
|
||||||
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer">Подключиться ↗</a>
|
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer">Подключиться ↗</a>
|
||||||
@@ -102,6 +288,12 @@
|
|||||||
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
|
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
|
||||||
✏️ Изменить
|
✏️ Изменить
|
||||||
</a>
|
</a>
|
||||||
|
@if (CanPromote(session))
|
||||||
|
{
|
||||||
|
<button type="button" class="btn-gm btn-gm-success" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
|
||||||
|
@(promotingSessionId == session.Id ? "⏳ Поднимаем..." : "⬆️ Из ожидания")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -112,11 +304,36 @@
|
|||||||
@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<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
||||||
|
private Guid? promotingSessionId;
|
||||||
|
private Guid? processingBatchId;
|
||||||
|
private Guid? processingTemplateId;
|
||||||
|
private long? removingCoGmId;
|
||||||
|
private bool isAddingCoGm;
|
||||||
|
private long telegramId;
|
||||||
|
private string? errorMessage;
|
||||||
|
private string? successMessage;
|
||||||
|
private CoGmEditModel coGmModel = new();
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
if (!authState.User.TryGetTelegramId(out var telegramId))
|
if (!authState.User.TryGetTelegramId(out telegramId))
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadSessions()
|
||||||
|
{
|
||||||
|
groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId);
|
||||||
|
if (groupManagement is null)
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
return;
|
return;
|
||||||
@@ -126,27 +343,415 @@
|
|||||||
if (sessions is null)
|
if (sessions is null)
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
campaignTemplates = await SessionService.GetCampaignTemplatesForGmAsync(GroupId, telegramId);
|
||||||
|
if (campaignTemplates is null)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
promotingSessionId = sessionId;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId);
|
||||||
|
await LoadSessions();
|
||||||
|
}
|
||||||
|
catch (SessionAccessDeniedException)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
promotingSessionId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateBatchDetails(BatchBulkEditModel batch)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
|
||||||
|
if (!ValidateBatchDetails(batch))
|
||||||
|
{
|
||||||
|
errorMessage = "Название и ссылка для batch не должны быть пустыми.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
processingBatchId = batch.BatchId;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SessionService.UpdateBatchDetailsForGmAsync(batch.BatchId, telegramId, batch.Title, batch.JoinLink);
|
||||||
|
await SessionService.UpdateBatchNotificationModeForGmAsync(
|
||||||
|
batch.BatchId,
|
||||||
|
telegramId,
|
||||||
|
SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode));
|
||||||
|
successMessage = "Настройки batch обновлены.";
|
||||||
|
await LoadSessions();
|
||||||
|
}
|
||||||
|
catch (SessionAccessDeniedException)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось обновить пачку: " + ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
processingBatchId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RescheduleBatch(BatchBulkEditModel batch)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
|
||||||
|
if (batch.IntervalDays <= 0)
|
||||||
|
{
|
||||||
|
errorMessage = "Шаг между играми должен быть больше 0 дней.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
processingBatchId = batch.BatchId;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var utcTime = new DateTimeOffset(batch.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
||||||
|
await SessionService.RescheduleBatchForGmAsync(batch.BatchId, telegramId, utcTime, batch.IntervalDays);
|
||||||
|
successMessage = "Расписание пачки обновлено.";
|
||||||
|
await LoadSessions();
|
||||||
|
}
|
||||||
|
catch (SessionAccessDeniedException)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось перенести пачку: " + ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
processingBatchId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CloneBatch(BatchBulkEditModel batch)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
processingBatchId = batch.BatchId;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var interval = batch.CloneInterval == "month"
|
||||||
|
? BatchCloneInterval.NextMonth
|
||||||
|
: BatchCloneInterval.NextWeek;
|
||||||
|
|
||||||
|
var clonedBatch = await SessionService.CloneBatchForGmAsync(batch.BatchId, telegramId, interval);
|
||||||
|
successMessage = $"Пачка склонирована: {clonedBatch.SessionCount} игр.";
|
||||||
|
await LoadSessions();
|
||||||
|
}
|
||||||
|
catch (SessionAccessDeniedException)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось клонировать пачку: " + ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
processingBatchId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
batchModels = sessions?
|
||||||
|
.GroupBy(session => session.BatchId)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var orderedSessions = group.OrderBy(session => session.ScheduledAt).ToList();
|
||||||
|
var firstSession = orderedSessions[0];
|
||||||
|
var lastSession = orderedSessions[^1];
|
||||||
|
|
||||||
|
return new BatchBulkEditModel
|
||||||
|
{
|
||||||
|
BatchId = group.Key,
|
||||||
|
Title = firstSession.Title,
|
||||||
|
JoinLink = firstSession.JoinLink,
|
||||||
|
NotificationMode = firstSession.NotificationMode,
|
||||||
|
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
|
||||||
|
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
|
||||||
|
IntervalDays = InferIntervalDays(orderedSessions),
|
||||||
|
SessionCount = orderedSessions.Count
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.OrderBy(batch => batch.FirstScheduledAtLocal)
|
||||||
|
.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)
|
||||||
|
{
|
||||||
|
batch.Title = batch.Title.Trim();
|
||||||
|
batch.JoinLink = batch.JoinLink.Trim();
|
||||||
|
return batch.Title.Length > 0 && batch.JoinLink.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (orderedSessions.Count < 2)
|
||||||
|
{
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
var days = (orderedSessions[1].ScheduledAt - orderedSessions[0].ScheduledAt).TotalDays;
|
||||||
|
return Math.Max(1, (int)Math.Round(days, MidpointRounding.AwayFromZero));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanPromote(WebSession session) =>
|
||||||
|
session.WaitlistedPlayerCount > 0 &&
|
||||||
|
(!session.MaxPlayers.HasValue || session.ActivePlayerCount < session.MaxPlayers.Value);
|
||||||
|
|
||||||
|
private static string FormatSeats(WebSession session)
|
||||||
|
{
|
||||||
|
var seats = session.MaxPlayers.HasValue
|
||||||
|
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
||||||
|
: session.ActivePlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
return session.WaitlistedPlayerCount > 0
|
||||||
|
? $"{seats} · ожидание {session.WaitlistedPlayerCount}"
|
||||||
|
: seats;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatBatchSummary(BatchBulkEditModel batch) =>
|
||||||
|
$"{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) =>
|
||||||
|
localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
|
||||||
|
|
||||||
private string GetStatusClass(string status) => status switch
|
private string GetStatusClass(string status) => status switch
|
||||||
{
|
{
|
||||||
SessionStatus.Confirmed => "status-success",
|
SessionStatus.Confirmed => "status-success",
|
||||||
SessionStatus.Cancelled => "status-danger",
|
SessionStatus.Cancelled => "status-danger",
|
||||||
SessionStatus.ConfirmationSent => "status-warning",
|
SessionStatus.ConfirmationSent => "status-warning",
|
||||||
"Recruiting" => "status-info",
|
SessionStatus.Planned => "status-info",
|
||||||
"RecruitmentClosed" => "status-info",
|
|
||||||
_ => "status-neutral"
|
_ => "status-neutral"
|
||||||
};
|
};
|
||||||
|
|
||||||
private string TranslateStatus(string status) => status switch
|
private string TranslateStatus(string status) => status switch
|
||||||
{
|
{
|
||||||
"Recruiting" => "Набор",
|
|
||||||
"RecruitmentClosed" => "Набор закрыт",
|
|
||||||
SessionStatus.Planned => "Запланировано",
|
SessionStatus.Planned => "Запланировано",
|
||||||
SessionStatus.ConfirmationSent => "Ждём подтверждения",
|
SessionStatus.ConfirmationSent => "Ждём подтверждения",
|
||||||
SessionStatus.Confirmed => "Подтверждено",
|
SessionStatus.Confirmed => "Подтверждено",
|
||||||
SessionStatus.Cancelled => "Отменено",
|
SessionStatus.Cancelled => "Отменено",
|
||||||
_ => status
|
_ => status
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private sealed class BatchBulkEditModel
|
||||||
|
{
|
||||||
|
public Guid BatchId { get; init; }
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public string JoinLink { get; set; } = "";
|
||||||
|
public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue;
|
||||||
|
public DateTime FirstScheduledAtLocal { get; set; } = DateTime.Now;
|
||||||
|
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
|
||||||
|
public int IntervalDays { get; set; } = 7;
|
||||||
|
public int SessionCount { get; init; }
|
||||||
|
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
@@ -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);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
namespace GmRelay.Web.Services;
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
||||||
@@ -5,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))
|
||||||
@@ -26,7 +46,18 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
|||||||
return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null;
|
return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink)
|
public async Task<WebSessionBatch?> GetBatchForGmAsync(Guid batchId, long gmId)
|
||||||
|
{
|
||||||
|
var batch = await sessionStore.GetBatchAsync(batchId);
|
||||||
|
if (batch is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await GroupBelongsToGmAsync(batch.GroupId, gmId) ? batch : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
|
||||||
{
|
{
|
||||||
var session = await GetSessionForGmAsync(sessionId, gmId);
|
var session = await GetSessionForGmAsync(sessionId, gmId);
|
||||||
if (session is null)
|
if (session is null)
|
||||||
@@ -34,12 +65,206 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
|||||||
throw new SessionAccessDeniedException(sessionId, gmId);
|
throw new SessionAccessDeniedException(sessionId, gmId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink);
|
await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink, maxPlayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PromoteWaitlistedPlayerForGmAsync(Guid sessionId, long gmId)
|
||||||
|
{
|
||||||
|
var session = await GetSessionForGmAsync(sessionId, gmId);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new SessionAccessDeniedException(sessionId, gmId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateBatchDetailsForGmAsync(Guid batchId, long gmId, string title, string joinLink)
|
||||||
|
{
|
||||||
|
var batch = await GetBatchForGmAsync(batchId, gmId);
|
||||||
|
if (batch is null)
|
||||||
|
{
|
||||||
|
throw new SessionAccessDeniedException(batchId, gmId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sessionStore.UpdateBatchDetailsAsync(batchId, batch.GroupId, title.Trim(), joinLink.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateBatchNotificationModeForGmAsync(Guid batchId, long gmId, SessionNotificationMode notificationMode)
|
||||||
|
{
|
||||||
|
var batch = await GetBatchForGmAsync(batchId, gmId);
|
||||||
|
if (batch is null)
|
||||||
|
{
|
||||||
|
throw new SessionAccessDeniedException(batchId, gmId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sessionStore.UpdateBatchNotificationModeAsync(batchId, batch.GroupId, notificationMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RescheduleBatchForGmAsync(Guid batchId, long gmId, DateTime firstScheduledAt, int intervalDays)
|
||||||
|
{
|
||||||
|
if (intervalDays <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var batch = await GetBatchForGmAsync(batchId, gmId);
|
||||||
|
if (batch is null)
|
||||||
|
{
|
||||||
|
throw new SessionAccessDeniedException(batchId, gmId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sessionStore.RescheduleBatchAsync(batchId, batch.GroupId, firstScheduledAt, intervalDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebSessionBatch> CloneBatchForGmAsync(Guid batchId, long gmId, BatchCloneInterval interval)
|
||||||
|
{
|
||||||
|
var batch = await GetBatchForGmAsync(batchId, gmId);
|
||||||
|
if (batch is null)
|
||||||
|
{
|
||||||
|
throw new SessionAccessDeniedException(batchId, gmId);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
|
public enum BatchCloneInterval
|
||||||
|
{
|
||||||
|
NextWeek,
|
||||||
|
NextMonth
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record WebSessionBatch(
|
||||||
|
Guid Id,
|
||||||
|
Guid GroupId,
|
||||||
|
string Title,
|
||||||
|
string JoinLink,
|
||||||
|
DateTime FirstScheduledAt,
|
||||||
|
DateTime LastScheduledAt,
|
||||||
|
int SessionCount,
|
||||||
|
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
|
||||||
|
{
|
||||||
|
private const int MaxTemplateSessionCount = 52;
|
||||||
|
private const int MaxTemplateIntervalDays = 365;
|
||||||
|
|
||||||
|
public static IReadOnlyList<DateTime> BuildFixedIntervalSchedule(
|
||||||
|
IEnumerable<DateTime> currentSchedule,
|
||||||
|
DateTime firstScheduledAt,
|
||||||
|
int intervalDays)
|
||||||
|
{
|
||||||
|
if (intervalDays <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentSchedule
|
||||||
|
.OrderBy(scheduledAt => scheduledAt)
|
||||||
|
.Select((_, index) => firstScheduledAt.AddDays(intervalDays * index))
|
||||||
|
.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) =>
|
||||||
|
interval switch
|
||||||
|
{
|
||||||
|
BatchCloneInterval.NextWeek => scheduledAt.AddDays(7),
|
||||||
|
BatchCloneInterval.NextMonth => scheduledAt.AddMonths(1),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(interval), interval, "Unknown clone interval.")
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,28 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
namespace GmRelay.Web.Services;
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
public interface ISessionStore
|
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 UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink);
|
Task<WebSessionBatch?> GetBatchAsync(Guid batchId);
|
||||||
|
Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers);
|
||||||
|
Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId);
|
||||||
|
Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink);
|
||||||
|
Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode);
|
||||||
|
Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
GM-Relay Design System v1.1.0
|
GM-Relay Design System v1.9.0
|
||||||
Dark RPG Dashboard Theme
|
Dark RPG Dashboard Theme
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@@ -363,6 +363,11 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
|||||||
filter: invert(0.7);
|
filter: invert(0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* === Tables === */
|
/* === Tables === */
|
||||||
.gm-table {
|
.gm-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -553,6 +558,138 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Batch bulk operations === */
|
||||||
|
.batch-bulk-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bulk-card {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bulk-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bulk-header h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bulk-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bulk-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bulk-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-clone-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-clone-row .btn-gm {
|
||||||
|
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); }
|
||||||
@@ -705,6 +842,46 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
|||||||
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;
|
||||||
@@ -772,10 +949,33 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-bulk-fields,
|
||||||
|
.batch-clone-row,
|
||||||
|
.campaign-template-fields,
|
||||||
|
.campaign-template-row,
|
||||||
|
.campaign-template-actions,
|
||||||
|
.template-group-selector {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-clone-row .btn-gm,
|
||||||
|
.campaign-template-actions .btn-gm {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.page-container {
|
.page-container {
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -821,4 +1021,4 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
|||||||
.glass-card {
|
.glass-card {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Domain;
|
||||||
|
|
||||||
|
public sealed class SessionNotificationModeTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(SessionNotificationMode.GroupAndDirect, true)]
|
||||||
|
[InlineData(SessionNotificationMode.GroupOnly, false)]
|
||||||
|
public void ShouldSendDirectMessages_ReturnsExpectedDecision(
|
||||||
|
SessionNotificationMode mode,
|
||||||
|
bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, mode.ShouldSendDirectMessages());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Domain;
|
||||||
|
|
||||||
|
public sealed class SessionStatusTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void All_ShouldContainOnlyCanonicalSessionStatuses()
|
||||||
|
{
|
||||||
|
var allProperty = typeof(SessionStatus).GetProperty(
|
||||||
|
"All",
|
||||||
|
BindingFlags.Public | BindingFlags.Static);
|
||||||
|
|
||||||
|
Assert.NotNull(allProperty);
|
||||||
|
|
||||||
|
var allStatusValues = Assert.IsAssignableFrom<IReadOnlySet<string>>(allProperty.GetValue(null));
|
||||||
|
var expectedStatusValues = new[]
|
||||||
|
{
|
||||||
|
SessionStatus.Planned,
|
||||||
|
SessionStatus.ConfirmationSent,
|
||||||
|
SessionStatus.Confirmed,
|
||||||
|
SessionStatus.Cancelled
|
||||||
|
}
|
||||||
|
.Order(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
Assert.Equal(expectedStatusValues, allStatusValues.Order(StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductionSources_ShouldNotReferenceLegacySessionStatuses()
|
||||||
|
{
|
||||||
|
var repositoryRoot = FindRepositoryRoot();
|
||||||
|
var productionFiles = Directory.EnumerateFiles(repositoryRoot, "*.*", SearchOption.AllDirectories)
|
||||||
|
.Where(path => IsProductionSource(path))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var legacyStatuses = new[] { "Recruit" + "ing", "Recruitment" + "Closed" };
|
||||||
|
var offenders = productionFiles
|
||||||
|
.SelectMany(path => legacyStatuses
|
||||||
|
.Where(status => File.ReadAllText(path).Contains(status, StringComparison.Ordinal))
|
||||||
|
.Select(status => $"{Path.GetRelativePath(repositoryRoot, path)} contains {status}"))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.Empty(offenders);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsProductionSource(string path)
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(path);
|
||||||
|
var separator = Path.DirectorySeparatorChar;
|
||||||
|
|
||||||
|
return path.Contains($"{separator}src{separator}", StringComparison.Ordinal)
|
||||||
|
&& !path.Contains($"{separator}bin{separator}", StringComparison.Ordinal)
|
||||||
|
&& !path.Contains($"{separator}obj{separator}", StringComparison.Ordinal)
|
||||||
|
&& extension is ".cs" or ".razor" or ".sql";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepositoryRoot()
|
||||||
|
{
|
||||||
|
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
|
||||||
|
while (current is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(current.FullName, "GM-Relay.slnx")))
|
||||||
|
{
|
||||||
|
return current.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Could not find repository root.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Confirmation.HandleRsvp;
|
||||||
|
|
||||||
|
public sealed class RsvpFlowRulesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_ShouldRevertAndAlert_WhenConfirmedSessionGetsDecline()
|
||||||
|
{
|
||||||
|
var decision = RsvpFlowRules.Evaluate(
|
||||||
|
RsvpStatus.Declined,
|
||||||
|
SessionStatus.Confirmed,
|
||||||
|
totalParticipants: 3,
|
||||||
|
confirmedParticipants: 2);
|
||||||
|
|
||||||
|
Assert.True(decision.ShouldAlertGm);
|
||||||
|
Assert.True(decision.ShouldRevertSessionToConfirmationSent);
|
||||||
|
Assert.False(decision.ShouldMarkSessionConfirmed);
|
||||||
|
Assert.Equal("Вы отказались от участия.", decision.CallbackText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_ShouldMarkConfirmed_WhenLastParticipantConfirms()
|
||||||
|
{
|
||||||
|
var decision = RsvpFlowRules.Evaluate(
|
||||||
|
RsvpStatus.Confirmed,
|
||||||
|
SessionStatus.ConfirmationSent,
|
||||||
|
totalParticipants: 3,
|
||||||
|
confirmedParticipants: 3);
|
||||||
|
|
||||||
|
Assert.True(decision.ShouldMarkSessionConfirmed);
|
||||||
|
Assert.True(decision.ShouldNotifyGroup);
|
||||||
|
Assert.True(decision.ShouldNotifyGm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_ShouldKeepWaiting_WhenNotEveryoneConfirmed()
|
||||||
|
{
|
||||||
|
var decision = RsvpFlowRules.Evaluate(
|
||||||
|
RsvpStatus.Confirmed,
|
||||||
|
SessionStatus.ConfirmationSent,
|
||||||
|
totalParticipants: 4,
|
||||||
|
confirmedParticipants: 2);
|
||||||
|
|
||||||
|
Assert.False(decision.ShouldMarkSessionConfirmed);
|
||||||
|
Assert.False(decision.ShouldNotifyGroup);
|
||||||
|
Assert.False(decision.ShouldNotifyGm);
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed class NewSessionCommandParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Parse_ShouldExtractTitleLinkAndUpcomingTimes()
|
||||||
|
{
|
||||||
|
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
var text = """
|
||||||
|
/newsession
|
||||||
|
Название: Curse of Strahd
|
||||||
|
Время: 24.04.2026 19:30
|
||||||
|
Время: 01.05.2026 20:00
|
||||||
|
Мест: 4
|
||||||
|
Ссылка: https://example.test/room
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = NewSessionCommandParser.Parse(text, nowUtc);
|
||||||
|
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
Assert.Equal("Curse of Strahd", result.Title);
|
||||||
|
Assert.Equal("https://example.test/room", result.Link);
|
||||||
|
Assert.Equal(4, result.MaxPlayers);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
new DateTimeOffset(2026, 4, 24, 16, 30, 0, TimeSpan.Zero),
|
||||||
|
new DateTimeOffset(2026, 5, 1, 17, 0, 0, TimeSpan.Zero)
|
||||||
|
],
|
||||||
|
result.ScheduledTimes);
|
||||||
|
Assert.Empty(result.PastTimeInputs);
|
||||||
|
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]
|
||||||
|
public void Parse_ShouldCollectPastAndInvalidTimes()
|
||||||
|
{
|
||||||
|
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
var text = """
|
||||||
|
Название: Delta Green
|
||||||
|
Время: 20.04.2026 19:30
|
||||||
|
Время: 31.04.2026 19:30
|
||||||
|
Время: 25.04.2026 18:00
|
||||||
|
Ссылка: https://example.test/dg
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = NewSessionCommandParser.Parse(text, nowUtc);
|
||||||
|
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
Assert.Single(result.ScheduledTimes);
|
||||||
|
Assert.Equal(["20.04.2026 19:30"], result.PastTimeInputs);
|
||||||
|
Assert.Equal(["31.04.2026 19:30"], result.InvalidTimeInputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_ShouldBeInvalid_WhenRequiredFieldsMissing()
|
||||||
|
{
|
||||||
|
var text = """
|
||||||
|
/newsession
|
||||||
|
Название: Blades in the Dark
|
||||||
|
Время: 25.04.2026 19:30
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Null(result.Link);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_ShouldCollectInvalidSeatLimit()
|
||||||
|
{
|
||||||
|
var text = """
|
||||||
|
/newsession
|
||||||
|
Название: Blades in the Dark
|
||||||
|
Время: 25.04.2026 19:30
|
||||||
|
Мест: 0
|
||||||
|
Ссылка: https://example.test/blades
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Null(result.MaxPlayers);
|
||||||
|
Assert.Equal(["0"], result.InvalidSeatLimitInputs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed class SessionCapacityRulesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DecideJoinStatus_ShouldReturnActive_WhenSessionHasFreeSeats()
|
||||||
|
{
|
||||||
|
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 3, activeParticipants: 2);
|
||||||
|
|
||||||
|
Assert.Equal(ParticipantRegistrationStatus.Active, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DecideJoinStatus_ShouldReturnWaitlisted_WhenSessionReachedLimit()
|
||||||
|
{
|
||||||
|
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 2, activeParticipants: 2);
|
||||||
|
|
||||||
|
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanPromoteWaitlistedPlayer_ShouldRequireWaitlistAndFreeSeat()
|
||||||
|
{
|
||||||
|
Assert.True(SessionCapacityRules.CanPromoteWaitlistedPlayer(maxPlayers: 3, activeParticipants: 2, waitlistedParticipants: 1));
|
||||||
|
Assert.False(SessionCapacityRules.CanPromoteWaitlistedPlayer(maxPlayers: 3, activeParticipants: 3, waitlistedParticipants: 1));
|
||||||
|
Assert.False(SessionCapacityRules.CanPromoteWaitlistedPlayer(maxPlayers: 3, activeParticipants: 2, waitlistedParticipants: 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldPromoteAfterParticipantLeaves_ShouldOnlyPromoteAfterActiveParticipantLeaves()
|
||||||
|
{
|
||||||
|
Assert.True(SessionCapacityRules.ShouldPromoteAfterParticipantLeaves(
|
||||||
|
removedRegistrationStatus: ParticipantRegistrationStatus.Active,
|
||||||
|
maxPlayers: 2,
|
||||||
|
activeParticipantsAfterLeave: 1,
|
||||||
|
waitlistedParticipants: 1));
|
||||||
|
|
||||||
|
Assert.False(SessionCapacityRules.ShouldPromoteAfterParticipantLeaves(
|
||||||
|
removedRegistrationStatus: ParticipantRegistrationStatus.Waitlisted,
|
||||||
|
maxPlayers: 2,
|
||||||
|
activeParticipantsAfterLeave: 1,
|
||||||
|
waitlistedParticipants: 1));
|
||||||
|
|
||||||
|
Assert.False(SessionCapacityRules.ShouldPromoteAfterParticipantLeaves(
|
||||||
|
removedRegistrationStatus: ParticipantRegistrationStatus.Active,
|
||||||
|
maxPlayers: 2,
|
||||||
|
activeParticipantsAfterLeave: 1,
|
||||||
|
waitlistedParticipants: 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
public sealed class HandleRescheduleTimeInputHandlerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TryParseVotingInput_ShouldAcceptTwoOptionsAndDeadline()
|
||||||
|
{
|
||||||
|
var now = new DateTimeOffset(2026, 4, 24, 8, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
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 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>
|
||||||
|
{
|
||||||
|
new(aliceId, "Alice", "alice"),
|
||||||
|
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(
|
||||||
|
"Shadowrun",
|
||||||
|
currentTime,
|
||||||
|
deadline,
|
||||||
|
options,
|
||||||
|
participants,
|
||||||
|
votes);
|
||||||
|
|
||||||
|
Assert.Contains("Shadowrun", text);
|
||||||
|
Assert.Contains("Дедлайн: <b>25 апреля 2026, 12:00</b> (МСК)", text);
|
||||||
|
Assert.Contains("1. <b>26 апреля 2026, 19:00</b> (МСК) — 1 голос", 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
public sealed class RescheduleVoteRulesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SelectWinner_ShouldApproveSingleTopOption()
|
||||||
|
{
|
||||||
|
var winningOptionId = Guid.NewGuid();
|
||||||
|
var otherOptionId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var decision = RescheduleVoteRules.SelectWinner(
|
||||||
|
[
|
||||||
|
new RescheduleOptionVoteCount(winningOptionId, 3),
|
||||||
|
new RescheduleOptionVoteCount(otherOptionId, 1)
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome);
|
||||||
|
Assert.Equal(winningOptionId, decision.SelectedOptionId);
|
||||||
|
Assert.Equal("Победил вариант с большинством голосов.", decision.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectWinner_ShouldRejectTie()
|
||||||
|
{
|
||||||
|
var firstOptionId = Guid.NewGuid();
|
||||||
|
var secondOptionId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var decision = RescheduleVoteRules.SelectWinner(
|
||||||
|
[
|
||||||
|
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}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class SessionBatchRendererTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldOrderSessionsAndSkipButtonsForCancelledSessions()
|
||||||
|
{
|
||||||
|
var firstSessionId = Guid.NewGuid();
|
||||||
|
var secondSessionId = Guid.NewGuid();
|
||||||
|
var cancelledSessionId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var sessions = new[]
|
||||||
|
{
|
||||||
|
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
|
||||||
|
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
|
||||||
|
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
|
||||||
|
};
|
||||||
|
var participants = new[]
|
||||||
|
{
|
||||||
|
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||||
|
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
|
||||||
|
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = SessionBatchRenderer.Render("Campaign", sessions, participants);
|
||||||
|
var text = result.Text;
|
||||||
|
var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||||
|
|
||||||
|
var firstIndex = text.IndexOf(sessions[2].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
|
||||||
|
var secondIndex = text.IndexOf(sessions[0].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
|
||||||
|
var thirdIndex = text.IndexOf(sessions[1].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
|
||||||
|
|
||||||
|
Assert.Contains("Campaign", text);
|
||||||
|
Assert.True(firstIndex < secondIndex);
|
||||||
|
Assert.True(secondIndex < thirdIndex);
|
||||||
|
Assert.Contains("Места: 0/2", text);
|
||||||
|
Assert.Contains("Места: 1/4", text);
|
||||||
|
Assert.Contains("@alice", text);
|
||||||
|
Assert.Contains("Лист ожидания (1)", text);
|
||||||
|
Assert.Contains("Charlie", text);
|
||||||
|
Assert.Contains("Bob", text);
|
||||||
|
Assert.Equal(4, result.Markup.InlineKeyboard.Count());
|
||||||
|
Assert.Collection(
|
||||||
|
buttons.Select(button => button.CallbackData),
|
||||||
|
callbackData => Assert.Equal($"join_session:{firstSessionId}", callbackData),
|
||||||
|
callbackData => Assert.Equal($"leave_session:{firstSessionId}", callbackData),
|
||||||
|
callbackData => Assert.Equal($"cancel_session:{firstSessionId}", callbackData),
|
||||||
|
callbackData => Assert.Equal($"reschedule_session:{firstSessionId}", callbackData),
|
||||||
|
callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData),
|
||||||
|
callbackData => Assert.Equal($"leave_session:{secondSessionId}", callbackData),
|
||||||
|
callbackData => Assert.Equal($"cancel_session:{secondSessionId}", callbackData),
|
||||||
|
callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData),
|
||||||
|
callbackData => Assert.Equal($"promote_waitlist:{secondSessionId}", callbackData));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using GmRelay.Web.Services;
|
using GmRelay.Web.Services;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Web;
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
],
|
],
|
||||||
sessions:
|
sessions:
|
||||||
[
|
[
|
||||||
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
@@ -27,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()
|
||||||
{
|
{
|
||||||
@@ -56,7 +85,7 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
],
|
],
|
||||||
sessions:
|
sessions:
|
||||||
[
|
[
|
||||||
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
@@ -66,6 +95,27 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
Assert.Equal(sessionId, session.Id);
|
Assert.Equal(sessionId, session.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSessionForGmAsync_ReturnsNull_WhenSessionBelongsToAnotherGm()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", 2002L)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
var session = await service.GetSessionForGmAsync(sessionId, 1001L);
|
||||||
|
|
||||||
|
Assert.Null(session);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateSessionForGmAsync_Throws_WhenSessionBelongsToAnotherGm()
|
public async Task UpdateSessionForGmAsync_Throws_WhenSessionBelongsToAnotherGm()
|
||||||
{
|
{
|
||||||
@@ -78,11 +128,11 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
],
|
],
|
||||||
sessions:
|
sessions:
|
||||||
[
|
[
|
||||||
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b");
|
var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
||||||
Assert.False(store.UpdateCalled);
|
Assert.False(store.UpdateCalled);
|
||||||
@@ -102,11 +152,11 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
],
|
],
|
||||||
sessions:
|
sessions:
|
||||||
[
|
[
|
||||||
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b");
|
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b", 5);
|
||||||
|
|
||||||
Assert.True(store.UpdateCalled);
|
Assert.True(store.UpdateCalled);
|
||||||
Assert.Equal(groupId, store.LastUpdatedGroupId);
|
Assert.Equal(groupId, store.LastUpdatedGroupId);
|
||||||
@@ -114,24 +164,500 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
Assert.Equal("Updated", store.LastUpdatedTitle);
|
Assert.Equal("Updated", store.LastUpdatedTitle);
|
||||||
Assert.Equal(scheduledAt, store.LastUpdatedScheduledAt);
|
Assert.Equal(scheduledAt, store.LastUpdatedScheduledAt);
|
||||||
Assert.Equal("https://example.test/b", store.LastUpdatedJoinLink);
|
Assert.Equal("https://example.test/b", store.LastUpdatedJoinLink);
|
||||||
|
Assert.Equal(5, store.LastUpdatedMaxPlayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PromoteWaitlistedPlayerForGmAsync_PromotesOwnedSession()
|
||||||
|
{
|
||||||
|
var gmId = 1001L;
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", gmId)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 3, 1)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
await service.PromoteWaitlistedPlayerForGmAsync(sessionId, gmId);
|
||||||
|
|
||||||
|
Assert.True(store.PromoteCalled);
|
||||||
|
Assert.Equal(groupId, store.LastPromotedGroupId);
|
||||||
|
Assert.Equal(sessionId, store.LastPromotedSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateBatchDetailsForGmAsync_Throws_WhenBatchBelongsToAnotherGm()
|
||||||
|
{
|
||||||
|
var batchId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", 2002L)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
var action = () => service.UpdateBatchDetailsForGmAsync(batchId, 1001L, "Updated", "https://example.test/b");
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
||||||
|
Assert.False(store.UpdateBatchDetailsCalled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateBatchDetailsForGmAsync_UpdatesOwnedBatch()
|
||||||
|
{
|
||||||
|
var gmId = 1001L;
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var batchId = Guid.NewGuid();
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", gmId)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
await service.UpdateBatchDetailsForGmAsync(batchId, gmId, "Updated", "https://example.test/b");
|
||||||
|
|
||||||
|
Assert.True(store.UpdateBatchDetailsCalled);
|
||||||
|
Assert.Equal(batchId, store.LastUpdatedBatchId);
|
||||||
|
Assert.Equal(groupId, store.LastUpdatedBatchGroupId);
|
||||||
|
Assert.Equal("Updated", store.LastUpdatedBatchTitle);
|
||||||
|
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]
|
||||||
|
public async Task UpdateBatchNotificationModeForGmAsync_Throws_WhenBatchBelongsToAnotherGm()
|
||||||
|
{
|
||||||
|
var batchId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", 2002L)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
var action = () => service.UpdateBatchNotificationModeForGmAsync(batchId, 1001L, SessionNotificationMode.GroupOnly);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
||||||
|
Assert.False(store.UpdateBatchNotificationModeCalled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateBatchNotificationModeForGmAsync_UpdatesOwnedBatch()
|
||||||
|
{
|
||||||
|
var gmId = 1001L;
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var batchId = Guid.NewGuid();
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", gmId)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
await service.UpdateBatchNotificationModeForGmAsync(batchId, gmId, SessionNotificationMode.GroupOnly);
|
||||||
|
|
||||||
|
Assert.True(store.UpdateBatchNotificationModeCalled);
|
||||||
|
Assert.Equal(batchId, store.LastUpdatedNotificationBatchId);
|
||||||
|
Assert.Equal(groupId, store.LastUpdatedNotificationGroupId);
|
||||||
|
Assert.Equal(SessionNotificationMode.GroupOnly, store.LastUpdatedNotificationMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RescheduleBatchForGmAsync_RejectsNonPositiveInterval()
|
||||||
|
{
|
||||||
|
var gmId = 1001L;
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var batchId = Guid.NewGuid();
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", gmId)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
var action = () => service.RescheduleBatchForGmAsync(batchId, gmId, DateTime.UtcNow.AddDays(7), intervalDays: 0);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(action);
|
||||||
|
Assert.False(store.RescheduleBatchCalled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RescheduleBatchForGmAsync_ReschedulesOwnedBatch()
|
||||||
|
{
|
||||||
|
var gmId = 1001L;
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var batchId = Guid.NewGuid();
|
||||||
|
var firstScheduledAt = DateTime.UtcNow.AddDays(7);
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", gmId)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
await service.RescheduleBatchForGmAsync(batchId, gmId, firstScheduledAt, intervalDays: 14);
|
||||||
|
|
||||||
|
Assert.True(store.RescheduleBatchCalled);
|
||||||
|
Assert.Equal(batchId, store.LastRescheduledBatchId);
|
||||||
|
Assert.Equal(groupId, store.LastRescheduledBatchGroupId);
|
||||||
|
Assert.Equal(firstScheduledAt, store.LastRescheduledFirstScheduledAt);
|
||||||
|
Assert.Equal(14, store.LastRescheduledIntervalDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CloneBatchForGmAsync_ClonesOwnedBatch()
|
||||||
|
{
|
||||||
|
var gmId = 1001L;
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var batchId = Guid.NewGuid();
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", gmId)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
await service.CloneBatchForGmAsync(batchId, gmId, BatchCloneInterval.NextWeek);
|
||||||
|
|
||||||
|
Assert.True(store.CloneBatchCalled);
|
||||||
|
Assert.Equal(batchId, store.LastClonedBatchId);
|
||||||
|
Assert.Equal(groupId, store.LastClonedBatchGroupId);
|
||||||
|
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 UpdateBatchDetailsCalled { get; private set; }
|
||||||
|
public bool UpdateBatchNotificationModeCalled { get; private set; }
|
||||||
|
public bool RescheduleBatchCalled { 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; }
|
||||||
public DateTime? LastUpdatedScheduledAt { get; private set; }
|
public DateTime? LastUpdatedScheduledAt { get; private set; }
|
||||||
public string? LastUpdatedJoinLink { get; private set; }
|
public string? LastUpdatedJoinLink { get; private set; }
|
||||||
|
public int? LastUpdatedMaxPlayers { get; private set; }
|
||||||
|
public Guid? LastPromotedSessionId { get; private set; }
|
||||||
|
public Guid? LastPromotedGroupId { get; private set; }
|
||||||
|
public Guid? LastUpdatedBatchId { get; private set; }
|
||||||
|
public Guid? LastUpdatedBatchGroupId { get; private set; }
|
||||||
|
public string? LastUpdatedBatchTitle { get; private set; }
|
||||||
|
public string? LastUpdatedBatchJoinLink { get; private set; }
|
||||||
|
public Guid? LastUpdatedNotificationBatchId { get; private set; }
|
||||||
|
public Guid? LastUpdatedNotificationGroupId { get; private set; }
|
||||||
|
public SessionNotificationMode? LastUpdatedNotificationMode { get; private set; }
|
||||||
|
public Guid? LastRescheduledBatchId { get; private set; }
|
||||||
|
public Guid? LastRescheduledBatchGroupId { get; private set; }
|
||||||
|
public DateTime? LastRescheduledFirstScheduledAt { get; private set; }
|
||||||
|
public int? LastRescheduledIntervalDays { get; private set; }
|
||||||
|
public Guid? LastClonedBatchId { get; private set; }
|
||||||
|
public Guid? LastClonedBatchGroupId { 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)
|
||||||
{
|
{
|
||||||
@@ -139,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());
|
||||||
|
|
||||||
@@ -148,7 +704,30 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
return Task.FromResult(session);
|
return Task.FromResult(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink)
|
public Task<WebSessionBatch?> GetBatchAsync(Guid batchId)
|
||||||
|
{
|
||||||
|
var batchSessions = sessionsById.Values
|
||||||
|
.Where(session => session.BatchId == batchId)
|
||||||
|
.OrderBy(session => session.ScheduledAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (batchSessions.Count == 0)
|
||||||
|
{
|
||||||
|
return Task.FromResult<WebSessionBatch?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstSession = batchSessions[0];
|
||||||
|
return Task.FromResult<WebSessionBatch?>(new(
|
||||||
|
batchId,
|
||||||
|
firstSession.GroupId,
|
||||||
|
firstSession.Title,
|
||||||
|
firstSession.JoinLink,
|
||||||
|
firstSession.ScheduledAt,
|
||||||
|
batchSessions[^1].ScheduledAt,
|
||||||
|
batchSessions.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
|
||||||
{
|
{
|
||||||
UpdateCalled = true;
|
UpdateCalled = true;
|
||||||
LastUpdatedSessionId = sessionId;
|
LastUpdatedSessionId = sessionId;
|
||||||
@@ -156,7 +735,148 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
LastUpdatedTitle = title;
|
LastUpdatedTitle = title;
|
||||||
LastUpdatedScheduledAt = scheduledAt;
|
LastUpdatedScheduledAt = scheduledAt;
|
||||||
LastUpdatedJoinLink = joinLink;
|
LastUpdatedJoinLink = joinLink;
|
||||||
|
LastUpdatedMaxPlayers = maxPlayers;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId)
|
||||||
|
{
|
||||||
|
PromoteCalled = true;
|
||||||
|
LastPromotedSessionId = sessionId;
|
||||||
|
LastPromotedGroupId = groupId;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink)
|
||||||
|
{
|
||||||
|
UpdateBatchDetailsCalled = true;
|
||||||
|
LastUpdatedBatchId = batchId;
|
||||||
|
LastUpdatedBatchGroupId = groupId;
|
||||||
|
LastUpdatedBatchTitle = title;
|
||||||
|
LastUpdatedBatchJoinLink = joinLink;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode)
|
||||||
|
{
|
||||||
|
UpdateBatchNotificationModeCalled = true;
|
||||||
|
LastUpdatedNotificationBatchId = batchId;
|
||||||
|
LastUpdatedNotificationGroupId = groupId;
|
||||||
|
LastUpdatedNotificationMode = notificationMode;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays)
|
||||||
|
{
|
||||||
|
RescheduleBatchCalled = true;
|
||||||
|
LastRescheduledBatchId = batchId;
|
||||||
|
LastRescheduledBatchGroupId = groupId;
|
||||||
|
LastRescheduledFirstScheduledAt = firstScheduledAt;
|
||||||
|
LastRescheduledIntervalDays = intervalDays;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval)
|
||||||
|
{
|
||||||
|
CloneBatchCalled = true;
|
||||||
|
LastClonedBatchId = batchId;
|
||||||
|
LastClonedBatchGroupId = groupId;
|
||||||
|
LastCloneInterval = interval;
|
||||||
|
return Task.FromResult(new WebSessionBatch(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
groupId,
|
||||||
|
"Session A",
|
||||||
|
"https://example.test/a",
|
||||||
|
DateTime.UtcNow.AddDays(7),
|
||||||
|
DateTime.UtcNow.AddDays(7),
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using GmRelay.Web.Services;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
|
public sealed class BatchSchedulePlannerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildFixedIntervalSchedule_OrdersSessionsAndAppliesInterval()
|
||||||
|
{
|
||||||
|
var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc);
|
||||||
|
var currentSchedule = new[]
|
||||||
|
{
|
||||||
|
new DateTime(2026, 4, 28, 16, 0, 0, DateTimeKind.Utc),
|
||||||
|
new DateTime(2026, 4, 21, 16, 0, 0, DateTimeKind.Utc),
|
||||||
|
new DateTime(2026, 5, 5, 16, 0, 0, DateTimeKind.Utc)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = BatchSchedulePlanner.BuildFixedIntervalSchedule(currentSchedule, firstScheduledAt, intervalDays: 7);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
firstScheduledAt,
|
||||||
|
firstScheduledAt.AddDays(7),
|
||||||
|
firstScheduledAt.AddDays(14)
|
||||||
|
],
|
||||||
|
result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildFixedIntervalSchedule_RejectsNonPositiveInterval()
|
||||||
|
{
|
||||||
|
var currentSchedule = new[] { new DateTime(2026, 4, 28, 16, 0, 0, DateTimeKind.Utc) };
|
||||||
|
var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var action = () => BatchSchedulePlanner.BuildFixedIntervalSchedule(currentSchedule, firstScheduledAt, intervalDays: 0);
|
||||||
|
|
||||||
|
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]
|
||||||
|
[InlineData(BatchCloneInterval.NextWeek, 2026, 5, 8)]
|
||||||
|
[InlineData(BatchCloneInterval.NextMonth, 2026, 6, 1)]
|
||||||
|
public void ShiftForClone_AppliesRequestedCalendarShift(BatchCloneInterval interval, int expectedYear, int expectedMonth, int expectedDay)
|
||||||
|
{
|
||||||
|
var scheduledAt = new DateTime(2026, 5, 1, 16, 30, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var result = BatchSchedulePlanner.ShiftForClone(scheduledAt, interval);
|
||||||
|
|
||||||
|
Assert.Equal(new DateTime(expectedYear, expectedMonth, expectedDay, 16, 30, 0, DateTimeKind.Utc), result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using GmRelay.Web.Services;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
|
public sealed class TelegramAuthServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Verify_ShouldAcceptValidTelegramPayload()
|
||||||
|
{
|
||||||
|
const string botToken = "test-bot-token";
|
||||||
|
var authDate = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||||||
|
var query = CreateQueryCollection(
|
||||||
|
botToken,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["auth_date"] = authDate,
|
||||||
|
["first_name"] = "Ada",
|
||||||
|
["id"] = "424242",
|
||||||
|
["last_name"] = "Lovelace",
|
||||||
|
["username"] = "ada"
|
||||||
|
});
|
||||||
|
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
||||||
|
|
||||||
|
var verified = service.Verify(query, out var telegramId, out var name);
|
||||||
|
|
||||||
|
Assert.True(verified);
|
||||||
|
Assert.Equal(424242L, telegramId);
|
||||||
|
Assert.Equal("Ada Lovelace", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_ShouldRejectTamperedHash()
|
||||||
|
{
|
||||||
|
const string botToken = "test-bot-token";
|
||||||
|
var values = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
|
||||||
|
["first_name"] = "Ada",
|
||||||
|
["id"] = "424242"
|
||||||
|
};
|
||||||
|
var query = CreateQueryCollection(botToken, values);
|
||||||
|
var invalidQuery = new QueryCollection(new Dictionary<string, StringValues>(query.ToDictionary(
|
||||||
|
pair => pair.Key,
|
||||||
|
pair => pair.Value))
|
||||||
|
{
|
||||||
|
["hash"] = "00"
|
||||||
|
});
|
||||||
|
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
||||||
|
|
||||||
|
var verified = service.Verify(invalidQuery, out _, out _);
|
||||||
|
|
||||||
|
Assert.False(verified);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_ShouldRejectExpiredPayload()
|
||||||
|
{
|
||||||
|
const string botToken = "test-bot-token";
|
||||||
|
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds().ToString();
|
||||||
|
var query = CreateQueryCollection(
|
||||||
|
botToken,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["auth_date"] = expiredAuthDate,
|
||||||
|
["first_name"] = "Ada",
|
||||||
|
["id"] = "424242"
|
||||||
|
});
|
||||||
|
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
||||||
|
|
||||||
|
var verified = service.Verify(query, out _, out _);
|
||||||
|
|
||||||
|
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) =>
|
||||||
|
new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Telegram:BotToken"] = botToken
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
private static QueryCollection CreateQueryCollection(string botToken, Dictionary<string, string> values)
|
||||||
|
{
|
||||||
|
var hash = ComputeTelegramHash(botToken, values);
|
||||||
|
var queryValues = values.ToDictionary(
|
||||||
|
pair => pair.Key,
|
||||||
|
pair => new StringValues(pair.Value));
|
||||||
|
queryValues["hash"] = new StringValues(hash);
|
||||||
|
return new QueryCollection(queryValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeTelegramHash(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 = SHA256.HashData(Encoding.UTF8.GetBytes(botToken));
|
||||||
|
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
|
public sealed class WebStylesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AppCss_ShouldStyleNativeSelectOptionsForReadableDropdowns()
|
||||||
|
{
|
||||||
|
var css = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/wwwroot/app.css"));
|
||||||
|
|
||||||
|
Assert.Matches(
|
||||||
|
@"select\s+option\s*\{[^}]*background:\s*var\(--bg-secondary\);[^}]*color:\s*var\(--text-primary\);",
|
||||||
|
css);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user