Initial commit: GM-Relay Telegram Bot
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
# Telegram Bot Token (ОБЯЗАТЕЛЬНАЯ НАСТРОЙКА!)
|
||||||
|
# Можно получить у @BotFather в Telegram
|
||||||
|
TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN_HERE
|
||||||
|
|
||||||
|
# Пароль для базы данных PostgreSQL
|
||||||
|
POSTGRES_PASSWORD=StrongPasswordForDatabase
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
## .NET
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
publish/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.userprefs
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
## IDE
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.gemini/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
## Aspire
|
||||||
|
.aspire/
|
||||||
|
|
||||||
|
## Build
|
||||||
|
artifacts/
|
||||||
|
TestResults/
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
appsettings.*.local.json
|
||||||
|
.env
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/GmRelay.AppHost/GmRelay.AppHost.csproj" />
|
||||||
|
<Project Path="src/GmRelay.Bot/GmRelay.Bot.csproj" />
|
||||||
|
<Project Path="src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# 🎲 GM-Relay: TTRPG Session Scheduling Bot
|
||||||
|
|
||||||
|
**GM-Relay** — это высокопроизводительный Telegram-бот для Мастеров Подземелий (ГМов), предназначенный для автоматизации записи игроков на сессии и управления игровым расписанием.
|
||||||
|
|
||||||
|
Бот разработан с упором на скорость, минимальное потребление ресурсов и удобство использования в реальных игровых группах.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Ключевые возможности
|
||||||
|
|
||||||
|
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
|
||||||
|
- **✋ Интерактивная запись**: Игроки записываются на конкретные даты нажатием одной кнопки.
|
||||||
|
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
|
||||||
|
- **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания.
|
||||||
|
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
|
||||||
|
- **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и потребление памяти в десятки раз меньше обычных .NET приложений. Идеально для **Raspberry Pi**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 Технологический стек
|
||||||
|
|
||||||
|
- **Язык**: C# 13 (.NET 10)
|
||||||
|
- **База данных**: PostgreSQL
|
||||||
|
- **ORM**: Dapper (AOT-совместный через Dapper.AOT)
|
||||||
|
- **Миграции**: DbUp (автоматически при старте)
|
||||||
|
- **Контейнеризация**: Docker + Multi-arch build (AMD64/ARM64)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт (Docker Compose)
|
||||||
|
|
||||||
|
### 1. Подготовка
|
||||||
|
Убедитесь, что у вас установлены **Docker** и **Docker Compose**.
|
||||||
|
|
||||||
|
### 2. Настройка окружения
|
||||||
|
Скопируйте файл-шаблон и заполните его значениями:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Отредактируйте `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Токен вашего бота от @BotFather
|
||||||
|
TELEGRAM_BOT_TOKEN=ваш_токен_здесь
|
||||||
|
|
||||||
|
# Пароль для базы данных PostgreSQL
|
||||||
|
POSTGRES_PASSWORD=ваш_надежный_пароль
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Запуск
|
||||||
|
Выполните команду:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
Бот сам создаст базу данных, применит миграции и начнет слушать сообщения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Настройка в Telegram
|
||||||
|
|
||||||
|
Чтобы бот работал корректно в вашей игровой группе:
|
||||||
|
|
||||||
|
1. **Добавьте бота в группу** (или Супергруппу/Форум).
|
||||||
|
2. **Назначьте бота Администратором**.
|
||||||
|
3. **Необходимые права**:
|
||||||
|
* `Выбор тем` (Managed Topics) — **обязательно**, если вы используете Форум (Темы), иначе бот не сможет создавать ветки для игр.
|
||||||
|
* `Отправка сообщений` — само собой.
|
||||||
|
* `Закрепление сообщений` — рекомендуется для удобства игроков.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Колонку "Мастер" (GM) бот определяет по первому человеку, который создал сессию в этой группе. Только этот пользователь сможет отменять игры через кнопки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Инструкция для Мастера
|
||||||
|
|
||||||
|
### Создание расписания игр
|
||||||
|
Используйте команду `/newsession` с описанием в следующем формате:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/newsession
|
||||||
|
Название: Легенды Берега Мечей (D&D 5e)
|
||||||
|
Время: 15.05.2024 19:30
|
||||||
|
Время: 22.05.2024 19:00
|
||||||
|
Ссылка: https://discord.gg/invite-link
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Название**: Заголовок игры (будет отображаться в списке).
|
||||||
|
* **Время**: Неограниченное количество строк "Время:" для каждой сессии.
|
||||||
|
* **Ссылка**: Ссылка на Discord/Roll20/Foundry (будет доступна игрокам).
|
||||||
|
|
||||||
|
### Другие команды
|
||||||
|
- `/listsessions` — Показать список всех актуальных игр в этой группе.
|
||||||
|
- `/reschedulesession` — Перенести сессию на другое время. Запускает голосование среди всех записавшихся игроков — время обновится только после единогласного одобрения.
|
||||||
|
- `/deletesession` — Удалить сессию. Автоматически закрывает связанную тему в Форуме.
|
||||||
|
- `/exportcalendar` — Получить файл `.ics` со всеми вашими будущими играми.
|
||||||
|
- `/help` — Справка по формату.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗 Разработка и сборка
|
||||||
|
|
||||||
|
Если вы хотите собрать проект вручную без Docker:
|
||||||
|
|
||||||
|
1. Установите [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0).
|
||||||
|
2. Настройте строку подключения в `appsettings.json` или через переменные окружения.
|
||||||
|
3. Для сборки Native AOT:
|
||||||
|
```bash
|
||||||
|
dotnet publish src/GmRelay.Bot/GmRelay.Bot.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> При использовании **Dapper** в режиме Native AOT, все SQL-запросы должны использовать строго типизированные DTO. Динамические типы (`dynamic`) не поддерживаются.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 Лицензия
|
||||||
|
Проект распространяется под лицензией MIT. Использование в некоммерческих целях приветствуется.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: gmrelay_db
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: gmrelay
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: gmrelay_db
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U gmrelay -d gmrelay_db"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
bot:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: src/GmRelay.Bot/Dockerfile
|
||||||
|
container_name: gmrelay_bot
|
||||||
|
restart: always
|
||||||
|
network_mode: host
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
- "ConnectionStrings__gmrelaydb=Host=127.0.0.1;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD}"
|
||||||
|
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# ADR-0001: Vertical Slice Architecture, Native AOT, BackgroundService и Aspire
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Accepted** — 2026-04-03
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
GM-Relay — Telegram-бот для автоматизации игровых сессий, развёрнутый на Raspberry Pi (ARM64) за NAT.
|
||||||
|
Требования к стеку:
|
||||||
|
|
||||||
|
- **.NET 10 / C# 14** — целевой рантайм.
|
||||||
|
- **Native AOT** — минимальный размер бинарника, мгновенный cold start на ARM64.
|
||||||
|
- **Raspberry Pi 5** — ограниченные ресурсы (4–8 ГБ RAM).
|
||||||
|
- **Long Polling** — Pi за NAT, webhook невозможен без проброса портов.
|
||||||
|
|
||||||
|
Необходимо выбрать: архитектурный паттерн, планировщик задач, ORM и оркестрацию.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. Vertical Slice Architecture (VSA)
|
||||||
|
|
||||||
|
Каждый use case реализован как **вертикальный срез**: Command/Query record + Handler.
|
||||||
|
Handler содержит всю логику (SQL, Telegram API, валидацию) — без абстрактных репозиториев и сервисных слоёв.
|
||||||
|
|
||||||
|
**Почему не Clean Architecture:**
|
||||||
|
- Бот с 5–7 use cases не нуждается в 4+ проектах и абстракциях.
|
||||||
|
- Все зависимости (Npgsql, Telegram.Bot) стабильны — заменять их не планируется.
|
||||||
|
- VSA позволяет добавлять фичи без рефакторинга существующих.
|
||||||
|
|
||||||
|
### 2. BackgroundService + PeriodicTimer (вместо Quartz.NET)
|
||||||
|
|
||||||
|
Два триггера расписания (T-24ч: подтверждение, T-5мин: ссылка) реализованы через
|
||||||
|
`BackgroundService` с `PeriodicTimer(TimeSpan.FromMinutes(1))`.
|
||||||
|
|
||||||
|
**Stateless-дизайн:** сервис при каждом тике делает SELECT к PostgreSQL, обрабатывает
|
||||||
|
найденные сессии и обновляет статус в БД. При перезагрузке — ничего не теряется.
|
||||||
|
|
||||||
|
**Почему не Quartz.NET:**
|
||||||
|
- Quartz.NET **не совместим с Native AOT** (reflection-based job loading).
|
||||||
|
- Для двух простых WHERE-запросов Quartz — overkill.
|
||||||
|
- BackgroundService нативно поддерживается .NET и AOT.
|
||||||
|
|
||||||
|
### 3. Npgsql + Dapper.AOT (вместо EF Core)
|
||||||
|
|
||||||
|
EF Core 10 **не совместим с Native AOT** (reflection-heavy query pipeline, dynamic IL).
|
||||||
|
Npgsql ADO.NET — полностью AOT-совместим. Dapper.AOT использует source generators
|
||||||
|
для compile-time генерации маппинга.
|
||||||
|
|
||||||
|
Миграции — **DbUp** (embedded SQL scripts).
|
||||||
|
|
||||||
|
### 4. Aspire 13 для оркестрации
|
||||||
|
|
||||||
|
Aspire обеспечивает:
|
||||||
|
- Автоматический запуск PostgreSQL в dev-среде.
|
||||||
|
- Service discovery и передачу connection strings.
|
||||||
|
- OpenTelemetry (traces, metrics, logs) из коробки.
|
||||||
|
- Aspire Dashboard для мониторинга.
|
||||||
|
|
||||||
|
### 5. Telegram.Bot 22.x + Long Polling
|
||||||
|
|
||||||
|
- Long Polling — единственный вариант для Pi за NAT.
|
||||||
|
- Telegram.Bot поддерживает `System.Text.Json` source generators для AOT.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Положительные
|
||||||
|
|
||||||
|
- **Один бинарник ~15–30 МБ** для ARM64 (Native AOT, self-contained).
|
||||||
|
- **Мгновенный cold start** (~50ms) на Raspberry Pi.
|
||||||
|
- **Простота**: каждая фича — один файл, один handler.
|
||||||
|
- **Устойчивость к перезагрузкам**: stateless scheduler + PostgreSQL.
|
||||||
|
|
||||||
|
### Отрицательные
|
||||||
|
|
||||||
|
- **Ручной SQL**: нет автогенерации запросов, миграции пишутся руками.
|
||||||
|
- **Нет LINQ-to-SQL**: все запросы — строки, ошибки только в runtime.
|
||||||
|
- **DI без reflection**: все handlers регистрируются явно (без assembly scanning).
|
||||||
|
|
||||||
|
### Риски
|
||||||
|
|
||||||
|
- Dapper.AOT — относительно молодой проект; при проблемах — fallback на чистый Npgsql.
|
||||||
|
- Telegram.Bot AOT-совместимость может потребовать кастомного `JsonSerializerContext`.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# GM-Relay — C4 Model
|
||||||
|
|
||||||
|
## Level 1: System Context
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
C4Context
|
||||||
|
title GM-Relay System Context
|
||||||
|
|
||||||
|
Person(gm, "Game Master", "Создаёт сессии, управляет расписанием игр")
|
||||||
|
Person(player, "Player", "Подтверждает участие через inline-кнопки")
|
||||||
|
|
||||||
|
System(gmrelay, "GM-Relay Bot", "Telegram Worker Service на Raspberry Pi. Управляет подтверждениями, рассылает напоминания и ссылки.")
|
||||||
|
|
||||||
|
System_Ext(telegram, "Telegram Bot API", "Long Polling. Сообщения, inline keyboards, callback queries.")
|
||||||
|
SystemDb_Ext(postgres, "PostgreSQL", "Сессии, игроки, RSVP-статусы")
|
||||||
|
|
||||||
|
Rel(gm, telegram, "Команды бота (/newsession)")
|
||||||
|
Rel(player, telegram, "Нажимает кнопки (✅ Буду / ❌ Не смогу)")
|
||||||
|
Rel(telegram, gmrelay, "Updates (Long Polling)")
|
||||||
|
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
||||||
|
Rel(gmrelay, postgres, "SQL (Npgsql + Dapper)")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Level 2: Container
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
C4Container
|
||||||
|
title GM-Relay Containers
|
||||||
|
|
||||||
|
Person(gm, "Game Master")
|
||||||
|
Person(player, "Player")
|
||||||
|
|
||||||
|
System_Boundary(pi, "Raspberry Pi 5") {
|
||||||
|
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Long polling, обработка команд и callback queries, планировщик")
|
||||||
|
ContainerDb(db, "PostgreSQL 16", "Database", "sessions, players, session_participants, game_groups")
|
||||||
|
}
|
||||||
|
|
||||||
|
System_Ext(telegram, "Telegram Bot API")
|
||||||
|
|
||||||
|
Rel(gm, telegram, "Commands")
|
||||||
|
Rel(player, telegram, "Callback Queries")
|
||||||
|
Rel(telegram, bot, "GetUpdates (Long Polling)")
|
||||||
|
Rel(bot, telegram, "Bot API calls")
|
||||||
|
Rel(bot, db, "Npgsql + Dapper.AOT")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Level 3: Component (GmRelay.Bot)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
C4Component
|
||||||
|
title GmRelay.Bot Components
|
||||||
|
|
||||||
|
Container_Boundary(bot, "GmRelay.Bot") {
|
||||||
|
Component(polling, "TelegramBotService", "BackgroundService", "Long polling loop, получает Updates")
|
||||||
|
Component(router, "UpdateRouter", "C#", "Маршрутизирует Update → Handler по типу")
|
||||||
|
Component(scheduler, "SessionSchedulerService", "BackgroundService", "PeriodicTimer(60s): T-24ч и T-5мин триггеры")
|
||||||
|
Component(migrator, "DbMigrator", "DbUp", "SQL миграции при старте")
|
||||||
|
|
||||||
|
Component(confirm, "SendConfirmationHandler", "Feature", "Отправляет inline keyboard за 24ч")
|
||||||
|
Component(rsvp, "HandleRsvpHandler", "Feature", "Обрабатывает ✅/❌, проверяет all-confirmed")
|
||||||
|
Component(link, "SendJoinLinkHandler", "Feature", "Отправляет join link за 5 мин")
|
||||||
|
}
|
||||||
|
|
||||||
|
System_Ext(telegram, "Telegram Bot API")
|
||||||
|
ContainerDb(db, "PostgreSQL")
|
||||||
|
|
||||||
|
Rel(polling, router, "Update")
|
||||||
|
Rel(router, rsvp, "CallbackQuery rsvp:*")
|
||||||
|
Rel(scheduler, confirm, "T-24h trigger")
|
||||||
|
Rel(scheduler, link, "T-5min trigger")
|
||||||
|
Rel(confirm, telegram, "SendMessage + InlineKeyboard")
|
||||||
|
Rel(rsvp, telegram, "EditMessage + AnswerCallback")
|
||||||
|
Rel(link, telegram, "SendMessage + user mentions")
|
||||||
|
Rel(confirm, db, "SELECT/UPDATE sessions")
|
||||||
|
Rel(rsvp, db, "UPDATE participants, SELECT counts")
|
||||||
|
Rel(link, db, "SELECT confirmed players")
|
||||||
|
Rel(migrator, db, "DDL migrations")
|
||||||
|
```
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.100",
|
||||||
|
"rollForward": "latestFeature"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# GM-Relay Solution Initialization Script
|
||||||
|
# Requires: .NET 10 SDK
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = $PSScriptRoot
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "[GM-Relay] Initializing solution..." -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# --- Prerequisites ---
|
||||||
|
$sdkVersion = dotnet --version
|
||||||
|
Write-Host " .NET SDK: $sdkVersion" -ForegroundColor Gray
|
||||||
|
|
||||||
|
Write-Host "[1/8] Installing Aspire project templates..." -ForegroundColor Yellow
|
||||||
|
dotnet new install Aspire.ProjectTemplates
|
||||||
|
|
||||||
|
# --- Solution ---
|
||||||
|
Write-Host "[2/8] Creating solution..." -ForegroundColor Yellow
|
||||||
|
dotnet new sln --name GM-Relay --output $root --force
|
||||||
|
|
||||||
|
# --- Projects ---
|
||||||
|
Write-Host "[3/8] Creating projects..." -ForegroundColor Yellow
|
||||||
|
dotnet new aspire-apphost -n GmRelay.AppHost -o "$root/src/GmRelay.AppHost" --force
|
||||||
|
dotnet new aspire-servicedefaults -n GmRelay.ServiceDefaults -o "$root/src/GmRelay.ServiceDefaults" --force
|
||||||
|
dotnet new worker -n GmRelay.Bot -o "$root/src/GmRelay.Bot" --force
|
||||||
|
dotnet new xunit -n GmRelay.Bot.Tests -o "$root/tests/GmRelay.Bot.Tests" --force
|
||||||
|
|
||||||
|
# --- Add to solution ---
|
||||||
|
Write-Host "[4/8] Adding projects to solution..." -ForegroundColor Yellow
|
||||||
|
dotnet sln "$root/GM-Relay.sln" add `
|
||||||
|
"$root/src/GmRelay.AppHost/GmRelay.AppHost.csproj" `
|
||||||
|
"$root/src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj" `
|
||||||
|
"$root/src/GmRelay.Bot/GmRelay.Bot.csproj" `
|
||||||
|
"$root/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj"
|
||||||
|
|
||||||
|
# --- Project references ---
|
||||||
|
Write-Host "[5/8] Setting up project references..." -ForegroundColor Yellow
|
||||||
|
dotnet add "$root/src/GmRelay.Bot/GmRelay.Bot.csproj" reference "$root/src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj"
|
||||||
|
dotnet add "$root/src/GmRelay.AppHost/GmRelay.AppHost.csproj" reference "$root/src/GmRelay.Bot/GmRelay.Bot.csproj"
|
||||||
|
dotnet add "$root/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj" reference "$root/src/GmRelay.Bot/GmRelay.Bot.csproj"
|
||||||
|
|
||||||
|
# --- NuGet: Bot ---
|
||||||
|
Write-Host "[6/8] Installing NuGet packages (Bot)..." -ForegroundColor Yellow
|
||||||
|
$botCsproj = "$root/src/GmRelay.Bot/GmRelay.Bot.csproj"
|
||||||
|
dotnet add $botCsproj package Telegram.Bot
|
||||||
|
dotnet add $botCsproj package Npgsql
|
||||||
|
dotnet add $botCsproj package Dapper
|
||||||
|
dotnet add $botCsproj package Dapper.AOT
|
||||||
|
dotnet add $botCsproj package dbup-postgresql
|
||||||
|
dotnet add $botCsproj package Aspire.Npgsql
|
||||||
|
|
||||||
|
# --- NuGet: AppHost ---
|
||||||
|
Write-Host "[7/8] Installing NuGet packages (AppHost)..." -ForegroundColor Yellow
|
||||||
|
dotnet add "$root/src/GmRelay.AppHost/GmRelay.AppHost.csproj" package Aspire.Hosting.PostgreSQL
|
||||||
|
|
||||||
|
# --- Cleanup template files ---
|
||||||
|
$workerFile = "$root/src/GmRelay.Bot/Worker.cs"
|
||||||
|
if (Test-Path $workerFile) {
|
||||||
|
Remove-Item $workerFile -Force
|
||||||
|
Write-Host " Removed template Worker.cs" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Create directory structure ---
|
||||||
|
Write-Host "[8/8] Creating directory structure..." -ForegroundColor Yellow
|
||||||
|
$dirs = @(
|
||||||
|
"src/GmRelay.Bot/Infrastructure/Database"
|
||||||
|
"src/GmRelay.Bot/Infrastructure/Telegram"
|
||||||
|
"src/GmRelay.Bot/Infrastructure/Scheduling"
|
||||||
|
"src/GmRelay.Bot/Domain"
|
||||||
|
"src/GmRelay.Bot/Migrations"
|
||||||
|
"src/GmRelay.Bot/Features/Sessions/CreateSession"
|
||||||
|
"src/GmRelay.Bot/Features/Confirmation/SendConfirmation"
|
||||||
|
"src/GmRelay.Bot/Features/Confirmation/HandleRsvp"
|
||||||
|
"src/GmRelay.Bot/Features/Reminders/SendJoinLink"
|
||||||
|
"tests/GmRelay.Bot.Tests/Features/Confirmation"
|
||||||
|
"docs/adr"
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($dir in $dirs) {
|
||||||
|
$fullPath = Join-Path $root $dir
|
||||||
|
if (-not (Test-Path $fullPath)) {
|
||||||
|
New-Item -ItemType Directory -Path $fullPath -Force | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "[OK] GM-Relay solution initialized!" -ForegroundColor Green
|
||||||
|
Write-Host " Run: dotnet build GM-Relay.sln" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Aspire.AppHost.Sdk/13.2.1">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\GmRelay.Bot\GmRelay.Bot.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.2.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<UserSecretsId>7d5a0cc1-d34c-4343-82b1-9db76d513fee</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
var builder = DistributedApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
var postgres = builder.AddPostgres("postgres")
|
||||||
|
.WithPgAdmin()
|
||||||
|
.AddDatabase("gmrelay-db");
|
||||||
|
|
||||||
|
builder.AddProject<Projects.GmRelay_Bot>("bot")
|
||||||
|
.WithReference(postgres)
|
||||||
|
.WaitFor(postgres);
|
||||||
|
|
||||||
|
builder.Build().Run();
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "https://localhost:17144;http://localhost:15110",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
|
"DOTNET_ENVIRONMENT": "Development",
|
||||||
|
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21050",
|
||||||
|
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23238",
|
||||||
|
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22287"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "http://localhost:15110",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
|
"DOTNET_ENVIRONMENT": "Development",
|
||||||
|
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19117",
|
||||||
|
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18080",
|
||||||
|
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20062"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Aspire.Hosting.Dcp": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"appHost": {
|
||||||
|
"path": "GmRelay.AppHost.csproj"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Этап 1: Сборка с использованием .NET SDK
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build
|
||||||
|
|
||||||
|
# Установка зависимостей для Native AOT-компиляции (clang и zlib)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
clang zlib1g-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Целевая архитектура для сборки (пробрасывается из Docker/Compose)
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Копирование проектов
|
||||||
|
COPY ["src/GmRelay.Bot/GmRelay.Bot.csproj", "src/GmRelay.Bot/"]
|
||||||
|
COPY ["src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj", "src/GmRelay.ServiceDefaults/"]
|
||||||
|
|
||||||
|
# Восстановление зависимостей с учетом архитектуры
|
||||||
|
RUN dotnet restore "src/GmRelay.Bot/GmRelay.Bot.csproj" -a $TARGETARCH
|
||||||
|
|
||||||
|
# Копирование остального исходного кода
|
||||||
|
COPY src/ src/
|
||||||
|
|
||||||
|
WORKDIR /src/src/GmRelay.Bot
|
||||||
|
|
||||||
|
# Публикация AOT-бинарного файла
|
||||||
|
RUN dotnet publish "GmRelay.Bot.csproj" -c Release -a $TARGETARCH -o /app/publish
|
||||||
|
|
||||||
|
# Этап 2: Runtime (runtime-deps содержит только ОС-зависимости, без .NET Runtime)
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем только AOT-результаты из билда
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
# Запуск скомпилированного AOT бинарного файла напрямую
|
||||||
|
ENTRYPOINT ["./GmRelay.Bot"]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
namespace GmRelay.Bot.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hardcoded Moscow timezone (UTC+3) for display purposes.
|
||||||
|
/// All DB storage is UTC (timestamptz). Conversion happens only at display layer.
|
||||||
|
/// Npgsql returns timestamptz as DateTime (Kind=Utc) under AOT, so we provide DateTime overloads.
|
||||||
|
/// </summary>
|
||||||
|
public static class MoscowTime
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||||
|
|
||||||
|
public static DateTimeOffset Now => DateTimeOffset.UtcNow.ToOffset(MoscowOffset);
|
||||||
|
|
||||||
|
public static DateTimeOffset ToMoscow(this DateTimeOffset utc) => utc.ToOffset(MoscowOffset);
|
||||||
|
|
||||||
|
public static string FormatMoscow(this DateTimeOffset utc)
|
||||||
|
=> utc.ToOffset(MoscowOffset).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
|
||||||
|
|
||||||
|
// ── DateTime overloads (for Dapper AOT compatibility) ────────────
|
||||||
|
|
||||||
|
public static DateTime ToMoscow(this DateTime utcDt) => utcDt.Add(MoscowOffset);
|
||||||
|
|
||||||
|
public static string FormatMoscow(this DateTime utcDt)
|
||||||
|
=> utcDt.Add(MoscowOffset).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
|
||||||
|
|
||||||
|
public static string FormatMoscowShort(this DateTime utcDt)
|
||||||
|
=> utcDt.Add(MoscowOffset).ToString("dd.MM");
|
||||||
|
|
||||||
|
public static bool TryParseMoscow(string text, out DateTimeOffset utcTime)
|
||||||
|
{
|
||||||
|
if (DateTime.TryParseExact(text, new[] { "dd.MM.yyyy HH:mm", "dd.MM.yyyy H:mm", "d.MM.yyyy HH:mm" },
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var localDt))
|
||||||
|
{
|
||||||
|
// Treat the parsed local time as UTC+3
|
||||||
|
utcTime = new DateTimeOffset(localDt, MoscowOffset).ToUniversalTime();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
utcTime = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace GmRelay.Bot.Domain;
|
||||||
|
|
||||||
|
public static class RsvpStatus
|
||||||
|
{
|
||||||
|
public const string Pending = "Pending";
|
||||||
|
public const string Confirmed = "Confirmed";
|
||||||
|
public const string Declined = "Declined";
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace GmRelay.Bot.Domain;
|
||||||
|
|
||||||
|
public static class SessionStatus
|
||||||
|
{
|
||||||
|
public const string Planned = "Planned";
|
||||||
|
public const string ConfirmationSent = "ConfirmationSent";
|
||||||
|
public const string Confirmed = "Confirmed";
|
||||||
|
public const string Cancelled = "Cancelled";
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Domain;
|
||||||
|
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||||
|
|
||||||
|
// ── Command ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public sealed record HandleRsvpCommand(
|
||||||
|
Guid SessionId,
|
||||||
|
long TelegramUserId,
|
||||||
|
string Status,
|
||||||
|
string CallbackQueryId,
|
||||||
|
long ChatId,
|
||||||
|
int MessageId);
|
||||||
|
|
||||||
|
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
|
||||||
|
|
||||||
|
internal sealed record SessionContext(
|
||||||
|
string Title,
|
||||||
|
DateTime ScheduledAt,
|
||||||
|
long GmTelegramId,
|
||||||
|
long TelegramChatId);
|
||||||
|
|
||||||
|
internal sealed record ParticipantRsvp(
|
||||||
|
long TelegramId,
|
||||||
|
string DisplayName,
|
||||||
|
string? TelegramUsername,
|
||||||
|
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(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
ILogger<HandleRsvpHandler> logger)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(HandleRsvpCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
// ── 1. Validate participant ──────────────────────────────────
|
||||||
|
|
||||||
|
var participantExists = await connection.ExecuteScalarAsync<bool>(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND p.telegram_id = @TelegramUserId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
new { command.SessionId, command.TelegramUserId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (!participantExists)
|
||||||
|
{
|
||||||
|
await bot.AnswerCallbackQuery(
|
||||||
|
callbackQueryId: command.CallbackQueryId,
|
||||||
|
text: "Вы не являетесь участником этой сессии.",
|
||||||
|
cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Record RSVP (idempotent) ─────────────────────────────
|
||||||
|
|
||||||
|
var updated = await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE session_participants
|
||||||
|
SET rsvp_status = @Status,
|
||||||
|
responded_at = now()
|
||||||
|
WHERE session_id = @SessionId
|
||||||
|
AND player_id = (SELECT id FROM players WHERE telegram_id = @TelegramUserId)
|
||||||
|
AND rsvp_status != @Status
|
||||||
|
""",
|
||||||
|
new { command.SessionId, command.TelegramUserId, command.Status },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (updated == 0)
|
||||||
|
{
|
||||||
|
// Already in this state — just dismiss the loading spinner
|
||||||
|
var alreadyText = command.Status == RsvpStatus.Confirmed
|
||||||
|
? "Вы уже подтвердили участие."
|
||||||
|
: "Вы уже отказались от участия.";
|
||||||
|
|
||||||
|
await bot.AnswerCallbackQuery(
|
||||||
|
callbackQueryId: command.CallbackQueryId,
|
||||||
|
text: alreadyText,
|
||||||
|
cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Load session context ─────────────────────────────────
|
||||||
|
|
||||||
|
var session = await connection.QuerySingleAsync<SessionContext>(
|
||||||
|
"""
|
||||||
|
SELECT s.title, s.scheduled_at AS ScheduledAt,
|
||||||
|
g.gm_telegram_id AS GmTelegramId,
|
||||||
|
g.telegram_chat_id AS TelegramChatId
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
|
WHERE s.id = @SessionId
|
||||||
|
""",
|
||||||
|
new { command.SessionId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
// ── 4. Handle decline ───────────────────────────────────────
|
||||||
|
|
||||||
|
if (command.Status == RsvpStatus.Declined)
|
||||||
|
{
|
||||||
|
// Revert session to ConfirmationSent if it was Confirmed
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET status = @ConfirmationSent, updated_at = now()
|
||||||
|
WHERE id = @SessionId AND status = @Confirmed
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
command.SessionId,
|
||||||
|
ConfirmationSent = SessionStatus.ConfirmationSent,
|
||||||
|
Confirmed = SessionStatus.Confirmed
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
// Alert GM immediately via private message
|
||||||
|
var declinedPlayer = await connection.QuerySingleAsync<string>(
|
||||||
|
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
|
||||||
|
new { command.TelegramUserId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
// Send alert outside transaction (network call)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await bot.SendMessage(
|
||||||
|
chatId: session.GmTelegramId,
|
||||||
|
text: $"🚨 Отмена! {declinedPlayer} не сможет прийти на игру «{session.Title}».",
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}",
|
||||||
|
command.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.AnswerCallbackQuery(
|
||||||
|
callbackQueryId: command.CallbackQueryId,
|
||||||
|
text: "Вы отказались от участия.",
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
// ── 5. Handle confirm — check if ALL confirmed ──────────────
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var counts = await connection.QuerySingleAsync<RsvpCounts>(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
count(*) AS Total,
|
||||||
|
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
|
||||||
|
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
|
||||||
|
FROM session_participants
|
||||||
|
WHERE session_id = @SessionId AND is_gm = false
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
command.SessionId,
|
||||||
|
Confirmed = RsvpStatus.Confirmed,
|
||||||
|
Declined = RsvpStatus.Declined
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
var allConfirmed = counts.Confirmed == counts.Total;
|
||||||
|
|
||||||
|
if (allConfirmed)
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET status = @Confirmed, updated_at = now()
|
||||||
|
WHERE id = @SessionId
|
||||||
|
""",
|
||||||
|
new { command.SessionId, Confirmed = SessionStatus.Confirmed },
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
if (allConfirmed)
|
||||||
|
{
|
||||||
|
// Notify group
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await bot.SendMessage(
|
||||||
|
chatId: session.TelegramChatId,
|
||||||
|
text: $"🎉 Игра «{session.Title}» подтверждена! Все участники на месте.",
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}",
|
||||||
|
command.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify GM privately
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await bot.SendMessage(
|
||||||
|
chatId: session.GmTelegramId,
|
||||||
|
text: $"✅ Все подтвердили участие в «{session.Title}» ({session.ScheduledAt.FormatMoscow()} МСК).",
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}",
|
||||||
|
command.SessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.AnswerCallbackQuery(
|
||||||
|
callbackQueryId: command.CallbackQueryId,
|
||||||
|
text: "Вы подтвердили участие!",
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Update inline keyboard message ───────────────────────
|
||||||
|
|
||||||
|
await UpdateConfirmationMessage(command, session, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-renders the confirmation message with current RSVP statuses.
|
||||||
|
/// </summary>
|
||||||
|
private async Task UpdateConfirmationMessage(
|
||||||
|
HandleRsvpCommand command, SessionContext session, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
|
var participants = (await connection.QueryAsync<ParticipantRsvp>(
|
||||||
|
"""
|
||||||
|
SELECT p.telegram_id AS TelegramId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.telegram_username AS TelegramUsername,
|
||||||
|
sp.rsvp_status AS RsvpStatus
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId AND sp.is_gm = false
|
||||||
|
ORDER BY sp.responded_at NULLS LAST
|
||||||
|
""",
|
||||||
|
new { command.SessionId })).ToList();
|
||||||
|
|
||||||
|
var confirmed = participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
|
||||||
|
var declined = participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
|
||||||
|
var pending = participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList();
|
||||||
|
|
||||||
|
var lines = new List<string>
|
||||||
|
{
|
||||||
|
$"🎲 Подтвердите участие в «{session.Title}»",
|
||||||
|
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var p in confirmed)
|
||||||
|
lines.Add($" ✅ {FormatName(p)}");
|
||||||
|
foreach (var p in declined)
|
||||||
|
lines.Add($" ❌ ~~{FormatName(p)}~~");
|
||||||
|
foreach (var p in pending)
|
||||||
|
lines.Add($" ⏳ {FormatName(p)}");
|
||||||
|
|
||||||
|
lines.Add("");
|
||||||
|
|
||||||
|
if (confirmed.Count == participants.Count)
|
||||||
|
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})");
|
||||||
|
else if (declined.Count > 0)
|
||||||
|
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)");
|
||||||
|
else
|
||||||
|
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})");
|
||||||
|
|
||||||
|
var text = string.Join("\n", lines);
|
||||||
|
|
||||||
|
// Keep buttons unless everyone confirmed
|
||||||
|
var replyMarkup = confirmed.Count == participants.Count
|
||||||
|
? null
|
||||||
|
: new InlineKeyboardMarkup([
|
||||||
|
[
|
||||||
|
InlineKeyboardButton.WithCallbackData("\u2705 Буду", $"rsvp:confirm:{command.SessionId}"),
|
||||||
|
InlineKeyboardButton.WithCallbackData("\u274c Не смогу", $"rsvp:decline:{command.SessionId}")
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
await bot.EditMessageText(
|
||||||
|
chatId: command.ChatId,
|
||||||
|
messageId: command.MessageId,
|
||||||
|
text: text,
|
||||||
|
replyMarkup: replyMarkup,
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatName(ParticipantRsvp p) =>
|
||||||
|
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Domain;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||||
|
|
||||||
|
// ── DTOs for Dapper mapping ──────────────────────────────────────────
|
||||||
|
|
||||||
|
internal sealed record SessionInfo(
|
||||||
|
Guid Id,
|
||||||
|
string Title,
|
||||||
|
DateTime ScheduledAt,
|
||||||
|
Guid GroupId,
|
||||||
|
long TelegramChatId);
|
||||||
|
|
||||||
|
internal sealed record ParticipantInfo(
|
||||||
|
long TelegramId,
|
||||||
|
string DisplayName,
|
||||||
|
string? TelegramUsername);
|
||||||
|
|
||||||
|
// ── Handler ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends the interactive confirmation message (inline keyboard) to the group chat.
|
||||||
|
/// Called by SessionSchedulerService at T-24h.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SendConfirmationHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
ILogger<SendConfirmationHandler> logger)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
|
// 1. Load session + group info
|
||||||
|
var session = await connection.QuerySingleOrDefaultAsync<SessionInfo>(
|
||||||
|
"""
|
||||||
|
SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId,
|
||||||
|
g.telegram_chat_id AS TelegramChatId
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
|
WHERE s.id = @SessionId AND s.status = @Planned
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId, Planned = SessionStatus.Planned });
|
||||||
|
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Session {SessionId} not found or not in Planned status", sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load non-GM participants
|
||||||
|
var participants = (await connection.QueryAsync<ParticipantInfo>(
|
||||||
|
"""
|
||||||
|
SELECT p.telegram_id AS TelegramId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.telegram_username AS TelegramUsername
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId AND sp.is_gm = false
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId })).ToList();
|
||||||
|
|
||||||
|
if (participants.Count == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Session {SessionId} has no non-GM participants", sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Build confirmation message
|
||||||
|
var playerList = string.Join("\n", participants.Select(p =>
|
||||||
|
$" ⏳ {FormatPlayerName(p)}"));
|
||||||
|
|
||||||
|
var text = $"""
|
||||||
|
🎲 Подтвердите участие в «{session.Title}»
|
||||||
|
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
|
||||||
|
|
||||||
|
{playerList}
|
||||||
|
|
||||||
|
Статус: ожидаем подтверждения (0/{participants.Count})
|
||||||
|
""";
|
||||||
|
|
||||||
|
var keyboard = new InlineKeyboardMarkup([
|
||||||
|
[
|
||||||
|
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{sessionId}"),
|
||||||
|
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{sessionId}")
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 4. Send to group
|
||||||
|
var message = await bot.SendMessage(
|
||||||
|
chatId: session.TelegramChatId,
|
||||||
|
text: text,
|
||||||
|
replyMarkup: keyboard,
|
||||||
|
cancellationToken: ct);
|
||||||
|
|
||||||
|
// 5. Update session status and store message ID
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET status = @Status,
|
||||||
|
confirmation_message_id = @MessageId,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = @SessionId
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
Status = SessionStatus.ConfirmationSent,
|
||||||
|
MessageId = message.MessageId
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Confirmation sent for session {SessionId} ({Title}), message_id={MessageId}",
|
||||||
|
sessionId, session.Title, message.MessageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string FormatPlayerName(ParticipantInfo p) =>
|
||||||
|
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Domain;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||||
|
|
||||||
|
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
internal sealed record JoinLinkSession(
|
||||||
|
Guid Id,
|
||||||
|
string Title,
|
||||||
|
string JoinLink,
|
||||||
|
DateTime ScheduledAt,
|
||||||
|
long TelegramChatId);
|
||||||
|
|
||||||
|
internal sealed record ConfirmedPlayer(
|
||||||
|
long TelegramId,
|
||||||
|
string DisplayName,
|
||||||
|
string? TelegramUsername);
|
||||||
|
|
||||||
|
// ── Handler ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends the join link to the group chat at T-5min, tagging all confirmed players.
|
||||||
|
/// Called by SessionSchedulerService.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SendJoinLinkHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
ILogger<SendJoinLinkHandler> logger)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
|
// 1. Load session
|
||||||
|
var session = await connection.QuerySingleOrDefaultAsync<JoinLinkSession>(
|
||||||
|
"""
|
||||||
|
SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt,
|
||||||
|
g.telegram_chat_id AS TelegramChatId
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
|
WHERE s.id = @SessionId
|
||||||
|
AND s.status = @Confirmed
|
||||||
|
AND s.link_message_id IS NULL
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId, Confirmed = SessionStatus.Confirmed });
|
||||||
|
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Session {SessionId} not eligible for join link", sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load confirmed players
|
||||||
|
var players = (await connection.QueryAsync<ConfirmedPlayer>(
|
||||||
|
"""
|
||||||
|
SELECT p.telegram_id AS TelegramId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.telegram_username AS TelegramUsername
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND sp.rsvp_status = @Confirmed
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId, Confirmed = RsvpStatus.Confirmed })).ToList();
|
||||||
|
|
||||||
|
// 3. Build message with player mentions
|
||||||
|
var mentions = string.Join(", ", players.Select(p =>
|
||||||
|
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName));
|
||||||
|
|
||||||
|
var text = $"""
|
||||||
|
🎮 Игра «{session.Title}» начинается через 5 минут!
|
||||||
|
|
||||||
|
🔗 Ссылка на подключение:
|
||||||
|
{session.JoinLink}
|
||||||
|
|
||||||
|
Участники: {mentions}
|
||||||
|
|
||||||
|
Хорошей игры! 🎲
|
||||||
|
""";
|
||||||
|
|
||||||
|
// 4. Send
|
||||||
|
var message = await bot.SendMessage(
|
||||||
|
chatId: session.TelegramChatId,
|
||||||
|
text: text,
|
||||||
|
cancellationToken: ct);
|
||||||
|
|
||||||
|
// 5. Mark as sent (idempotent — link_message_id IS NULL guard in query)
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET link_message_id = @MessageId, updated_at = now()
|
||||||
|
WHERE id = @SessionId AND link_message_id IS NULL
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId, MessageId = message.MessageId });
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Join link sent for session {SessionId} ({Title}), message_id={MessageId}",
|
||||||
|
sessionId, session.Title, message.MessageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Domain;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed record CancelSessionCommand(
|
||||||
|
Guid SessionId,
|
||||||
|
long TelegramUserId,
|
||||||
|
string CallbackQueryId,
|
||||||
|
long ChatId,
|
||||||
|
int MessageId);
|
||||||
|
|
||||||
|
// DTOs for AOT compilation
|
||||||
|
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, long GmId);
|
||||||
|
|
||||||
|
public sealed class CancelSessionHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
ILogger<CancelSessionHandler> logger)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(CancelSessionCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
// 1. Проверяем, что запрос делает ГМ данной сессии
|
||||||
|
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
|
||||||
|
JOIN game_groups g ON s.group_id = g.id
|
||||||
|
WHERE s.id = @SessionId",
|
||||||
|
new { command.SessionId }, transaction);
|
||||||
|
|
||||||
|
if (session == null)
|
||||||
|
{
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.GmId != command.TelegramUserId)
|
||||||
|
{
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может отменять сессию.", showAlert: true, cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Отменяем сессию
|
||||||
|
await connection.ExecuteAsync("UPDATE sessions SET status = 'Cancelled' WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
||||||
|
|
||||||
|
// 3. Загружаем весь батч для перерисовки
|
||||||
|
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",
|
||||||
|
new { BatchId = session.BatchId }, transaction);
|
||||||
|
|
||||||
|
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
|
@"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 { BatchId = session.BatchId }, transaction);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
// 4. Перерисовываем сообщение
|
||||||
|
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
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, "Сессия отменена!", cancellationToken: ct);
|
||||||
|
|
||||||
|
// Опционально: написать отдельное сообщение в чат
|
||||||
|
await bot.SendMessage(command.ChatId, $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to update batch message after cancelling session {SessionId}", command.SessionId);
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Ошибка при обновлении сообщения.", cancellationToken: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Domain;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed class CreateSessionHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ITelegramBotClient botClient,
|
||||||
|
ILogger<CreateSessionHandler> logger)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var text = message.Text ?? "";
|
||||||
|
|
||||||
|
string? title = null;
|
||||||
|
string? link = null;
|
||||||
|
var scheduledTimes = new List<DateTimeOffset>();
|
||||||
|
|
||||||
|
foreach (var line in text.Split('\n'))
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (trimmed.StartsWith("Название:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
title = trimmed["Название:".Length..].Trim();
|
||||||
|
else if (trimmed.StartsWith("Ссылка:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
chatId: message.Chat.Id,
|
||||||
|
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nСсылка: https://link",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var gmId = message.From!.Id;
|
||||||
|
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}");
|
||||||
|
var gmUsername = message.From.Username;
|
||||||
|
|
||||||
|
var chatId = message.Chat.Id;
|
||||||
|
var chatTitle = message.Chat.Title ?? "Private Chat";
|
||||||
|
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. Убеждаемся, что GM зарегистрирован
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
@"INSERT INTO players (telegram_id, display_name, telegram_username)
|
||||||
|
VALUES (@TgId, @Name, @Username)
|
||||||
|
ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = EXCLUDED.telegram_username;",
|
||||||
|
new { TgId = gmId, Name = gmName, Username = gmUsername },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
// 2. Убеждаемся, что Группа зарегистрирована
|
||||||
|
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
|
@"INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
|
||||||
|
VALUES (@ChatId, @ChatName, @GmId)
|
||||||
|
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
|
||||||
|
RETURNING id;",
|
||||||
|
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
int? messageThreadId = null;
|
||||||
|
if (message.Chat.IsForum)
|
||||||
|
{
|
||||||
|
var topic = await botClient.CreateForumTopic(
|
||||||
|
chatId: chatId,
|
||||||
|
name: $"🎲 Игры: {title}",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
messageThreadId = topic.MessageThreadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Создаем сессии в цикле с общим batch_id
|
||||||
|
var batchId = Guid.NewGuid();
|
||||||
|
var sessions = new List<SessionBatchDto>();
|
||||||
|
|
||||||
|
foreach (var dt in scheduledTimes.OrderBy(d => d))
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
RETURNING id;",
|
||||||
|
new { BatchId = batchId, GroupId = groupId, Title = title, Link = link, ScheduledAt = dt, ThreadId = messageThreadId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
sessions.Add(new SessionBatchDto(sessionId, dt.UtcDateTime, "Planned"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
|
||||||
|
|
||||||
|
// 4. Отправляем сообщение в чат
|
||||||
|
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||||
|
|
||||||
|
|
||||||
|
var batchMessage = await botClient.SendMessage(
|
||||||
|
chatId: chatId,
|
||||||
|
messageThreadId: messageThreadId,
|
||||||
|
text: renderResult.Text,
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
replyMarkup: renderResult.Markup,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
// 4b. Сохраняем message_id батч-сообщения для дальнейшего редактирования
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
|
||||||
|
new { MsgId = batchMessage.MessageId, BatchId = batchId });
|
||||||
|
|
||||||
|
// 5. Удаляем исходное сообщение с командой /newsession, чтобы не спамить
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await botClient.DeleteMessage(
|
||||||
|
chatId: chatId,
|
||||||
|
messageId: message.MessageId,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Ошибка при создании сессии");
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
using GmRelay.Bot.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed record JoinSessionCommand(
|
||||||
|
Guid SessionId,
|
||||||
|
long TelegramUserId,
|
||||||
|
string DisplayName,
|
||||||
|
string? TelegramUsername,
|
||||||
|
string CallbackQueryId,
|
||||||
|
long ChatId,
|
||||||
|
int MessageId);
|
||||||
|
|
||||||
|
// DTOs for AOT compilation
|
||||||
|
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title);
|
||||||
|
|
||||||
|
public sealed class JoinSessionHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
ILogger<JoinSessionHandler> logger)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. Убеждаемся, что игрок есть в базе
|
||||||
|
var playerId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
|
@"INSERT INTO players (telegram_id, display_name, telegram_username)
|
||||||
|
VALUES (@TgId, @Name, @Username)
|
||||||
|
ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = EXCLUDED.telegram_username
|
||||||
|
RETURNING id;",
|
||||||
|
new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
// 2. Добавляем в участники сессии (статус Pending, так как за 24 часа нужно будет финальное подтверждение)
|
||||||
|
var inserted = await connection.ExecuteAsync(
|
||||||
|
@"INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status)
|
||||||
|
VALUES (@SessionId, @PlayerId, false, 'Pending')
|
||||||
|
ON CONFLICT (session_id, player_id) DO NOTHING;",
|
||||||
|
new { SessionId = command.SessionId, PlayerId = playerId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (inserted == 0)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы уже записаны!", cancellationToken: ct);
|
||||||
|
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>(
|
||||||
|
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||||
|
new { BatchId = batchInfo.BatchId }, transaction);
|
||||||
|
|
||||||
|
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
|
@"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 { BatchId = batchInfo.BatchId }, transaction);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
// 4. Перерисовываем сообщение
|
||||||
|
var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||||
|
|
||||||
|
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, "Вы успешно записаны!", cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Ошибка при добавлении игрока к сессии");
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Произошла ошибка при регистрации.", cancellationToken: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using GmRelay.Bot.Domain;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
internal sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status);
|
||||||
|
internal sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername);
|
||||||
|
|
||||||
|
internal static class SessionBatchRenderer
|
||||||
|
{
|
||||||
|
public static (string Text, InlineKeyboardMarkup Markup) Render(
|
||||||
|
string title,
|
||||||
|
IReadOnlyList<SessionBatchDto> sessions,
|
||||||
|
IReadOnlyList<ParticipantBatchDto> participants)
|
||||||
|
{
|
||||||
|
var activeSessions = sessions.OrderBy(s => s.ScheduledAt).ToList();
|
||||||
|
|
||||||
|
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(title)}\n\n" +
|
||||||
|
$"<b>Расписание:</b>\n\n";
|
||||||
|
|
||||||
|
var buttons = new List<InlineKeyboardButton[]>();
|
||||||
|
|
||||||
|
foreach (var session in activeSessions)
|
||||||
|
{
|
||||||
|
var sessionPlayers = participants.Where(p => p.SessionId == session.SessionId).ToList();
|
||||||
|
|
||||||
|
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
||||||
|
messageText += $"👥 Игроки ({sessionPlayers.Count}):\n";
|
||||||
|
|
||||||
|
if (sessionPlayers.Count > 0)
|
||||||
|
{
|
||||||
|
messageText += string.Join("\n", sessionPlayers.Select(p => $" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
messageText += " <i>Пока никто не записался</i>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.Status == "Cancelled")
|
||||||
|
{
|
||||||
|
messageText += "❌ <i>Сессия отменена</i>\n\n";
|
||||||
|
}
|
||||||
|
else if (session.Status == "RecruitmentClosed") // custom state we can derive or use
|
||||||
|
{
|
||||||
|
messageText += "🔒 <i>Набор завершен</i>\n\n";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
messageText += "\n";
|
||||||
|
// Add buttons for this specific session
|
||||||
|
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
||||||
|
buttons.Add(new[]
|
||||||
|
{
|
||||||
|
InlineKeyboardButton.WithCallbackData($"✋ На {dateTitle}", $"join_session:{session.SessionId}"),
|
||||||
|
InlineKeyboardButton.WithCallbackData($"❌ Отменить {dateTitle} (ГМ)", $"cancel_session:{session.SessionId}"),
|
||||||
|
InlineKeyboardButton.WithCallbackData($"⏰ (ГМ)", $"reschedule_session:{session.SessionId}")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (messageText, new InlineKeyboardMarkup(buttons));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Dapper;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||||||
|
|
||||||
|
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
||||||
|
|
||||||
|
public sealed class ExportCalendarHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ITelegramBotClient botClient)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
|
||||||
|
var sessions = await connection.QueryAsync<CalendarSessionDto>(
|
||||||
|
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON s.group_id = g.id
|
||||||
|
WHERE g.telegram_chat_id = @ChatId
|
||||||
|
AND s.status = 'Planned'
|
||||||
|
AND s.scheduled_at > NOW()
|
||||||
|
ORDER BY s.scheduled_at ASC",
|
||||||
|
new { ChatId = message.Chat.Id });
|
||||||
|
|
||||||
|
var sessionsList = sessions.ToList();
|
||||||
|
|
||||||
|
if (sessionsList.Count == 0)
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
chatId: message.Chat.Id,
|
||||||
|
text: "📭 У этой группы нет запланированных сессий для экспорта.",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("BEGIN:VCALENDAR");
|
||||||
|
sb.AppendLine("VERSION:2.0");
|
||||||
|
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
|
||||||
|
|
||||||
|
foreach (var s in sessionsList)
|
||||||
|
{
|
||||||
|
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
|
||||||
|
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
|
||||||
|
|
||||||
|
sb.AppendLine("BEGIN:VEVENT");
|
||||||
|
sb.AppendLine($"UID:{s.Id}@gmrelay");
|
||||||
|
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
|
||||||
|
sb.AppendLine($"DTSTART:{dtStart}");
|
||||||
|
sb.AppendLine($"DTEND:{dtEnd}");
|
||||||
|
sb.AppendLine($"SUMMARY:{s.Title}");
|
||||||
|
// Escape special chars according to iCal standards (RFC 5545) -- simple escaping for summary
|
||||||
|
// In a fuller implementation we'd escape \r\n, commas, etc. But titles are mostly plain text.
|
||||||
|
sb.AppendLine("END:VEVENT");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine("END:VCALENDAR");
|
||||||
|
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||||
|
using var stream = new MemoryStream(bytes);
|
||||||
|
|
||||||
|
var inputFile = InputFile.FromStream(stream, "schedule.ics");
|
||||||
|
|
||||||
|
await botClient.SendDocument(
|
||||||
|
chatId: message.Chat.Id,
|
||||||
|
document: inputFile,
|
||||||
|
caption: "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
messageThreadId: message.MessageThreadId,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using GmRelay.Bot.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
|
public sealed record DeleteSessionCommand(
|
||||||
|
Guid SessionId,
|
||||||
|
long TelegramUserId,
|
||||||
|
string CallbackQueryId,
|
||||||
|
long ChatId,
|
||||||
|
int MessageId);
|
||||||
|
|
||||||
|
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, long GmId, int? ThreadId);
|
||||||
|
|
||||||
|
public sealed class DeleteSessionHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
ILogger<DeleteSessionHandler> logger)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
// 1. Fetch session and verify GM
|
||||||
|
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
|
||||||
|
JOIN game_groups g ON s.group_id = g.id
|
||||||
|
WHERE s.id = @SessionId",
|
||||||
|
new { command.SessionId }, transaction);
|
||||||
|
|
||||||
|
if (session == null)
|
||||||
|
{
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.GmId != command.TelegramUserId)
|
||||||
|
{
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может удалять сессию.", showAlert: true, cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete session
|
||||||
|
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
||||||
|
|
||||||
|
// 3. Check if any sessions are left in the batch
|
||||||
|
var remainingInBatch = await connection.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM sessions WHERE batch_id = @BatchId",
|
||||||
|
new { BatchId = session.BatchId }, transaction);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
// 4. If no sessions left and we have a forum topic, delete the topic
|
||||||
|
if (remainingInBatch == 0 && session.ThreadId.HasValue)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await bot.DeleteForumTopic(command.ChatId, session.ThreadId.Value, cancellationToken: ct);
|
||||||
|
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", session.ThreadId.Value, session.BatchId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", session.ThreadId.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия удалена!", cancellationToken: ct);
|
||||||
|
|
||||||
|
// 5. Update the /listsessions message (we delete the message or edit it to remove the button)
|
||||||
|
// A simple way is to re-render the list:
|
||||||
|
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
var sessions = await readConnection.QueryAsync<SessionListItemDto>(
|
||||||
|
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
|
||||||
|
COUNT(sp.id) FILTER (WHERE sp.is_gm = false) as PlayerCount,
|
||||||
|
g.gm_telegram_id as GmId
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON s.group_id = g.id
|
||||||
|
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||||
|
WHERE g.telegram_chat_id = @ChatId AND s.status != 'Cancelled' AND s.scheduled_at > NOW()
|
||||||
|
GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id
|
||||||
|
ORDER BY s.scheduled_at ASC",
|
||||||
|
new { ChatId = command.ChatId });
|
||||||
|
|
||||||
|
var sessionsList = sessions.ToList();
|
||||||
|
|
||||||
|
if (sessionsList.Count == 0)
|
||||||
|
{
|
||||||
|
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = "📅 <b>Ближайшие игры:</b>\n\n";
|
||||||
|
foreach (var s in sessionsList)
|
||||||
|
{
|
||||||
|
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Участников: {s.PlayerCount})\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
var isGm = command.TelegramUserId == sessionsList.First().GmId;
|
||||||
|
var keyboard = isGm
|
||||||
|
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
|
||||||
|
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await bot.EditMessageText(
|
||||||
|
command.ChatId,
|
||||||
|
command.MessageId,
|
||||||
|
text,
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
replyMarkup: keyboard,
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to edit list sessions message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Domain;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
|
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int PlayerCount, long GmId);
|
||||||
|
|
||||||
|
public sealed class ListSessionsHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ITelegramBotClient botClient)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
|
||||||
|
var sessions = await connection.QueryAsync<SessionListItemDto>(
|
||||||
|
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
|
||||||
|
COUNT(sp.id) FILTER (WHERE sp.is_gm = false) as PlayerCount,
|
||||||
|
g.gm_telegram_id as GmId
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON s.group_id = g.id
|
||||||
|
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||||
|
WHERE g.telegram_chat_id = @ChatId AND s.status != 'Cancelled' AND s.scheduled_at > NOW()
|
||||||
|
GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id
|
||||||
|
ORDER BY s.scheduled_at ASC",
|
||||||
|
new { ChatId = message.Chat.Id });
|
||||||
|
|
||||||
|
var sessionsList = sessions.ToList();
|
||||||
|
|
||||||
|
if (sessionsList.Count == 0)
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
chatId: message.Chat.Id,
|
||||||
|
text: "📭 В этой группе нет предстоящих игр.",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = "📅 <b>Ближайшие игры:</b>\n\n";
|
||||||
|
foreach (var s in sessionsList)
|
||||||
|
{
|
||||||
|
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Участников: {s.PlayerCount})\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
var isGm = message.From?.Id == sessionsList.First().GmId;
|
||||||
|
var keyboard = isGm
|
||||||
|
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
|
||||||
|
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await botClient.SendMessage(
|
||||||
|
chatId: message.Chat.Id,
|
||||||
|
text: text,
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
replyMarkup: keyboard,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Domain;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
internal sealed record AwaitingProposalDto(
|
||||||
|
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
|
||||||
|
Guid BatchId, int? BatchMessageId, long TelegramChatId);
|
||||||
|
|
||||||
|
internal sealed record VoteParticipantDto(Guid PlayerId, string DisplayName, string? TelegramUsername);
|
||||||
|
|
||||||
|
// ── Handler ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles text input from the GM who has an AwaitingTime proposal.
|
||||||
|
/// Parses the new time, creates a voting message, and tags all participants.
|
||||||
|
/// If no participants are registered, reschedules immediately.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HandleRescheduleTimeInputHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
ILogger<HandleRescheduleTimeInputHandler> logger)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to handle a text message as reschedule time input.
|
||||||
|
/// Returns true if it was handled (i.e. user had an AwaitingTime proposal).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> TryHandleAsync(Message message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var gmTelegramId = message.From.Id;
|
||||||
|
var chatId = message.Chat.Id;
|
||||||
|
var text = message.Text.Trim();
|
||||||
|
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
|
// 1. Check if this GM has an AwaitingTime proposal in this chat
|
||||||
|
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
|
||||||
|
"""
|
||||||
|
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
||||||
|
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||||
|
g.telegram_chat_id AS TelegramChatId
|
||||||
|
FROM reschedule_proposals rp
|
||||||
|
JOIN sessions s ON s.id = rp.session_id
|
||||||
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
|
WHERE rp.proposed_by = @GmId
|
||||||
|
AND rp.status = 'AwaitingTime'
|
||||||
|
AND g.telegram_chat_id = @ChatId
|
||||||
|
ORDER BY rp.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
new { GmId = gmTelegramId, ChatId = chatId });
|
||||||
|
|
||||||
|
if (proposal is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// 2. Parse the new time
|
||||||
|
if (!MoscowTime.TryParseMoscow(text, out var newTime))
|
||||||
|
{
|
||||||
|
await bot.SendMessage(
|
||||||
|
chatId: chatId,
|
||||||
|
text: "⚠️ Не удалось распознать время. Используйте формат: <code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\nНапример: <code>25.04.2026 19:30</code>",
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
cancellationToken: ct);
|
||||||
|
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
|
||||||
|
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
||||||
|
"""
|
||||||
|
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId AND sp.is_gm = false
|
||||||
|
""",
|
||||||
|
new { proposal.SessionId })).ToList();
|
||||||
|
|
||||||
|
// 4. If no participants — reschedule immediately
|
||||||
|
if (participants.Count == 0)
|
||||||
|
{
|
||||||
|
await RescheduleImmediately(connection, proposal, newTime, chatId, ct);
|
||||||
|
await TryDeleteMessage(chatId, message.MessageId, ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Create voting message
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
// Update proposal with proposed time and Voting status
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE reschedule_proposals
|
||||||
|
SET proposed_at = @ProposedAt, status = 'Voting', vote_chat_id = @ChatId
|
||||||
|
WHERE id = @Id
|
||||||
|
""",
|
||||||
|
new { ProposedAt = newTime, ChatId = chatId, Id = proposal.Id },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
// Build voting message text
|
||||||
|
var voteText = BuildVotingMessage(proposal.Title, proposal.CurrentScheduledAt, newTime, participants, []);
|
||||||
|
|
||||||
|
var keyboard = new InlineKeyboardMarkup([
|
||||||
|
[
|
||||||
|
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{proposal.Id}"),
|
||||||
|
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{proposal.Id}")
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
var voteMsg = await bot.SendMessage(
|
||||||
|
chatId: chatId,
|
||||||
|
text: voteText,
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
replyMarkup: keyboard,
|
||||||
|
cancellationToken: ct);
|
||||||
|
|
||||||
|
// Store vote message ID
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
|
||||||
|
new { MsgId = voteMsg.MessageId, Id = proposal.Id });
|
||||||
|
|
||||||
|
logger.LogInformation("Reschedule voting started for session {SessionId}, proposal {ProposalId}", proposal.SessionId, proposal.Id);
|
||||||
|
|
||||||
|
// Delete GM's time input message
|
||||||
|
await TryDeleteMessage(chatId, message.MessageId, ct);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RescheduleImmediately(
|
||||||
|
NpgsqlConnection connection, AwaitingProposalDto proposal,
|
||||||
|
DateTimeOffset newTime, long chatId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE sessions SET scheduled_at = @NewTime, status = 'Planned', updated_at = now()
|
||||||
|
WHERE id = @SessionId
|
||||||
|
""",
|
||||||
|
new { NewTime = newTime, proposal.SessionId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
|
||||||
|
new { NewTime = newTime, Id = proposal.Id },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
await bot.SendMessage(
|
||||||
|
chatId: chatId,
|
||||||
|
text: $"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
cancellationToken: ct);
|
||||||
|
|
||||||
|
// Re-render batch message with updated time
|
||||||
|
await TryUpdateBatchMessage(proposal, ct);
|
||||||
|
|
||||||
|
logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string BuildVotingMessage(
|
||||||
|
string title, DateTime currentTime, DateTimeOffset newTime,
|
||||||
|
IReadOnlyList<VoteParticipantDto> participants,
|
||||||
|
IReadOnlyCollection<Guid> approvedPlayerIds)
|
||||||
|
{
|
||||||
|
var lines = new List<string>
|
||||||
|
{
|
||||||
|
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
|
||||||
|
"",
|
||||||
|
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
|
||||||
|
$"📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)",
|
||||||
|
"",
|
||||||
|
"Для переноса нужно согласие всех участников:"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var p in participants)
|
||||||
|
{
|
||||||
|
var name = p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
|
||||||
|
var icon = approvedPlayerIds.Contains(p.PlayerId) ? "✅" : "⏳";
|
||||||
|
lines.Add($" {icon} {name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.Add("");
|
||||||
|
lines.Add($"Голоса: {approvedPlayerIds.Count}/{participants.Count} ✅");
|
||||||
|
|
||||||
|
return string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
|
var batchSessions = (await conn.QueryAsync<GmRelay.Bot.Features.Sessions.CreateSession.SessionBatchDto>(
|
||||||
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||||
|
new { proposal.BatchId })).ToList();
|
||||||
|
|
||||||
|
var batchParticipants = (await conn.QueryAsync<GmRelay.Bot.Features.Sessions.CreateSession.ParticipantBatchDto>(
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var renderResult = GmRelay.Bot.Features.Sessions.CreateSession.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
|
||||||
|
{
|
||||||
|
logger.LogWarning("No batch_message_id stored for session {SessionId}, cannot edit batch message in-place", proposal.SessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule for session {SessionId}", proposal.SessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await bot.DeleteMessage(chatId, messageId, cancellationToken: ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to delete message {MessageId} in chat {ChatId}", messageId, chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Domain;
|
||||||
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
// ── Command ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public sealed record HandleRescheduleVoteCommand(
|
||||||
|
Guid ProposalId,
|
||||||
|
string Vote, // "yes" or "no"
|
||||||
|
long TelegramUserId,
|
||||||
|
string CallbackQueryId,
|
||||||
|
long ChatId,
|
||||||
|
int MessageId);
|
||||||
|
|
||||||
|
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
internal sealed record VoteProposalDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid SessionId,
|
||||||
|
DateTime ProposedAt,
|
||||||
|
string Title,
|
||||||
|
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(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
ILogger<HandleRescheduleVoteHandler> logger)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
// 1. Load proposal + session info
|
||||||
|
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
|
||||||
|
"""
|
||||||
|
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.proposed_at AS ProposedAt,
|
||||||
|
s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
||||||
|
s.batch_id AS BatchId, s.status AS SessionStatus,
|
||||||
|
s.confirmation_message_id AS ConfirmationMessageId,
|
||||||
|
s.batch_message_id AS BatchMessageId,
|
||||||
|
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'
|
||||||
|
""",
|
||||||
|
new { command.ProposalId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (proposal is null)
|
||||||
|
{
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||||
|
"Голосование уже завершено или не найдено.", cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verify voter is a participant of this session
|
||||||
|
var playerId = await connection.ExecuteScalarAsync<Guid?>(
|
||||||
|
"""
|
||||||
|
SELECT p.id
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND p.telegram_id = @TelegramUserId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
""",
|
||||||
|
new { proposal.SessionId, command.TelegramUserId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (playerId is null)
|
||||||
|
{
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||||
|
"Вы не являетесь участником этой сессии.", cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Record vote (upsert)
|
||||||
|
var inserted = await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO reschedule_votes (proposal_id, player_id, vote)
|
||||||
|
VALUES (@ProposalId, @PlayerId, @Vote)
|
||||||
|
ON CONFLICT (proposal_id, player_id) DO UPDATE SET vote = EXCLUDED.vote, voted_at = now()
|
||||||
|
""",
|
||||||
|
new { command.ProposalId, PlayerId = playerId.Value, command.Vote },
|
||||||
|
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>(
|
||||||
|
"""
|
||||||
|
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId AND sp.is_gm = false
|
||||||
|
""",
|
||||||
|
new { proposal.SessionId },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
|
var approvedPlayerIds = (await connection.QueryAsync<Guid>(
|
||||||
|
"""
|
||||||
|
SELECT player_id FROM reschedule_votes
|
||||||
|
WHERE proposal_id = @ProposalId AND vote = 'yes'
|
||||||
|
""",
|
||||||
|
new { command.ProposalId },
|
||||||
|
transaction)).ToHashSet();
|
||||||
|
|
||||||
|
var allApproved = approvedPlayerIds.Count == participants.Count;
|
||||||
|
|
||||||
|
if (allApproved)
|
||||||
|
{
|
||||||
|
// 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
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET scheduled_at = @NewTime,
|
||||||
|
status = 'Planned',
|
||||||
|
confirmation_message_id = NULL,
|
||||||
|
link_message_id = NULL,
|
||||||
|
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
|
||||||
|
{
|
||||||
|
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 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
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to update batch message for proposal {ProposalId}", proposal.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
// ── Command ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public sealed record InitiateRescheduleCommand(
|
||||||
|
Guid SessionId,
|
||||||
|
long TelegramUserId,
|
||||||
|
string CallbackQueryId,
|
||||||
|
long ChatId,
|
||||||
|
int MessageId);
|
||||||
|
|
||||||
|
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
internal sealed record RescheduleSessionInfoDto(string Title, long GmId);
|
||||||
|
|
||||||
|
// ── Handler ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles the "⏰ Перенести" button press from the batch message.
|
||||||
|
/// Creates a reschedule proposal in AwaitingTime status and prompts
|
||||||
|
/// the GM to enter the new time via a regular text message.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InitiateRescheduleHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
ILogger<InitiateRescheduleHandler> logger)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(InitiateRescheduleCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
|
// 1. Verify GM ownership
|
||||||
|
var session = await connection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
|
||||||
|
"""
|
||||||
|
SELECT s.title AS Title, g.gm_telegram_id AS GmId
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON s.group_id = g.id
|
||||||
|
WHERE s.id = @SessionId AND s.status != 'Cancelled'
|
||||||
|
""",
|
||||||
|
new { command.SessionId });
|
||||||
|
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.GmId != command.TelegramUserId)
|
||||||
|
{
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||||
|
"Только Мастер Игры (GM) может переносить сессию.", showAlert: true, cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check no active proposal exists
|
||||||
|
var hasActive = await connection.ExecuteScalarAsync<bool>(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM reschedule_proposals
|
||||||
|
WHERE session_id = @SessionId AND status IN ('AwaitingTime', 'Voting')
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
new { command.SessionId });
|
||||||
|
|
||||||
|
if (hasActive)
|
||||||
|
{
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||||
|
"Уже есть активный запрос на перенос этой сессии.", showAlert: true, cancellationToken: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create proposal in AwaitingTime status
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO reschedule_proposals (session_id, proposed_by, status)
|
||||||
|
VALUES (@SessionId, @GmId, 'AwaitingTime')
|
||||||
|
""",
|
||||||
|
new { command.SessionId, GmId = command.TelegramUserId });
|
||||||
|
|
||||||
|
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
|
||||||
|
|
||||||
|
// 4. Prompt GM in chat
|
||||||
|
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||||
|
"Введите новое время в чат (формат: ДД.ММ.ГГГГ ЧЧ:ММ)", cancellationToken: ct);
|
||||||
|
|
||||||
|
await bot.SendMessage(
|
||||||
|
chatId: command.ChatId,
|
||||||
|
text: $"⏰ Укажите новое время для сессии «{session.Title}» в формате:\n<code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\n\nНапример: <code>25.04.2026 19:30</code>",
|
||||||
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>dotnet-GmRelay.Bot-f3419d27-b8b5-4b14-b9d2-77289d82d372</UserSecretsId>
|
||||||
|
|
||||||
|
<!-- Native AOT -->
|
||||||
|
<PublishAot>true</PublishAot>
|
||||||
|
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
|
||||||
|
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||||
|
|
||||||
|
<!-- Dapper.AOT: enable interceptors -->
|
||||||
|
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Embed SQL migration scripts for DbUp -->
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Migrations\*.sql" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Aspire.Npgsql" Version="13.2.1" />
|
||||||
|
<PackageReference Include="Dapper" Version="2.1.72" />
|
||||||
|
<PackageReference Include="Dapper.AOT" Version="1.0.48" />
|
||||||
|
<PackageReference Include="dbup-postgresql" Version="7.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
||||||
|
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Telegram.Bot" Version="22.9.5.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\GmRelay.ServiceDefaults\GmRelay.ServiceDefaults.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using DbUp;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Infrastructure.Database;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs embedded SQL migrations via DbUp on application startup.
|
||||||
|
/// Scripts are embedded as resources from the Migrations/ folder.
|
||||||
|
/// NOTE: We read the connection string from IConfiguration directly,
|
||||||
|
/// because NpgsqlDataSource.ConnectionString strips the password by default.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DbMigrator(IConfiguration configuration, ILogger<DbMigrator> logger)
|
||||||
|
{
|
||||||
|
public void MigrateUp()
|
||||||
|
{
|
||||||
|
var connectionString = configuration.GetConnectionString("gmrelaydb")
|
||||||
|
?? throw new InvalidOperationException("ConnectionStrings:gmrelaydb is required.");
|
||||||
|
|
||||||
|
EnsureDatabase.For.PostgresqlDatabase(connectionString);
|
||||||
|
|
||||||
|
var upgrader = DeployChanges.To
|
||||||
|
.PostgresqlDatabase(connectionString)
|
||||||
|
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly(), s => s.Contains(".Migrations."))
|
||||||
|
.WithTransactionPerScript()
|
||||||
|
.LogToConsole()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var result = upgrader.PerformUpgrade();
|
||||||
|
|
||||||
|
if (!result.Successful)
|
||||||
|
{
|
||||||
|
var ex = result.Error;
|
||||||
|
logger.LogCritical(ex, "Database migration failed");
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = result.Scripts.Count();
|
||||||
|
logger.LogInformation("Database migrations applied successfully. {Count} scripts executed", count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Bot.Domain;
|
||||||
|
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||||
|
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Infrastructure.Scheduling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stateless scheduler: wakes every 60 seconds, queries PostgreSQL for actionable sessions.
|
||||||
|
/// Two triggers:
|
||||||
|
/// T-24h: send confirmation request with inline keyboard
|
||||||
|
/// T-5min: send join link to all confirmed players
|
||||||
|
///
|
||||||
|
/// If the Raspberry Pi reboots, nothing is lost — all state is in the DB.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SessionSchedulerService(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
SendConfirmationHandler confirmationHandler,
|
||||||
|
SendJoinLinkHandler joinLinkHandler,
|
||||||
|
ILogger<SessionSchedulerService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
||||||
|
private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24);
|
||||||
|
private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Session scheduler started (interval: {Interval})", TickInterval);
|
||||||
|
|
||||||
|
using var timer = new PeriodicTimer(TickInterval);
|
||||||
|
|
||||||
|
// Run immediately on startup, then on each tick
|
||||||
|
do
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProcessConfirmationTriggers(stoppingToken);
|
||||||
|
await ProcessJoinLinkTriggers(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Scheduler tick failed, will retry next tick");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken));
|
||||||
|
|
||||||
|
logger.LogInformation("Session scheduler stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// T-24h trigger: find sessions that need confirmation requests sent.
|
||||||
|
/// Condition: status='Planned' AND scheduled_at minus 24h is in the past.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ProcessConfirmationTriggers(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
|
var sessionIds = await connection.QueryAsync<Guid>(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM sessions
|
||||||
|
WHERE status = @Planned
|
||||||
|
AND scheduled_at - @LeadTime <= now()
|
||||||
|
""",
|
||||||
|
new { Planned = SessionStatus.Planned, LeadTime = ConfirmationLeadTime });
|
||||||
|
|
||||||
|
foreach (var sessionId in sessionIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await confirmationHandler.HandleAsync(sessionId, ct);
|
||||||
|
logger.LogInformation("Confirmation sent for session {SessionId}", sessionId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to send confirmation for session {SessionId}", sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// T-5min trigger: find confirmed sessions that need join links sent.
|
||||||
|
/// Condition: status='Confirmed' AND scheduled_at minus 5min is in the past AND link not yet sent.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ProcessJoinLinkTriggers(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
|
var sessionIds = await connection.QueryAsync<Guid>(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM sessions
|
||||||
|
WHERE status = @Confirmed
|
||||||
|
AND scheduled_at - @LeadTime <= now()
|
||||||
|
AND link_message_id IS NULL
|
||||||
|
""",
|
||||||
|
new { Confirmed = SessionStatus.Confirmed, LeadTime = JoinLinkLeadTime });
|
||||||
|
|
||||||
|
foreach (var sessionId in sessionIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await joinLinkHandler.HandleAsync(sessionId, ct);
|
||||||
|
logger.LogInformation("Join link sent for session {SessionId}", sessionId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to send join link for session {SessionId}", sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
using Telegram.Bot.Types.Enums;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Long polling loop for Telegram Bot API.
|
||||||
|
/// Stateless — all state is in PostgreSQL. Safe to restart at any time.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TelegramBotService(
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
UpdateRouter router,
|
||||||
|
ILogger<TelegramBotService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Telegram bot polling started");
|
||||||
|
|
||||||
|
// Skip any pending updates from before this startup
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pending = await bot.GetUpdates(offset: -1, limit: 1, cancellationToken: stoppingToken);
|
||||||
|
if (pending.Length > 0)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Skipped {Count} pending update(s)", pending[^1].Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to clear pending updates, continuing anyway");
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = 0;
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var updates = await bot.GetUpdates(
|
||||||
|
offset: offset,
|
||||||
|
timeout: 30,
|
||||||
|
allowedUpdates: [UpdateType.Message, UpdateType.CallbackQuery],
|
||||||
|
cancellationToken: stoppingToken);
|
||||||
|
|
||||||
|
foreach (var update in updates)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await router.RouteAsync(update, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error handling update {UpdateId}", update.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = update.Id + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Polling error, retrying in 5s");
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Telegram bot polling stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
|
||||||
|
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||||
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Bot.Features.Sessions.ListSessions;
|
||||||
|
using GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||||||
|
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
using Telegram.Bot.Types.Enums;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes incoming Telegram updates to the appropriate feature handler.
|
||||||
|
/// No reflection — all routing is explicit (AOT-safe).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdateRouter(
|
||||||
|
HandleRsvpHandler rsvpHandler,
|
||||||
|
CreateSessionHandler createSessionHandler,
|
||||||
|
JoinSessionHandler joinSessionHandler,
|
||||||
|
CancelSessionHandler cancelSessionHandler,
|
||||||
|
DeleteSessionHandler deleteSessionHandler,
|
||||||
|
ListSessionsHandler listSessionsHandler,
|
||||||
|
ExportCalendarHandler exportCalendarHandler,
|
||||||
|
InitiateRescheduleHandler initiateRescheduleHandler,
|
||||||
|
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||||||
|
HandleRescheduleVoteHandler rescheduleVoteHandler,
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
ILogger<UpdateRouter> logger)
|
||||||
|
{
|
||||||
|
public async Task RouteAsync(Update update, CancellationToken ct)
|
||||||
|
{
|
||||||
|
switch (update)
|
||||||
|
{
|
||||||
|
case { CallbackQuery: { } query }:
|
||||||
|
await HandleCallbackQueryAsync(query, ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case { Message: { Text: { } text } message } when text.StartsWith('/'):
|
||||||
|
await HandleCommandAsync(message, text, ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Non-command text messages — check for reschedule time input
|
||||||
|
case { Message: { Text: { } } message } when !message.Text!.StartsWith('/'):
|
||||||
|
await rescheduleTimeInputHandler.TryHandleAsync(message, ct);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (query.Data is not { } data || query.Message is not { } message)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var parts = data.Split(':', 3);
|
||||||
|
var action = parts[0];
|
||||||
|
|
||||||
|
if (action == "join_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var joinSessionId))
|
||||||
|
{
|
||||||
|
var command = new JoinSessionCommand(
|
||||||
|
SessionId: joinSessionId,
|
||||||
|
TelegramUserId: query.From.Id,
|
||||||
|
DisplayName: query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
|
||||||
|
TelegramUsername: query.From.Username,
|
||||||
|
CallbackQueryId: query.Id,
|
||||||
|
ChatId: message.Chat.Id,
|
||||||
|
MessageId: message.MessageId);
|
||||||
|
|
||||||
|
await joinSessionHandler.HandleAsync(command, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == "cancel_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var cancelSessionId))
|
||||||
|
{
|
||||||
|
var command = new CancelSessionCommand(
|
||||||
|
SessionId: cancelSessionId,
|
||||||
|
TelegramUserId: query.From.Id,
|
||||||
|
CallbackQueryId: query.Id,
|
||||||
|
ChatId: message.Chat.Id,
|
||||||
|
MessageId: message.MessageId);
|
||||||
|
|
||||||
|
await cancelSessionHandler.HandleAsync(command, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == "delete_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var deleteSessionId))
|
||||||
|
{
|
||||||
|
var command = new DeleteSessionCommand(
|
||||||
|
SessionId: deleteSessionId,
|
||||||
|
TelegramUserId: query.From.Id,
|
||||||
|
CallbackQueryId: query.Id,
|
||||||
|
ChatId: message.Chat.Id,
|
||||||
|
MessageId: message.MessageId);
|
||||||
|
|
||||||
|
await deleteSessionHandler.HandleAsync(command, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == "reschedule_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var rescheduleSessionId))
|
||||||
|
{
|
||||||
|
var command = new InitiateRescheduleCommand(
|
||||||
|
SessionId: rescheduleSessionId,
|
||||||
|
TelegramUserId: query.From.Id,
|
||||||
|
CallbackQueryId: query.Id,
|
||||||
|
ChatId: message.Chat.Id,
|
||||||
|
MessageId: message.MessageId);
|
||||||
|
|
||||||
|
await initiateRescheduleHandler.HandleAsync(command, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == "reschedule_vote" && parts.Length >= 3 && Guid.TryParse(parts[2], out var proposalId))
|
||||||
|
{
|
||||||
|
var vote = parts[1]; // "yes" or "no"
|
||||||
|
if (vote is not ("yes" or "no"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var command = new HandleRescheduleVoteCommand(
|
||||||
|
ProposalId: proposalId,
|
||||||
|
Vote: vote,
|
||||||
|
TelegramUserId: query.From.Id,
|
||||||
|
CallbackQueryId: query.Id,
|
||||||
|
ChatId: message.Chat.Id,
|
||||||
|
MessageId: message.MessageId);
|
||||||
|
|
||||||
|
await rescheduleVoteHandler.HandleAsync(command, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == "rsvp")
|
||||||
|
{
|
||||||
|
if (parts.Length < 3 || !Guid.TryParse(parts[2], out var sessionId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var status = parts[1] switch
|
||||||
|
{
|
||||||
|
"confirm" => Domain.RsvpStatus.Confirmed,
|
||||||
|
"decline" => Domain.RsvpStatus.Declined,
|
||||||
|
_ => (string?)null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var command = new HandleRsvpCommand(
|
||||||
|
SessionId: sessionId,
|
||||||
|
TelegramUserId: query.From.Id,
|
||||||
|
Status: status,
|
||||||
|
CallbackQueryId: query.Id,
|
||||||
|
ChatId: message.Chat.Id,
|
||||||
|
MessageId: message.MessageId);
|
||||||
|
|
||||||
|
await rsvpHandler.HandleAsync(command, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleCommandAsync(Message message, string text, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Извлекаем команду: берём первую строку, первое слово, убираем @BotUsername
|
||||||
|
var firstLine = text.Split('\n')[0].Trim();
|
||||||
|
var command = firstLine.Split(' ')[0].Split('@')[0].ToLowerInvariant();
|
||||||
|
|
||||||
|
switch (command)
|
||||||
|
{
|
||||||
|
case "/start":
|
||||||
|
await bot.SendMessage(
|
||||||
|
chatId: message.Chat.Id,
|
||||||
|
text: "GM-Relay Bot ready. Use /help for commands.",
|
||||||
|
cancellationToken: ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/newsession":
|
||||||
|
await createSessionHandler.HandleAsync(message, ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/listsessions":
|
||||||
|
await listSessionsHandler.HandleAsync(message, ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/exportcalendar":
|
||||||
|
await exportCalendarHandler.HandleAsync(message, ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "/help":
|
||||||
|
await bot.SendMessage(
|
||||||
|
chatId: message.Chat.Id,
|
||||||
|
text: """
|
||||||
|
GM-Relay — бот для управления игровыми сессиями.
|
||||||
|
|
||||||
|
/newsession
|
||||||
|
Название: My Game
|
||||||
|
Время: 15.05.2026 19:30
|
||||||
|
Ссылка: https://link
|
||||||
|
|
||||||
|
/listsessions — список предстоящих сессий
|
||||||
|
/help — эта справка
|
||||||
|
""",
|
||||||
|
cancellationToken: ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// TODO: /listsessions — will be implemented as features
|
||||||
|
default:
|
||||||
|
logger.LogDebug("Unknown command: {Command}", command);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- Players: all known Telegram users who interact with the bot
|
||||||
|
-- =============================================================
|
||||||
|
CREATE TABLE players (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
telegram_id BIGINT NOT NULL UNIQUE,
|
||||||
|
display_name VARCHAR(255) NOT NULL,
|
||||||
|
telegram_username VARCHAR(255),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- Game groups: Telegram group chats where games are organized
|
||||||
|
-- =============================================================
|
||||||
|
CREATE TABLE game_groups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
telegram_chat_id BIGINT NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
gm_telegram_id BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- Group membership: which players belong to which groups
|
||||||
|
-- =============================================================
|
||||||
|
CREATE TABLE game_group_members (
|
||||||
|
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,
|
||||||
|
is_gm BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (group_id, player_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- Sessions: individual game sessions with scheduling
|
||||||
|
-- =============================================================
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
join_link TEXT NOT NULL,
|
||||||
|
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'Planned'
|
||||||
|
CHECK (status IN ('Planned','ConfirmationSent','Confirmed','Cancelled')),
|
||||||
|
confirmation_message_id INTEGER,
|
||||||
|
link_message_id INTEGER,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Partial index: only scan sessions that still need processing
|
||||||
|
CREATE INDEX ix_sessions_pending ON sessions (scheduled_at)
|
||||||
|
WHERE status IN ('Planned', 'ConfirmationSent', 'Confirmed');
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- Session participants: per-session RSVP tracking
|
||||||
|
-- =============================================================
|
||||||
|
CREATE TABLE session_participants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||||
|
is_gm BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
rsvp_status VARCHAR(50) NOT NULL DEFAULT 'Pending'
|
||||||
|
CHECK (rsvp_status IN ('Pending','Confirmed','Declined')),
|
||||||
|
responded_at TIMESTAMPTZ,
|
||||||
|
UNIQUE (session_id, player_id)
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add batch_id to sessions to support multiple sessions per message
|
||||||
|
ALTER TABLE sessions ADD COLUMN batch_id UUID NOT NULL DEFAULT gen_random_uuid();
|
||||||
|
CREATE INDEX ix_sessions_batch ON sessions (batch_id);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add thread_id to sessions to store the forum topic ID so we can delete it later
|
||||||
|
ALTER TABLE sessions ADD COLUMN thread_id INTEGER;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Reschedule proposals: tracks GM-initiated time change requests
|
||||||
|
-- Status flow: AwaitingTime → Voting → Approved/Rejected
|
||||||
|
|
||||||
|
CREATE TABLE reschedule_proposals (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
proposed_at TIMESTAMPTZ, -- new proposed time (NULL while AwaitingTime)
|
||||||
|
proposed_by BIGINT NOT NULL, -- GM's telegram_id
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'AwaitingTime'
|
||||||
|
CHECK (status IN ('AwaitingTime', 'Voting', 'Approved', 'Rejected')),
|
||||||
|
vote_message_id INTEGER, -- message ID of the voting message in chat
|
||||||
|
vote_chat_id BIGINT, -- chat where voting takes place
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Only one active proposal per session at a time
|
||||||
|
CREATE UNIQUE INDEX ix_reschedule_active ON reschedule_proposals (session_id)
|
||||||
|
WHERE status IN ('AwaitingTime', 'Voting');
|
||||||
|
|
||||||
|
CREATE TABLE reschedule_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,
|
||||||
|
vote VARCHAR(10) NOT NULL CHECK (vote IN ('yes', 'no')),
|
||||||
|
voted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (proposal_id, player_id)
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Store the message ID of the batch schedule message so we can edit it later
|
||||||
|
ALTER TABLE sessions ADD COLUMN batch_message_id INTEGER;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||||
|
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||||
|
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||||
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
using GmRelay.Bot.Infrastructure.Database;
|
||||||
|
using GmRelay.Bot.Infrastructure.Scheduling;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
|
||||||
|
[module: Dapper.DapperAot]
|
||||||
|
|
||||||
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
|
// ── Aspire service defaults (OpenTelemetry, health checks) ───────────
|
||||||
|
builder.AddServiceDefaults();
|
||||||
|
|
||||||
|
// ── PostgreSQL (ручная регистрация — AOT safe, без Aspire-магии) ─────
|
||||||
|
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||||
|
{
|
||||||
|
var config = sp.GetRequiredService<IConfiguration>();
|
||||||
|
var connectionString = config.GetConnectionString("gmrelaydb")
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb.");
|
||||||
|
|
||||||
|
Console.WriteLine($"[DBG] Master ConnectionString => {connectionString}");
|
||||||
|
return NpgsqlDataSource.Create(connectionString);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Database migrations ──────────────────────────────────────────────
|
||||||
|
builder.Services.AddSingleton<DbMigrator>();
|
||||||
|
|
||||||
|
// ── Telegram Bot Client ──────────────────────────────────────────────
|
||||||
|
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||||
|
{
|
||||||
|
var config = sp.GetRequiredService<IConfiguration>();
|
||||||
|
var token = config["Telegram:BotToken"]
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Telegram:BotToken is required. Set via environment variable Telegram__BotToken or appsettings.json.");
|
||||||
|
return new TelegramBotClient(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
||||||
|
builder.Services.AddSingleton<SendConfirmationHandler>();
|
||||||
|
builder.Services.AddSingleton<HandleRsvpHandler>();
|
||||||
|
builder.Services.AddSingleton<SendJoinLinkHandler>();
|
||||||
|
builder.Services.AddSingleton<CreateSessionHandler>();
|
||||||
|
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||||
|
builder.Services.AddSingleton<CancelSessionHandler>();
|
||||||
|
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.ExportCalendar.ExportCalendarHandler>();
|
||||||
|
builder.Services.AddSingleton<InitiateRescheduleHandler>();
|
||||||
|
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
|
||||||
|
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
|
||||||
|
|
||||||
|
// ── Telegram infrastructure ──────────────────────────────────────────
|
||||||
|
builder.Services.AddSingleton<UpdateRouter>();
|
||||||
|
builder.Services.AddHostedService<TelegramBotService>();
|
||||||
|
|
||||||
|
// ── Session scheduler ────────────────────────────────────────────────
|
||||||
|
builder.Services.AddHostedService<SessionSchedulerService>();
|
||||||
|
|
||||||
|
var host = builder.Build();
|
||||||
|
|
||||||
|
// ── Run database migrations on startup ───────────────────────────────
|
||||||
|
var migrator = host.Services.GetRequiredService<DbMigrator>();
|
||||||
|
migrator.MigrateUp();
|
||||||
|
|
||||||
|
await host.RunAsync();
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"GmRelay.Bot": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information",
|
||||||
|
"GmRelay": "Debug"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Telegram": {
|
||||||
|
"BotToken": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.ServiceDiscovery;
|
||||||
|
using OpenTelemetry;
|
||||||
|
using OpenTelemetry.Metrics;
|
||||||
|
using OpenTelemetry.Trace;
|
||||||
|
|
||||||
|
namespace Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
|
||||||
|
// This project should be referenced by each service project in your solution.
|
||||||
|
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
|
||||||
|
public static class Extensions
|
||||||
|
{
|
||||||
|
private const string HealthEndpointPath = "/health";
|
||||||
|
private const string AlivenessEndpointPath = "/alive";
|
||||||
|
|
||||||
|
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||||
|
{
|
||||||
|
builder.ConfigureOpenTelemetry();
|
||||||
|
|
||||||
|
builder.AddDefaultHealthChecks();
|
||||||
|
|
||||||
|
builder.Services.AddServiceDiscovery();
|
||||||
|
|
||||||
|
builder.Services.ConfigureHttpClientDefaults(http =>
|
||||||
|
{
|
||||||
|
// Turn on resilience by default
|
||||||
|
http.AddStandardResilienceHandler();
|
||||||
|
|
||||||
|
// Turn on service discovery by default
|
||||||
|
http.AddServiceDiscovery();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uncomment the following to restrict the allowed schemes for service discovery.
|
||||||
|
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
|
||||||
|
// {
|
||||||
|
// options.AllowedSchemes = ["https"];
|
||||||
|
// });
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||||
|
{
|
||||||
|
builder.Logging.AddOpenTelemetry(logging =>
|
||||||
|
{
|
||||||
|
logging.IncludeFormattedMessage = true;
|
||||||
|
logging.IncludeScopes = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddOpenTelemetry()
|
||||||
|
.WithMetrics(metrics =>
|
||||||
|
{
|
||||||
|
metrics.AddAspNetCoreInstrumentation()
|
||||||
|
.AddHttpClientInstrumentation()
|
||||||
|
.AddRuntimeInstrumentation();
|
||||||
|
})
|
||||||
|
.WithTracing(tracing =>
|
||||||
|
{
|
||||||
|
tracing.AddSource(builder.Environment.ApplicationName)
|
||||||
|
.AddAspNetCoreInstrumentation(tracing =>
|
||||||
|
// Exclude health check requests from tracing
|
||||||
|
tracing.Filter = context =>
|
||||||
|
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
|
||||||
|
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
|
||||||
|
)
|
||||||
|
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
|
||||||
|
//.AddGrpcClientInstrumentation()
|
||||||
|
.AddHttpClientInstrumentation();
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.AddOpenTelemetryExporters();
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||||
|
{
|
||||||
|
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
|
||||||
|
|
||||||
|
if (useOtlpExporter)
|
||||||
|
{
|
||||||
|
builder.Services.AddOpenTelemetry().UseOtlpExporter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
|
||||||
|
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
|
||||||
|
//{
|
||||||
|
// builder.Services.AddOpenTelemetry()
|
||||||
|
// .UseAzureMonitor();
|
||||||
|
//}
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||||
|
{
|
||||||
|
builder.Services.AddHealthChecks()
|
||||||
|
// Add a default liveness check to ensure app is responsive
|
||||||
|
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
// Adding health checks endpoints to applications in non-development environments has security implications.
|
||||||
|
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
// All health checks must pass for app to be considered ready to accept traffic after starting
|
||||||
|
app.MapHealthChecks(HealthEndpointPath);
|
||||||
|
|
||||||
|
// Only health checks tagged with the "live" tag must pass for app to be considered alive
|
||||||
|
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
|
||||||
|
{
|
||||||
|
Predicate = r => r.Tags.Contains("live")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http.Resilience" 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.Extensions.Hosting" Version="1.15.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\GmRelay.Bot\GmRelay.Bot.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace GmRelay.Bot.Tests;
|
||||||
|
|
||||||
|
public class UnitTest1
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Test1()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user