From 9db4bee2f6f92bddfe7d0736ed1351e7f40130e5 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 13 Apr 2026 13:52:49 +0300 Subject: [PATCH] Initial commit: GM-Relay Telegram Bot --- .env.example | 6 + .gitignore | 28 ++ Directory.Build.props | 9 + GM-Relay.slnx | 10 + README.md | 120 +++++++ compose.yaml | 35 ++ ...se-vertical-slice-native-aot-and-aspire.md | 83 +++++ docs/c4-system-context.md | 78 +++++ global.json | 6 + init.ps1 | 92 +++++ src/GmRelay.AppHost/GmRelay.AppHost.csproj | 19 + src/GmRelay.AppHost/Program.cs | 11 + .../Properties/launchSettings.json | 31 ++ .../appsettings.Development.json | 8 + src/GmRelay.AppHost/appsettings.json | 9 + src/GmRelay.AppHost/aspire.config.json | 5 + src/GmRelay.Bot/Dockerfile | 37 ++ src/GmRelay.Bot/Domain/MoscowTime.cs | 42 +++ src/GmRelay.Bot/Domain/RsvpStatus.cs | 8 + src/GmRelay.Bot/Domain/SessionStatus.cs | 9 + .../HandleRsvp/HandleRsvpHandler.cs | 330 ++++++++++++++++++ .../SendConfirmationHandler.cs | 123 +++++++ .../SendJoinLink/SendJoinLinkHandler.cs | 103 ++++++ .../CreateSession/CancelSessionHandler.cs | 92 +++++ .../CreateSession/CreateSessionHandler.cs | 152 ++++++++ .../CreateSession/JoinSessionHandler.cs | 99 ++++++ .../CreateSession/SessionBatchRenderer.cs | 63 ++++ .../ExportCalendar/ExportCalendarHandler.cs | 76 ++++ .../ListSessions/DeleteSessionHandler.cs | 123 +++++++ .../ListSessions/ListSessionsHandler.cs | 61 ++++ .../HandleRescheduleTimeInputHandler.cs | 265 ++++++++++++++ .../HandleRescheduleVoteHandler.cs | 313 +++++++++++++++++ .../InitiateRescheduleHandler.cs | 96 +++++ src/GmRelay.Bot/GmRelay.Bot.csproj | 37 ++ .../Infrastructure/Database/DbMigrator.cs | 40 +++ .../Scheduling/SessionSchedulerService.cs | 117 +++++++ .../Telegram/TelegramBotService.cs | 73 ++++ .../Infrastructure/Telegram/UpdateRouter.cs | 207 +++++++++++ .../Migrations/V001__initial_schema.sql | 70 ++++ .../Migrations/V002__add_session_batch.sql | 3 + .../Migrations/V003__add_thread_id.sql | 2 + .../V004__add_reschedule_proposals.sql | 27 ++ .../Migrations/V005__add_batch_message_id.sql | 2 + src/GmRelay.Bot/Program.cs | 71 ++++ .../Properties/launchSettings.json | 12 + src/GmRelay.Bot/appsettings.Development.json | 8 + src/GmRelay.Bot/appsettings.json | 12 + src/GmRelay.ServiceDefaults/Extensions.cs | 127 +++++++ .../GmRelay.ServiceDefaults.csproj | 22 ++ .../GmRelay.Bot.Tests.csproj | 25 ++ tests/GmRelay.Bot.Tests/UnitTest1.cs | 10 + 51 files changed, 3407 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 GM-Relay.slnx create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md create mode 100644 docs/c4-system-context.md create mode 100644 global.json create mode 100644 init.ps1 create mode 100644 src/GmRelay.AppHost/GmRelay.AppHost.csproj create mode 100644 src/GmRelay.AppHost/Program.cs create mode 100644 src/GmRelay.AppHost/Properties/launchSettings.json create mode 100644 src/GmRelay.AppHost/appsettings.Development.json create mode 100644 src/GmRelay.AppHost/appsettings.json create mode 100644 src/GmRelay.AppHost/aspire.config.json create mode 100644 src/GmRelay.Bot/Dockerfile create mode 100644 src/GmRelay.Bot/Domain/MoscowTime.cs create mode 100644 src/GmRelay.Bot/Domain/RsvpStatus.cs create mode 100644 src/GmRelay.Bot/Domain/SessionStatus.cs create mode 100644 src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs create mode 100644 src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs create mode 100644 src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs create mode 100644 src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs create mode 100644 src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs create mode 100644 src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs create mode 100644 src/GmRelay.Bot/Features/Sessions/CreateSession/SessionBatchRenderer.cs create mode 100644 src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs create mode 100644 src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs create mode 100644 src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs create mode 100644 src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs create mode 100644 src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs create mode 100644 src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs create mode 100644 src/GmRelay.Bot/GmRelay.Bot.csproj create mode 100644 src/GmRelay.Bot/Infrastructure/Database/DbMigrator.cs create mode 100644 src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs create mode 100644 src/GmRelay.Bot/Infrastructure/Telegram/TelegramBotService.cs create mode 100644 src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs create mode 100644 src/GmRelay.Bot/Migrations/V001__initial_schema.sql create mode 100644 src/GmRelay.Bot/Migrations/V002__add_session_batch.sql create mode 100644 src/GmRelay.Bot/Migrations/V003__add_thread_id.sql create mode 100644 src/GmRelay.Bot/Migrations/V004__add_reschedule_proposals.sql create mode 100644 src/GmRelay.Bot/Migrations/V005__add_batch_message_id.sql create mode 100644 src/GmRelay.Bot/Program.cs create mode 100644 src/GmRelay.Bot/Properties/launchSettings.json create mode 100644 src/GmRelay.Bot/appsettings.Development.json create mode 100644 src/GmRelay.Bot/appsettings.json create mode 100644 src/GmRelay.ServiceDefaults/Extensions.cs create mode 100644 src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj create mode 100644 tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj create mode 100644 tests/GmRelay.Bot.Tests/UnitTest1.cs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2f07ad7 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Telegram Bot Token (ОБЯЗАТЕЛЬНАЯ НАСТРОЙКА!) +# Можно получить у @BotFather в Telegram +TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN_HERE + +# Пароль для базы данных PostgreSQL +POSTGRES_PASSWORD=StrongPasswordForDatabase diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4aa1493 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..6598721 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,9 @@ + + + net10.0 + preview + enable + enable + true + + diff --git a/GM-Relay.slnx b/GM-Relay.slnx new file mode 100644 index 0000000..fd08e5c --- /dev/null +++ b/GM-Relay.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f39ac5e --- /dev/null +++ b/README.md @@ -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. Использование в некоммерческих целях приветствуется. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..1a0466c --- /dev/null +++ b/compose.yaml @@ -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: diff --git a/docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md b/docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md new file mode 100644 index 0000000..3337b7f --- /dev/null +++ b/docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md @@ -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`. diff --git a/docs/c4-system-context.md b/docs/c4-system-context.md new file mode 100644 index 0000000..c16acfb --- /dev/null +++ b/docs/c4-system-context.md @@ -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") +``` diff --git a/global.json b/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/init.ps1 b/init.ps1 new file mode 100644 index 0000000..9ab3ae5 --- /dev/null +++ b/init.ps1 @@ -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 "" diff --git a/src/GmRelay.AppHost/GmRelay.AppHost.csproj b/src/GmRelay.AppHost/GmRelay.AppHost.csproj new file mode 100644 index 0000000..15babf5 --- /dev/null +++ b/src/GmRelay.AppHost/GmRelay.AppHost.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + Exe + net10.0 + enable + enable + 7d5a0cc1-d34c-4343-82b1-9db76d513fee + + + diff --git a/src/GmRelay.AppHost/Program.cs b/src/GmRelay.AppHost/Program.cs new file mode 100644 index 0000000..11486ce --- /dev/null +++ b/src/GmRelay.AppHost/Program.cs @@ -0,0 +1,11 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var postgres = builder.AddPostgres("postgres") + .WithPgAdmin() + .AddDatabase("gmrelay-db"); + +builder.AddProject("bot") + .WithReference(postgres) + .WaitFor(postgres); + +builder.Build().Run(); diff --git a/src/GmRelay.AppHost/Properties/launchSettings.json b/src/GmRelay.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..43bc334 --- /dev/null +++ b/src/GmRelay.AppHost/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/src/GmRelay.AppHost/appsettings.Development.json b/src/GmRelay.AppHost/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/GmRelay.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/GmRelay.AppHost/appsettings.json b/src/GmRelay.AppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/src/GmRelay.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/GmRelay.AppHost/aspire.config.json b/src/GmRelay.AppHost/aspire.config.json new file mode 100644 index 0000000..4076af0 --- /dev/null +++ b/src/GmRelay.AppHost/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "GmRelay.AppHost.csproj" + } +} diff --git a/src/GmRelay.Bot/Dockerfile b/src/GmRelay.Bot/Dockerfile new file mode 100644 index 0000000..820392c --- /dev/null +++ b/src/GmRelay.Bot/Dockerfile @@ -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"] diff --git a/src/GmRelay.Bot/Domain/MoscowTime.cs b/src/GmRelay.Bot/Domain/MoscowTime.cs new file mode 100644 index 0000000..26df911 --- /dev/null +++ b/src/GmRelay.Bot/Domain/MoscowTime.cs @@ -0,0 +1,42 @@ +namespace GmRelay.Bot.Domain; + +/// +/// 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. +/// +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; + } +} diff --git a/src/GmRelay.Bot/Domain/RsvpStatus.cs b/src/GmRelay.Bot/Domain/RsvpStatus.cs new file mode 100644 index 0000000..34daaeb --- /dev/null +++ b/src/GmRelay.Bot/Domain/RsvpStatus.cs @@ -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"; +} diff --git a/src/GmRelay.Bot/Domain/SessionStatus.cs b/src/GmRelay.Bot/Domain/SessionStatus.cs new file mode 100644 index 0000000..2007200 --- /dev/null +++ b/src/GmRelay.Bot/Domain/SessionStatus.cs @@ -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"; +} diff --git a/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs b/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs new file mode 100644 index 0000000..f782881 --- /dev/null +++ b/src/GmRelay.Bot/Features/Confirmation/HandleRsvp/HandleRsvpHandler.cs @@ -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 ────────────────────────────────────────────────────────── + +/// +/// 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. +/// +public sealed class HandleRsvpHandler( + NpgsqlDataSource dataSource, + ITelegramBotClient bot, + ILogger 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( + """ + 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( + """ + 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( + "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( + """ + 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); + } + + /// + /// Re-renders the confirmation message with current RSVP statuses. + /// + private async Task UpdateConfirmationMessage( + HandleRsvpCommand command, SessionContext session, CancellationToken ct) + { + try + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var participants = (await connection.QueryAsync( + """ + 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 + { + $"🎲 Подтвердите участие в «{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; +} diff --git a/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs b/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs new file mode 100644 index 0000000..efb5bdb --- /dev/null +++ b/src/GmRelay.Bot/Features/Confirmation/SendConfirmation/SendConfirmationHandler.cs @@ -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 ────────────────────────────────────────────────────────── + +/// +/// Sends the interactive confirmation message (inline keyboard) to the group chat. +/// Called by SessionSchedulerService at T-24h. +/// +public sealed class SendConfirmationHandler( + NpgsqlDataSource dataSource, + ITelegramBotClient bot, + ILogger 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( + """ + 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( + """ + 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; +} diff --git a/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs b/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs new file mode 100644 index 0000000..35b1cf2 --- /dev/null +++ b/src/GmRelay.Bot/Features/Reminders/SendJoinLink/SendJoinLinkHandler.cs @@ -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 ────────────────────────────────────────────────────────── + +/// +/// Sends the join link to the group chat at T-5min, tagging all confirmed players. +/// Called by SessionSchedulerService. +/// +public sealed class SendJoinLinkHandler( + NpgsqlDataSource dataSource, + ITelegramBotClient bot, + ILogger 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( + """ + 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( + """ + 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); + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs new file mode 100644 index 0000000..b036d20 --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs @@ -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 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( + @"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( + @"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( + @"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, $"❌ Внимание! Сессия \"{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); + } + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs new file mode 100644 index 0000000..720d84f --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -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 logger) +{ + public async Task HandleAsync(Message message, CancellationToken cancellationToken) + { + var text = message.Text ?? ""; + + string? title = null; + string? link = null; + var scheduledTimes = new List(); + + 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( + @"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(); + + foreach (var dt in scheduledTimes.OrderBy(d => d)) + { + var sessionId = await connection.ExecuteScalarAsync( + @"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()); + + + 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); + } + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs new file mode 100644 index 0000000..f3d22f7 --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs @@ -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 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( + @"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( + @"SELECT batch_id as BatchId, title as Title FROM sessions WHERE id = @SessionId", + new { command.SessionId }, transaction); + + // Загружаем весь батч для перерисовки + var batchSessions = await connection.QueryAsync( + @"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( + @"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); + } + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/SessionBatchRenderer.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/SessionBatchRenderer.cs new file mode 100644 index 0000000..1b774e8 --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/SessionBatchRenderer.cs @@ -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 sessions, + IReadOnlyList participants) + { + var activeSessions = sessions.OrderBy(s => s.ScheduledAt).ToList(); + + var messageText = $"🎲 Новые игры: {System.Net.WebUtility.HtmlEncode(title)}\n\n" + + $"Расписание:\n\n"; + + var buttons = new List(); + + foreach (var session in activeSessions) + { + var sessionPlayers = participants.Where(p => p.SessionId == session.SessionId).ToList(); + + messageText += $"📅 {session.ScheduledAt.FormatMoscow()}\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 += " Пока никто не записался\n"; + } + + if (session.Status == "Cancelled") + { + messageText += "❌ Сессия отменена\n\n"; + } + else if (session.Status == "RecruitmentClosed") // custom state we can derive or use + { + messageText += "🔒 Набор завершен\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)); + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs b/src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs new file mode 100644 index 0000000..b9b9f50 --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs @@ -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( + @"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: "📅 Ваш календарь игр!\nОткройте файл на устройстве, чтобы добавить события в свой календарь.", + parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, + messageThreadId: message.MessageThreadId, + cancellationToken: cancellationToken); + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs new file mode 100644 index 0000000..ab80946 --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs @@ -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 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( + @"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( + "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( + @"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 = "📅 Ближайшие игры:\n\n"; + foreach (var s in sessionsList) + { + text += $"🔹 {s.ScheduledAt.FormatMoscow()} — {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"); + } + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs b/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs new file mode 100644 index 0000000..59237bd --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/ListSessions/ListSessionsHandler.cs @@ -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( + @"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 = "📅 Ближайшие игры:\n\n"; + foreach (var s in sessionsList) + { + text += $"🔹 {s.ScheduledAt.FormatMoscow()} — {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); + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs new file mode 100644 index 0000000..45172c9 --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs @@ -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 ────────────────────────────────────────────────────────── + +/// +/// 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. +/// +public sealed class HandleRescheduleTimeInputHandler( + NpgsqlDataSource dataSource, + ITelegramBotClient bot, + ILogger logger) +{ + /// + /// Attempts to handle a text message as reschedule time input. + /// Returns true if it was handled (i.e. user had an AwaitingTime proposal). + /// + public async Task 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( + """ + 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: "⚠️ Не удалось распознать время. Используйте формат: ДД.ММ.ГГГГ ЧЧ:ММ\nНапример: 25.04.2026 19:30", + 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( + """ + 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📅 Новое время: {newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))} (МСК)\n\nУчастников нет — голосование не требуется.", + 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 participants, + IReadOnlyCollection approvedPlayerIds) + { + var lines = new List + { + $"🔄 Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»", + "", + $"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)", + $"📅 Новое время: {newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))} (МСК)", + "", + "Для переноса нужно согласие всех участников:" + }; + + 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( + "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( + """ + 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); + } + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs new file mode 100644 index 0000000..e611a2e --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs @@ -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 ────────────────────────────────────────────────────────── + +/// +/// 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. +/// +public sealed class HandleRescheduleVoteHandler( + NpgsqlDataSource dataSource, + ITelegramBotClient bot, + ILogger 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( + """ + 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( + """ + 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( + "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: $"❌ Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» отклонён!\n\n{voterName ?? "Участник"} проголосовал(а) против. Время сессии остаётся прежним:\n📅 {proposal.CurrentScheduledAt.FormatMoscow()} (МСК)", + 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( + """ + 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( + """ + 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: $"✅ Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» одобрен!\n\nВсе участники согласились.\n📅 Новое время: {proposal.ProposedAt.FormatMoscow()} (МСК)\n\nУведомления будут приходить согласно новому расписанию.", + 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); + } + + /// + /// 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. + /// + private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct) + { + try + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var batchSessions = (await connection.QueryAsync( + "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( + """ + 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}» перенесена на {proposal.ProposedAt.FormatMoscow()} (МСК).", + 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); + } + } +} diff --git a/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs new file mode 100644 index 0000000..2debb3d --- /dev/null +++ b/src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs @@ -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 ────────────────────────────────────────────────────────── + +/// +/// 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. +/// +public sealed class InitiateRescheduleHandler( + NpgsqlDataSource dataSource, + ITelegramBotClient bot, + ILogger 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( + """ + 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( + """ + 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ДД.ММ.ГГГГ ЧЧ:ММ\n\nНапример: 25.04.2026 19:30", + parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, + cancellationToken: ct); + } +} diff --git a/src/GmRelay.Bot/GmRelay.Bot.csproj b/src/GmRelay.Bot/GmRelay.Bot.csproj new file mode 100644 index 0000000..06d0c98 --- /dev/null +++ b/src/GmRelay.Bot/GmRelay.Bot.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + preview + enable + enable + dotnet-GmRelay.Bot-f3419d27-b8b5-4b14-b9d2-77289d82d372 + + + true + true + false + + + $(InterceptorsPreviewNamespaces);Dapper.AOT + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GmRelay.Bot/Infrastructure/Database/DbMigrator.cs b/src/GmRelay.Bot/Infrastructure/Database/DbMigrator.cs new file mode 100644 index 0000000..a70d7fe --- /dev/null +++ b/src/GmRelay.Bot/Infrastructure/Database/DbMigrator.cs @@ -0,0 +1,40 @@ +using System.Reflection; +using DbUp; + +namespace GmRelay.Bot.Infrastructure.Database; + +/// +/// 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. +/// +public sealed class DbMigrator(IConfiguration configuration, ILogger 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); + } +} diff --git a/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs b/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs new file mode 100644 index 0000000..c55272d --- /dev/null +++ b/src/GmRelay.Bot/Infrastructure/Scheduling/SessionSchedulerService.cs @@ -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; + +/// +/// 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. +/// +public sealed class SessionSchedulerService( + NpgsqlDataSource dataSource, + SendConfirmationHandler confirmationHandler, + SendJoinLinkHandler joinLinkHandler, + ILogger 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"); + } + + /// + /// T-24h trigger: find sessions that need confirmation requests sent. + /// Condition: status='Planned' AND scheduled_at minus 24h is in the past. + /// + private async Task ProcessConfirmationTriggers(CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var sessionIds = await connection.QueryAsync( + """ + 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); + } + } + } + + /// + /// 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. + /// + private async Task ProcessJoinLinkTriggers(CancellationToken ct) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var sessionIds = await connection.QueryAsync( + """ + 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); + } + } + } +} diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramBotService.cs b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramBotService.cs new file mode 100644 index 0000000..41fe2c5 --- /dev/null +++ b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramBotService.cs @@ -0,0 +1,73 @@ +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; + +namespace GmRelay.Bot.Infrastructure.Telegram; + +/// +/// Long polling loop for Telegram Bot API. +/// Stateless — all state is in PostgreSQL. Safe to restart at any time. +/// +public sealed class TelegramBotService( + ITelegramBotClient bot, + UpdateRouter router, + ILogger 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"); + } +} diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs new file mode 100644 index 0000000..e08e0c7 --- /dev/null +++ b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs @@ -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; + +/// +/// Routes incoming Telegram updates to the appropriate feature handler. +/// No reflection — all routing is explicit (AOT-safe). +/// +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 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; + } + } +} diff --git a/src/GmRelay.Bot/Migrations/V001__initial_schema.sql b/src/GmRelay.Bot/Migrations/V001__initial_schema.sql new file mode 100644 index 0000000..5693981 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V001__initial_schema.sql @@ -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) +); diff --git a/src/GmRelay.Bot/Migrations/V002__add_session_batch.sql b/src/GmRelay.Bot/Migrations/V002__add_session_batch.sql new file mode 100644 index 0000000..62b21c6 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V002__add_session_batch.sql @@ -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); diff --git a/src/GmRelay.Bot/Migrations/V003__add_thread_id.sql b/src/GmRelay.Bot/Migrations/V003__add_thread_id.sql new file mode 100644 index 0000000..34b2690 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V003__add_thread_id.sql @@ -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; diff --git a/src/GmRelay.Bot/Migrations/V004__add_reschedule_proposals.sql b/src/GmRelay.Bot/Migrations/V004__add_reschedule_proposals.sql new file mode 100644 index 0000000..2d8bb16 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V004__add_reschedule_proposals.sql @@ -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) +); diff --git a/src/GmRelay.Bot/Migrations/V005__add_batch_message_id.sql b/src/GmRelay.Bot/Migrations/V005__add_batch_message_id.sql new file mode 100644 index 0000000..a8a9a95 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V005__add_batch_message_id.sql @@ -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; diff --git a/src/GmRelay.Bot/Program.cs b/src/GmRelay.Bot/Program.cs new file mode 100644 index 0000000..5f8a7bb --- /dev/null +++ b/src/GmRelay.Bot/Program.cs @@ -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(sp => +{ + var config = sp.GetRequiredService(); + 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(); + +// ── Telegram Bot Client ────────────────────────────────────────────── +builder.Services.AddSingleton(sp => +{ + var config = sp.GetRequiredService(); + 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(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// ── Telegram infrastructure ────────────────────────────────────────── +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +// ── Session scheduler ──────────────────────────────────────────────── +builder.Services.AddHostedService(); + +var host = builder.Build(); + +// ── Run database migrations on startup ─────────────────────────────── +var migrator = host.Services.GetRequiredService(); +migrator.MigrateUp(); + +await host.RunAsync(); diff --git a/src/GmRelay.Bot/Properties/launchSettings.json b/src/GmRelay.Bot/Properties/launchSettings.json new file mode 100644 index 0000000..9aeb4a7 --- /dev/null +++ b/src/GmRelay.Bot/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "GmRelay.Bot": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/GmRelay.Bot/appsettings.Development.json b/src/GmRelay.Bot/appsettings.Development.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/src/GmRelay.Bot/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/GmRelay.Bot/appsettings.json b/src/GmRelay.Bot/appsettings.json new file mode 100644 index 0000000..158aa8e --- /dev/null +++ b/src/GmRelay.Bot/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "GmRelay": "Debug" + } + }, + "Telegram": { + "BotToken": "" + } +} diff --git a/src/GmRelay.ServiceDefaults/Extensions.cs b/src/GmRelay.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..b72c875 --- /dev/null +++ b/src/GmRelay.ServiceDefaults/Extensions.cs @@ -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(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(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(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(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(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; + } +} diff --git a/src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj b/src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj new file mode 100644 index 0000000..b71d3c7 --- /dev/null +++ b/src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj b/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj new file mode 100644 index 0000000..bb1ef74 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/GmRelay.Bot.Tests/UnitTest1.cs b/tests/GmRelay.Bot.Tests/UnitTest1.cs new file mode 100644 index 0000000..f1e7c7e --- /dev/null +++ b/tests/GmRelay.Bot.Tests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace GmRelay.Bot.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +}