Initial commit: GM-Relay Telegram Bot

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