Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b32f962f11 | |||
| 0c1d3abd7e | |||
| d81564c308 | |||
| accb3b2405 | |||
| a63e3bef1e | |||
| 9d9aca53df | |||
| 5b6971fda5 | |||
| b496a401fc | |||
| 76c6818952 | |||
| 633a020212 | |||
| ab38238fe8 | |||
| 4145cacc52 | |||
| 6d59737d07 | |||
| 71ffcce06b | |||
| 72f43dbef2 | |||
| a5f4a68c6a | |||
| b2497ed877 | |||
| 9b42ea034a | |||
| f94bea3e74 | |||
| cde1e4311f | |||
| 847a40815f | |||
| 6fd03ef836 | |||
| c2ccc35e50 | |||
| 3418d1a46c | |||
| fac5d75c7e | |||
| 7a2965b43f | |||
| a0df94fc91 | |||
| 79694f7de8 | |||
| 542f15f2d6 | |||
| 64216f5a26 | |||
| 383e2c1d8d | |||
| bfa979a224 | |||
| c69ebf6c03 | |||
| 040b0a3cdb | |||
| a5aed14dd2 | |||
| 9fc434b42b | |||
| c2cc7fd9a8 | |||
| 3447acd8c4 | |||
| 56aeca5288 | |||
| 6ed0a120a0 | |||
| 682dd3fdec | |||
| c955e1572f | |||
| a9aa84af0f | |||
| dcbd9bab41 | |||
| 92d5d9c2d3 | |||
| 47d106e288 | |||
| a5624897e9 | |||
| 11e75d036a | |||
| 2942da0c35 | |||
| 549c0c96ae | |||
| dd9337dd20 | |||
| 3cc3b373e5 | |||
| f6d5281af8 | |||
| fa63886195 | |||
| 9bd5fe75c9 | |||
| d931da37ec | |||
| 9375fa45b2 | |||
| 0b45aee96d | |||
| 80e346d6b5 | |||
| eff0128d29 | |||
| 8214e052af | |||
| 2a233b2b1e | |||
| 5e3028e470 | |||
| 63193310f2 | |||
| af37f3a8ec | |||
| 66228cf106 | |||
| 9c59240f48 | |||
| baa25f2e1e | |||
| 7a2ed808c4 | |||
| dd0828a63d | |||
| 72a392e652 | |||
| e1fac04775 | |||
| 7e02e86cd6 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 2.8.0
|
VERSION: 3.5.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>2.8.0</Version>
|
<Version>3.5.0</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||||
|
|
||||||
**Текущая версия:** `v2.8.0`.
|
**Текущая версия:** `v3.5.0`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -37,6 +37,8 @@
|
|||||||
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
|
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
|
||||||
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
|
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
|
||||||
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
||||||
|
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
|
||||||
|
- **🧑🏫 Публичные профили мастеров**: мастер управляет профилем из `/profile`, публикует описание на `/gm/{slug}`, а публичные клубы, игры и каталог ссылаются на профиль без раскрытия platform identifiers.
|
||||||
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
|
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
|
||||||
- **📦 Bulk-операции для Batch Sessions**:
|
- **📦 Bulk-операции для Batch Sessions**:
|
||||||
- обновить общий `title`/`link` у всей пачки;
|
- обновить общий `title`/`link` у всей пачки;
|
||||||
@@ -85,8 +87,10 @@ TELEGRAM_BOT_TOKEN=ваш_токен_здесь
|
|||||||
# Токен Discord application bot
|
# Токен Discord application bot
|
||||||
DISCORD_BOT_TOKEN=ваш_discord_токен_здесь
|
DISCORD_BOT_TOKEN=ваш_discord_токен_здесь
|
||||||
|
|
||||||
# Client ID Discord application (используется для slash-команд)
|
# Discord OAuth (для Web Dashboard)
|
||||||
DISCORD_BOT_CLIENT_ID=ваш_discord_client_id_здесь
|
DISCORD_CLIENT_ID=ваш_discord_client_id_здесь
|
||||||
|
DISCORD_CLIENT_SECRET=ваш_discord_client_secret_здесь
|
||||||
|
DISCORD_REDIRECT_URI=https://your-domain.example/auth/discord/callback
|
||||||
|
|
||||||
# Имя бота без @ (для Telegram Login Widget)
|
# Имя бота без @ (для Telegram Login Widget)
|
||||||
TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
|
TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
|
||||||
@@ -119,7 +123,7 @@ docker compose up -d
|
|||||||
1. Напишите боту `/start`.
|
1. Напишите боту `/start`.
|
||||||
2. Создайте группу через `/newgroup`.
|
2. Создайте группу через `/newgroup`.
|
||||||
3. Откройте Mini App или Web Dashboard для расширенного управления.
|
3. Откройте Mini App или Web Dashboard для расширенного управления.
|
||||||
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` и `DISCORD_BOT_CLIENT_ID` в `.env`.
|
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord.
|
||||||
5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
|
5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
|
||||||
|
|
||||||
## 💾 Backup и восстановление
|
## 💾 Backup и восстановление
|
||||||
|
|||||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
|||||||
crond -f
|
crond -f
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.8.0
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.5.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.8.0
|
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.5.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -84,7 +84,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:2.8.0
|
image: git.codeanddice.ru/toutsu/gmrelay-web:3.5.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -8,17 +8,19 @@ C4Context
|
|||||||
|
|
||||||
Person(gm, "Game Master", "Creates sessions and manages schedules")
|
Person(gm, "Game Master", "Creates sessions and manages schedules")
|
||||||
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
|
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
|
||||||
|
Person(visitor, "Public visitor", "Views published club schedules, sessions, and GM profiles without private player data")
|
||||||
|
|
||||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, and shared scheduling logic")
|
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club/session/GM profile pages, and shared scheduling logic")
|
||||||
|
|
||||||
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
|
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
|
||||||
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
|
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
|
||||||
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities")
|
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities, sanitized master_profiles")
|
||||||
|
|
||||||
Rel(gm, telegram, "Creates and manages sessions")
|
Rel(gm, telegram, "Creates and manages sessions")
|
||||||
Rel(gm, discord, "Uses /newsession and /listsessions")
|
Rel(gm, discord, "Uses /newsession and /listsessions")
|
||||||
Rel(player, telegram, "Uses inline buttons")
|
Rel(player, telegram, "Uses inline buttons")
|
||||||
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
|
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
|
||||||
|
Rel(visitor, gmrelay, "Views public club, session, and GM profile pages")
|
||||||
Rel(telegram, gmrelay, "Updates via long polling")
|
Rel(telegram, gmrelay, "Updates via long polling")
|
||||||
Rel(discord, gmrelay, "Gateway events and component interactions")
|
Rel(discord, gmrelay, "Gateway events and component interactions")
|
||||||
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
||||||
@@ -34,13 +36,14 @@ C4Container
|
|||||||
|
|
||||||
Person(gm, "Game Master")
|
Person(gm, "Game Master")
|
||||||
Person(player, "Player")
|
Person(player, "Player")
|
||||||
|
Person(visitor, "Public visitor")
|
||||||
|
|
||||||
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
|
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
|
||||||
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
|
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
|
||||||
Container(discordBot, "Discord Gateway Worker", "Внутри GmRelay.Bot", "NetCord Gateway, slash commands, scheduler notifications, button interactions, healthcheck :8082")
|
Container(discordBot, "Discord Gateway Worker", "Внутри GmRelay.Bot", "NetCord Gateway, slash commands, scheduler notifications, button interactions, healthcheck :8082")
|
||||||
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, editing and stats")
|
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club/session/GM profile pages, editing and stats")
|
||||||
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
|
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
|
||||||
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, platform identities")
|
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, master_profiles, platform identities")
|
||||||
}
|
}
|
||||||
|
|
||||||
System_Ext(telegram, "Telegram Bot API")
|
System_Ext(telegram, "Telegram Bot API")
|
||||||
@@ -50,6 +53,7 @@ C4Container
|
|||||||
Rel(gm, discord, "Slash commands")
|
Rel(gm, discord, "Slash commands")
|
||||||
Rel(player, telegram, "Callback queries")
|
Rel(player, telegram, "Callback queries")
|
||||||
Rel(player, discord, "Button interactions")
|
Rel(player, discord, "Button interactions")
|
||||||
|
Rel(visitor, web, "Read-only public schedule and sanitized GM profile pages")
|
||||||
Rel(telegram, bot, "GetUpdates")
|
Rel(telegram, bot, "GetUpdates")
|
||||||
Rel(discord, discordBot, "Gateway events")
|
Rel(discord, discordBot, "Gateway events")
|
||||||
Rel(bot, telegram, "Bot API calls")
|
Rel(bot, telegram, "Bot API calls")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
|||||||
|
# Game Catalog and One-Shot Showcase — Design Spec
|
||||||
|
|
||||||
|
> Issue #39: feat: добавить каталог игр и витрину ваншотов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Build a public `/showcase` page that aggregates published sessions from all clubs into a filterable catalog. Users can browse games by system, format, date, and availability. GM controls whether direct registration from the catalog is allowed. The catalog respects existing seat limits and waitlist logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
Extend the existing public-pages infrastructure (V026) with new session metadata fields, a cross-group query layer in `ISessionStore`, and new Razor pages in `GmRelay.Web`. Bot flows (Telegram + Discord) are updated to collect the new fields during session creation. Fuzzy matching on game system names is performed client-side in the bot UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- .NET 10, Blazor Server, Dapper.AOT, Npgsql
|
||||||
|
- Existing: `PublicLayout`, `ISessionStore`, `SessionService`, `SessionCapacityRules`
|
||||||
|
- New: `GameSystem` enum, `ShowcaseFilter` record, `ShowcaseSessionDto`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### New Fields on `sessions` (Migration V027)
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `is_one_shot` | `BOOLEAN` | `NOT NULL DEFAULT false` | One-shot or campaign |
|
||||||
|
| `system` | `VARCHAR(50)` | nullable | Game system name (enum value or custom) |
|
||||||
|
| `description` | `TEXT` | nullable | Short description for card |
|
||||||
|
| `cover_image_url` | `TEXT` | nullable | Cover image URL |
|
||||||
|
| `duration_minutes` | `INTEGER` | nullable | Duration in minutes |
|
||||||
|
| `format` | `VARCHAR(20)` | `CHECK (format IN ('Online','Offline','Hybrid'))`, nullable | Session format |
|
||||||
|
| `allow_direct_registration` | `BOOLEAN` | `NOT NULL DEFAULT false` | Allow direct registration from showcase |
|
||||||
|
|
||||||
|
### `GameSystem` Enum
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum GameSystem
|
||||||
|
{
|
||||||
|
Dnd5e, Pathfinder2e, CallOfCthulhu7e, Shadowdark,
|
||||||
|
OldSchoolEssentials, Dragonbane, BladesInTheDark,
|
||||||
|
Daggerheart, CyberpunkRed, Mothership, AlienRpg,
|
||||||
|
WarhammerFantasy, VampireMasquerade5e, StarWarsFfg,
|
||||||
|
Genesys, SavageWorlds, GURPS, Fate, DungeonWorld,
|
||||||
|
Ironsworn, Other
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stored as `VARCHAR(50)` in DB (not native enum) to allow future extension without migration.
|
||||||
|
|
||||||
|
### DTOs
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record ShowcaseSessionDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid GroupId,
|
||||||
|
string GroupName,
|
||||||
|
string? GroupSlug,
|
||||||
|
string Title,
|
||||||
|
DateTime ScheduledAt,
|
||||||
|
string Status,
|
||||||
|
string? System,
|
||||||
|
bool IsOneShot,
|
||||||
|
string? Format,
|
||||||
|
int? DurationMinutes,
|
||||||
|
string? CoverImageUrl,
|
||||||
|
int? MaxPlayers,
|
||||||
|
int ActivePlayerCount,
|
||||||
|
int WaitlistedPlayerCount,
|
||||||
|
bool AllowDirectRegistration);
|
||||||
|
|
||||||
|
public sealed record ShowcaseFilter(
|
||||||
|
DateFilter Date = DateFilter.All,
|
||||||
|
SeatFilter Seats = SeatFilter.Any,
|
||||||
|
GameSystem? System = null,
|
||||||
|
bool? IsOneShot = null,
|
||||||
|
string? Format = null);
|
||||||
|
|
||||||
|
public enum DateFilter { Today, Tomorrow, ThisWeek, All }
|
||||||
|
public enum SeatFilter { Available, Waitlist, Any }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Design
|
||||||
|
|
||||||
|
### `/showcase` — Catalog Page
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
- Hero with title "Каталог игр"
|
||||||
|
- Sticky filter bar (horizontal on desktop, collapsible on mobile)
|
||||||
|
- Responsive grid of session cards (1 col mobile, 2 col tablet, 3 col desktop)
|
||||||
|
- Pagination (page + pageSize = 12)
|
||||||
|
|
||||||
|
**Filters:**
|
||||||
|
- Date: "Сегодня" | "Завтра" | "На неделю" | "Все"
|
||||||
|
- Seats: "Есть места" | "Waitlist" | "Любое"
|
||||||
|
- System: dropdown with all `GameSystem` values
|
||||||
|
- Type: "Ваншот" | "Кампания" | "Любое"
|
||||||
|
- Format: "Онлайн" | "Офлайн" | "Гибрид" | "Любое"
|
||||||
|
|
||||||
|
**Card Design:**
|
||||||
|
- Cover image (fallback: colored placeholder with initials)
|
||||||
|
- Title
|
||||||
|
- System badge
|
||||||
|
- Date + time (MSK)
|
||||||
|
- Duration (e.g. "3 часа")
|
||||||
|
- Format badge
|
||||||
|
- Seats indicator: "5/6 мест" | "Waitlist (3)" | "Мест нет"
|
||||||
|
- Club name (link to `/club/{slug}`)
|
||||||
|
- Buttons: "Подробнее" → `/s/{id}`, "Записаться" (if `AllowDirectRegistration`)
|
||||||
|
|
||||||
|
### `/s/{id}` — Public Session Detail (Updated)
|
||||||
|
|
||||||
|
New fields added to existing page:
|
||||||
|
- Cover image (full-width hero)
|
||||||
|
- System badge
|
||||||
|
- Description block
|
||||||
|
- Duration + format
|
||||||
|
- GM contact (always visible: Telegram username or Discord tag)
|
||||||
|
- If `allow_direct_registration`:
|
||||||
|
- "Записаться" button → Telegram Mini App deeplink or Discord OAuth
|
||||||
|
- Direct registration into `session_participants` via `SessionCapacityRules`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
### ISessionStore Methods
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(
|
||||||
|
ShowcaseFilter filter, int page, int pageSize);
|
||||||
|
|
||||||
|
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
|
||||||
|
|
||||||
|
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, PlatformUser user);
|
||||||
|
```
|
||||||
|
|
||||||
|
`GetShowcaseSessionsAsync` query:
|
||||||
|
- Cross-group (all clubs with `public_schedule_enabled = true`)
|
||||||
|
- Only `is_public = true` sessions
|
||||||
|
- `scheduled_at > now() - interval '4 hours'`
|
||||||
|
- `status <> 'Cancelled'`
|
||||||
|
- Apply filters in SQL WHERE clause
|
||||||
|
- Order by `scheduled_at ASC`
|
||||||
|
- Offset/limit pagination
|
||||||
|
|
||||||
|
`RegisterFromShowcaseAsync`:
|
||||||
|
- Check `allow_direct_registration = true`
|
||||||
|
- Load session with `FOR UPDATE`
|
||||||
|
- Count active + waitlisted participants
|
||||||
|
- Use `SessionCapacityRules.DecideJoinStatus`
|
||||||
|
- Insert participant with appropriate `registration_status`
|
||||||
|
- Return true on success, false if full and no waitlist allowed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bot Integration
|
||||||
|
|
||||||
|
### Telegram Bot
|
||||||
|
|
||||||
|
During `CreateSessionCommand` flow, after title/link/time input:
|
||||||
|
1. "Выберите систему:" inline keyboard with `GameSystem` values + "Другое"
|
||||||
|
2. If text input instead of button: fuzzy match against display names (Levenshtein/Contains/StartsWith)
|
||||||
|
3. "Описание игры (краткое):" — text input, optional (skip button)
|
||||||
|
4. "Формат:" inline keyboard — "Онлайн" | "Офлайн" | "Гибрид"
|
||||||
|
5. "Продолжительность (в часах):" — int input, optional
|
||||||
|
6. "Обложка (URL или пропустить):" — text input, optional
|
||||||
|
|
||||||
|
During `/publish` flow:
|
||||||
|
- "Разрешить прямую запись из каталога?" — yes/no toggle (default: no)
|
||||||
|
|
||||||
|
### Discord Bot
|
||||||
|
|
||||||
|
Same flow adapted for Discord interactions:
|
||||||
|
- Slash command options or button menus for system/format
|
||||||
|
- Modal input for description, duration, cover URL
|
||||||
|
- Fuzzy matching on free-text system input
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration V027
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN system VARCHAR(50),
|
||||||
|
ADD COLUMN description TEXT,
|
||||||
|
ADD COLUMN cover_image_url TEXT,
|
||||||
|
ADD COLUMN duration_minutes INTEGER,
|
||||||
|
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online','Offline','Hybrid')),
|
||||||
|
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE INDEX ix_sessions_showcase
|
||||||
|
ON sessions (scheduled_at, system, is_one_shot, format)
|
||||||
|
WHERE is_public = true AND status <> 'Cancelled';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
1. **Unit tests:** `SessionCapacityRules` with showcase registration scenarios
|
||||||
|
2. **Integration tests:** `GetShowcaseSessionsAsync` with each filter combination
|
||||||
|
3. **UI tests:** `Showcase.razor` rendering with/without cover images, filters applied
|
||||||
|
4. **Bot tests:** Fuzzy matching algorithm for `GameSystem` resolution
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version Bump
|
||||||
|
|
||||||
|
Issue label: `type:feature` → **minor bump**
|
||||||
|
Current: `3.3.0` → Next: `3.4.0`
|
||||||
|
|
||||||
|
Files to sync:
|
||||||
|
- `Directory.Build.props`
|
||||||
|
- `compose.yaml` (bot, discord, web image tags)
|
||||||
|
- `.gitea/workflows/deploy.yml` (`VERSION` env)
|
||||||
|
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria (from Issue #39)
|
||||||
|
|
||||||
|
- [ ] User can find a published game without accessing a private dashboard
|
||||||
|
- [ ] Registration does not bypass existing seat/waitlist limits
|
||||||
|
- [ ] Owner/co-GM controls what appears in the showcase via `is_public` + `allow_direct_registration`
|
||||||
|
- [ ] Filters work: date, seats, system, type, format
|
||||||
|
- [ ] GM contact is always visible on public session detail
|
||||||
|
- [ ] Direct registration respects `SessionCapacityRules`
|
||||||
@@ -42,12 +42,13 @@ public sealed class CancelSessionHandler(
|
|||||||
FROM group_managers gm
|
FROM group_managers gm
|
||||||
JOIN players p ON p.id = gm.player_id
|
JOIN players p ON p.id = gm.player_id
|
||||||
WHERE gm.group_id = s.group_id
|
WHERE gm.group_id = s.group_id
|
||||||
AND p.telegram_id = @TelegramUserId
|
AND p.platform = 'Telegram'
|
||||||
|
AND p.external_user_id = @ExternalUserId
|
||||||
) AS CanManage
|
) AS CanManage
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
WHERE s.id = @SessionId
|
WHERE s.id = @SessionId
|
||||||
""",
|
""",
|
||||||
new { command.SessionId, command.TelegramUserId }, transaction);
|
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() }, transaction);
|
||||||
|
|
||||||
if (session == null)
|
if (session == null)
|
||||||
{
|
{
|
||||||
@@ -89,7 +90,7 @@ public sealed class CancelSessionHandler(
|
|||||||
|
|
||||||
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
|
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
|
||||||
"""
|
"""
|
||||||
SELECT p.telegram_id AS TelegramId,
|
SELECT p.external_user_id::BIGINT AS TelegramId,
|
||||||
p.display_name AS DisplayName
|
p.display_name AS DisplayName
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
|
|||||||
@@ -1,292 +1,178 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Platform;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
|
|
||||||
|
|
||||||
public sealed class CreateSessionHandler(
|
public sealed class CreateSessionHandler(
|
||||||
|
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler sharedHandler,
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient botClient,
|
IPlatformMessenger messenger,
|
||||||
ILogger<CreateSessionHandler> logger)
|
ILogger<CreateSessionHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
public async Task HandleAsync(Message message, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow);
|
var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
foreach (var timeInput in parseResult.PastTimeInputs)
|
foreach (var timeInput in parseResult.PastTimeInputs)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
message.Chat.Id,
|
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||||
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
|
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
|
||||||
cancellationToken: cancellationToken);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var timeInput in parseResult.InvalidTimeInputs)
|
foreach (var timeInput in parseResult.InvalidTimeInputs)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
message.Chat.Id,
|
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||||
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
|
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
|
||||||
cancellationToken: cancellationToken);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
|
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
message.Chat.Id,
|
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||||
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
|
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
|
||||||
cancellationToken: cancellationToken);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
|
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
message.Chat.Id,
|
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||||
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
|
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
|
||||||
cancellationToken: cancellationToken);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parseResult.IsValid)
|
if (!parseResult.IsValid)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
chatId: message.Chat.Id,
|
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||||
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link\nКартинка: https://cover\n\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7",
|
"""
|
||||||
cancellationToken: cancellationToken);
|
❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:
|
||||||
|
|
||||||
|
/newsession
|
||||||
|
Название: My Game
|
||||||
|
Время: 15.05.2026 19:30
|
||||||
|
Время: 22.05.2026 19:30
|
||||||
|
Мест: 4
|
||||||
|
Ссылка: https://link
|
||||||
|
Картинка: https://cover
|
||||||
|
|
||||||
|
Для повтора можно указать одну дату и строки:
|
||||||
|
Игр: 4
|
||||||
|
Интервал: 7
|
||||||
|
""",
|
||||||
|
ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var title = parseResult.Title!;
|
|
||||||
var link = parseResult.Link!;
|
|
||||||
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
|
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
|
||||||
var gmId = message.From!.Id;
|
var gmId = message.From!.Id;
|
||||||
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
|
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
|
||||||
var gmUsername = message.From.Username;
|
var gmUsername = message.From.Username;
|
||||||
|
|
||||||
var chatId = message.Chat.Id;
|
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
|
||||||
var chatTitle = message.Chat.Title ?? "Private Chat";
|
message.Chat.IsForum,
|
||||||
|
message.MessageThreadId);
|
||||||
|
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
|
||||||
|
var messageThreadId = topicDestination.MessageThreadId;
|
||||||
|
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
if (topicDestination.ShouldCreateForumTopic)
|
||||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
|
||||||
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
|
||||||
VALUES (@TgId, @Name, @Username, 'Telegram', @TgId::TEXT, @Username)
|
|
||||||
ON CONFLICT (telegram_id) DO UPDATE
|
|
||||||
SET display_name = EXCLUDED.display_name,
|
|
||||||
telegram_username = EXCLUDED.telegram_username,
|
|
||||||
platform = COALESCE(players.platform, 'Telegram'),
|
|
||||||
external_user_id = COALESCE(players.external_user_id, EXCLUDED.telegram_id::TEXT),
|
|
||||||
external_username = COALESCE(players.external_username, EXCLUDED.telegram_username);
|
|
||||||
""",
|
|
||||||
new { TgId = gmId, Name = gmName, Username = gmUsername },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
|
||||||
"""
|
|
||||||
SELECT g.id AS GroupId,
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM group_managers gm
|
|
||||||
JOIN players p ON p.id = gm.player_id
|
|
||||||
WHERE gm.group_id = g.id
|
|
||||||
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT
|
|
||||||
) AS CanManage
|
|
||||||
FROM game_groups g
|
|
||||||
WHERE COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) = @ChatId::TEXT
|
|
||||||
""",
|
|
||||||
new { ChatId = chatId, GmId = gmId },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
Guid groupId;
|
|
||||||
if (existingGroup is null)
|
|
||||||
{
|
|
||||||
groupId = await connection.ExecuteScalarAsync<Guid>(
|
|
||||||
"""
|
|
||||||
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
|
|
||||||
VALUES (@ChatId, @ChatName, @GmId, 'Telegram', @ChatId::TEXT)
|
|
||||||
RETURNING id;
|
|
||||||
""",
|
|
||||||
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
|
||||||
INSERT INTO group_managers (group_id, player_id, role)
|
|
||||||
SELECT @GroupId, p.id, @OwnerRole
|
|
||||||
FROM players p
|
|
||||||
WHERE COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT
|
|
||||||
ON CONFLICT (group_id, player_id) DO NOTHING
|
|
||||||
""",
|
|
||||||
new { GroupId = groupId, GmId = gmId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
|
||||||
transaction);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (!existingGroup.CanManage)
|
|
||||||
{
|
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
|
||||||
await botClient.SendMessage(
|
|
||||||
chatId,
|
|
||||||
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
groupId = existingGroup.GroupId;
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"UPDATE game_groups SET name = @ChatName WHERE id = @GroupId",
|
|
||||||
new { ChatName = chatTitle, GroupId = groupId },
|
|
||||||
transaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
|
|
||||||
message.Chat.IsForum,
|
|
||||||
message.MessageThreadId);
|
|
||||||
var messageThreadId = topicDestination.MessageThreadId;
|
|
||||||
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
|
|
||||||
if (topicDestination.ShouldCreateForumTopic)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var topic = await botClient.CreateForumTopic(
|
|
||||||
chatId: chatId,
|
|
||||||
name: $"🎲 Игры: {title}",
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
messageThreadId = topic.MessageThreadId;
|
|
||||||
}
|
|
||||||
catch (Telegram.Bot.Exceptions.ApiRequestException ex)
|
|
||||||
when (TelegramTopicRouting.IsMissingForumTopicRightsError(ex.Message))
|
|
||||||
{
|
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
|
||||||
await botClient.SendMessage(
|
|
||||||
chatId,
|
|
||||||
TelegramTopicRouting.MissingForumTopicRightsMessage,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var batchId = Guid.NewGuid();
|
|
||||||
var sessions = new List<SessionBatchDto>();
|
|
||||||
|
|
||||||
foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value))
|
|
||||||
{
|
|
||||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
|
||||||
"""
|
|
||||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players)
|
|
||||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers)
|
|
||||||
RETURNING id;
|
|
||||||
""",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
BatchId = batchId,
|
|
||||||
GroupId = groupId,
|
|
||||||
Title = title,
|
|
||||||
Link = link,
|
|
||||||
ScheduledAt = scheduledAt,
|
|
||||||
ThreadId = messageThreadId,
|
|
||||||
TopicCreatedByBot = topicCreatedByBot,
|
|
||||||
MaxPlayers = parseResult.MaxPlayers,
|
|
||||||
Status = SessionStatus.Planned
|
|
||||||
},
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers, link));
|
|
||||||
}
|
|
||||||
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
|
|
||||||
|
|
||||||
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
|
|
||||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
|
||||||
|
|
||||||
Message batchMessage;
|
|
||||||
|
|
||||||
if (imageReference is not null && renderResult.Text.Length <= 1024)
|
|
||||||
{
|
|
||||||
// Картинка + расписание умещаются в одном Telegram-фото с подписью
|
|
||||||
try
|
|
||||||
{
|
|
||||||
batchMessage = await botClient.SendPhoto(
|
|
||||||
chatId: chatId,
|
|
||||||
messageThreadId: messageThreadId,
|
|
||||||
photo: InputFile.FromString(imageReference),
|
|
||||||
caption: renderResult.Text,
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}, отправляем текстом", batchId);
|
|
||||||
batchMessage = await botClient.SendMessage(
|
|
||||||
chatId: chatId,
|
|
||||||
messageThreadId: messageThreadId,
|
|
||||||
text: renderResult.Text,
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Текст слишком длинный для caption — fallback на два сообщения
|
|
||||||
if (imageReference is not null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await botClient.SendPhoto(
|
|
||||||
chatId: chatId,
|
|
||||||
messageThreadId: messageThreadId,
|
|
||||||
photo: InputFile.FromString(imageReference),
|
|
||||||
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}", batchId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
batchMessage = await botClient.SendMessage(
|
|
||||||
chatId: chatId,
|
|
||||||
messageThreadId: messageThreadId,
|
|
||||||
text: renderResult.Text,
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
|
|
||||||
new { MsgId = batchMessage.MessageId, BatchId = batchId });
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await botClient.DeleteMessage(
|
var topicRef = await messenger.CreateThreadAsync(
|
||||||
chatId: chatId,
|
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||||
messageId: message.MessageId,
|
$"🎲 Игры: {parseResult.Title}",
|
||||||
cancellationToken: cancellationToken);
|
ct);
|
||||||
|
messageThreadId = int.Parse(topicRef.ExternalThreadId!, System.Globalization.CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
when (ex.Message.Contains("not enough rights") ||
|
||||||
|
ex.Message.Contains("CHAT_ADMIN_REQUIRED") ||
|
||||||
|
ex.Message.Contains("not an administrator"))
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, chatId);
|
await messenger.SendGroupMessageAsync(
|
||||||
|
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||||
|
TelegramTopicRouting.MissingForumTopicRightsMessage,
|
||||||
|
ct);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var platformGroup = TelegramPlatformIds.Group(message.Chat.Id, messageThreadId, message.Chat.Title ?? "Private Chat");
|
||||||
|
var platformUser = new PlatformUser(
|
||||||
|
PlatformKind.Telegram,
|
||||||
|
gmId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
gmName,
|
||||||
|
gmUsername);
|
||||||
|
|
||||||
|
var command = new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionCommand(
|
||||||
|
platformUser,
|
||||||
|
platformGroup,
|
||||||
|
parseResult.Title!,
|
||||||
|
parseResult.Link!,
|
||||||
|
parseResult.ScheduledTimes,
|
||||||
|
parseResult.MaxPlayers,
|
||||||
|
imageReference);
|
||||||
|
|
||||||
|
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await sharedHandler.HandleAsync(command, ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await messenger.SendGroupMessageAsync(
|
||||||
|
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||||
|
"💥 Произошла ошибка базы данных при создании сессии.",
|
||||||
|
ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
await messenger.SendGroupMessageAsync(
|
||||||
|
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||||
|
result.ErrorMessage!,
|
||||||
|
ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scheduleMessage = new PlatformScheduleMessage(
|
||||||
|
platformGroup,
|
||||||
|
result.View!,
|
||||||
|
null,
|
||||||
|
imageReference);
|
||||||
|
|
||||||
|
var sentMessageRef = await messenger.SendScheduleAsync(scheduleMessage, ct);
|
||||||
|
|
||||||
|
// Store batch_message_id
|
||||||
|
if (int.TryParse(sentMessageRef.ExternalMessageId, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var batchMessageId))
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
|
||||||
|
new { MsgId = batchMessageId, BatchId = result.BatchId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete original message
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await messenger.DeleteMessageAsync(
|
||||||
|
TelegramPlatformIds.Message(message.Chat.Id, null, message.MessageId),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Ошибка при создании сессии");
|
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, message.Chat.Id);
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
|
||||||
await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,13 +41,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
FROM group_managers gm
|
FROM group_managers gm
|
||||||
JOIN players p ON p.id = gm.player_id
|
JOIN players p ON p.id = gm.player_id
|
||||||
WHERE gm.group_id = s.group_id
|
WHERE gm.group_id = s.group_id
|
||||||
AND p.telegram_id = @TelegramUserId
|
AND p.platform = 'Telegram'
|
||||||
|
AND p.external_user_id = @ExternalUserId
|
||||||
) AS CanManage
|
) AS CanManage
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
WHERE s.id = @SessionId
|
WHERE s.id = @SessionId
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
""",
|
""",
|
||||||
new { command.SessionId, command.TelegramUserId },
|
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
if (session is null)
|
if (session is null)
|
||||||
@@ -150,7 +151,7 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
"""
|
"""
|
||||||
SELECT sp.session_id AS SessionId,
|
SELECT sp.session_id AS SessionId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
sp.registration_status AS RegistrationStatus
|
sp.registration_status AS RegistrationStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
|
|||||||
@@ -1,113 +1,25 @@
|
|||||||
using System.Text;
|
|
||||||
using Dapper;
|
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Npgsql;
|
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
|
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||||||
|
|
||||||
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
|
||||||
|
|
||||||
public sealed class ExportCalendarHandler(
|
public sealed class ExportCalendarHandler(
|
||||||
NpgsqlDataSource dataSource,
|
GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler sharedHandler)
|
||||||
IPlatformMessenger messenger,
|
|
||||||
IConfiguration configuration)
|
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
public Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
var command = new GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarCommand(
|
||||||
|
new PlatformGroup(
|
||||||
|
PlatformKind.Telegram,
|
||||||
|
message.Chat.Id.ToString(),
|
||||||
|
message.Chat.Title ?? "Private Chat",
|
||||||
|
message.MessageThreadId?.ToString()),
|
||||||
|
new PlatformUser(
|
||||||
|
PlatformKind.Telegram,
|
||||||
|
message.From?.Id.ToString() ?? string.Empty,
|
||||||
|
message.From?.FirstName ?? string.Empty,
|
||||||
|
message.From?.Username));
|
||||||
|
|
||||||
var sessions = await connection.QueryAsync<CalendarSessionDto>(
|
return sharedHandler.HandleAsync(command, cancellationToken);
|
||||||
@"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, Planned = SessionStatus.Planned });
|
|
||||||
|
|
||||||
var sessionsList = sessions.ToList();
|
|
||||||
|
|
||||||
if (sessionsList.Count == 0)
|
|
||||||
{
|
|
||||||
await messenger.SendGroupMessageAsync(
|
|
||||||
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
|
|
||||||
"📭 У этой группы нет запланированных сессий для экспорта.",
|
|
||||||
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}");
|
|
||||||
sb.AppendLine("END:VEVENT");
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.AppendLine("END:VCALENDAR");
|
|
||||||
|
|
||||||
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
|
||||||
|
|
||||||
|
|
||||||
// Create calendar subscription
|
|
||||||
string? subscriptionUrl = null;
|
|
||||||
var baseUrl = configuration["Web:BaseUrl"];
|
|
||||||
var senderId = message.From?.Id;
|
|
||||||
if (!string.IsNullOrWhiteSpace(baseUrl) && senderId.HasValue)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var token = Guid.NewGuid().ToString("N");
|
|
||||||
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
|
|
||||||
@"SELECT id FROM game_groups WHERE telegram_chat_id = @ChatId",
|
|
||||||
new { ChatId = message.Chat.Id });
|
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
|
|
||||||
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
|
|
||||||
new { token, userTelegramId = senderId.Value, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
|
|
||||||
|
|
||||||
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Non-critical: if subscription creation fails, still send the file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var actions = subscriptionUrl is not null
|
|
||||||
? new[]
|
|
||||||
{
|
|
||||||
new PlatformMessageAction(
|
|
||||||
"calendar-subscription",
|
|
||||||
"🔗 Подписаться на календарь",
|
|
||||||
subscriptionUrl)
|
|
||||||
}
|
|
||||||
: Array.Empty<PlatformMessageAction>();
|
|
||||||
|
|
||||||
await messenger.SendCalendarFileAsync(
|
|
||||||
new PlatformCalendarFile(
|
|
||||||
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
|
|
||||||
"schedule.ics",
|
|
||||||
bytes,
|
|
||||||
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
|
||||||
actions),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
using Dapper;
|
|
||||||
using Npgsql;
|
|
||||||
using Telegram.Bot;
|
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
@@ -13,138 +10,88 @@ public sealed record DeleteSessionCommand(
|
|||||||
long ChatId,
|
long ChatId,
|
||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
internal sealed record DeleteSessionInfoDto(
|
|
||||||
string Title,
|
|
||||||
Guid BatchId,
|
|
||||||
Guid GroupId,
|
|
||||||
bool CanManage,
|
|
||||||
int? ThreadId,
|
|
||||||
bool TopicCreatedByBot);
|
|
||||||
|
|
||||||
public sealed class DeleteSessionHandler(
|
public sealed class DeleteSessionHandler(
|
||||||
NpgsqlDataSource dataSource,
|
GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler sharedHandler,
|
||||||
ITelegramBotClient bot,
|
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler listSessionsHandler,
|
||||||
|
IPlatformMessenger messenger,
|
||||||
ILogger<DeleteSessionHandler> logger)
|
ILogger<DeleteSessionHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct)
|
public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
var platformUser = new PlatformUser(
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
PlatformKind.Telegram,
|
||||||
|
command.TelegramUserId.ToString(),
|
||||||
|
string.Empty,
|
||||||
|
null);
|
||||||
|
|
||||||
// 1. Fetch session and verify group manager.
|
var platformGroup = new PlatformGroup(
|
||||||
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
|
PlatformKind.Telegram,
|
||||||
"""
|
command.ChatId.ToString(),
|
||||||
SELECT s.title AS Title,
|
string.Empty);
|
||||||
s.batch_id AS BatchId,
|
|
||||||
s.group_id AS GroupId,
|
|
||||||
s.thread_id AS ThreadId,
|
|
||||||
s.topic_created_by_bot AS TopicCreatedByBot,
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM group_managers gm
|
|
||||||
JOIN players p ON p.id = gm.player_id
|
|
||||||
WHERE gm.group_id = s.group_id
|
|
||||||
AND p.telegram_id = @TelegramUserId
|
|
||||||
) AS CanManage
|
|
||||||
FROM sessions s
|
|
||||||
WHERE s.id = @SessionId
|
|
||||||
""",
|
|
||||||
new { command.SessionId, command.TelegramUserId }, transaction);
|
|
||||||
|
|
||||||
if (session == null)
|
var scheduleMessage = TelegramPlatformIds.Message(command.ChatId, null, command.MessageId);
|
||||||
|
|
||||||
|
var sharedCommand = new GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionCommand(
|
||||||
|
command.SessionId,
|
||||||
|
platformUser,
|
||||||
|
platformGroup,
|
||||||
|
scheduleMessage);
|
||||||
|
|
||||||
|
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
|
||||||
|
|
||||||
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
await messenger.AnswerInteractionAsync(
|
||||||
|
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("owner")),
|
||||||
|
ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session.CanManage)
|
|
||||||
{
|
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может удалять сессию.", showAlert: true, cancellationToken: ct);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Delete session
|
|
||||||
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
|
||||||
|
|
||||||
var remainingInTopic = session.ThreadId.HasValue
|
|
||||||
? await connection.ExecuteScalarAsync<int>(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM sessions
|
|
||||||
WHERE group_id = @GroupId
|
|
||||||
AND thread_id = @ThreadId
|
|
||||||
""",
|
|
||||||
new { session.GroupId, ThreadId = session.ThreadId.Value },
|
|
||||||
transaction)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
|
||||||
|
|
||||||
// 4. If no sessions are left in a bot-owned forum topic, delete the topic.
|
// 4. If no sessions are left in a bot-owned forum topic, delete the topic.
|
||||||
if (session.ThreadId.HasValue &&
|
if (result.ThreadId.HasValue &&
|
||||||
TelegramTopicRouting.ShouldDeleteForumTopic(session.TopicCreatedByBot, remainingInTopic))
|
TelegramTopicRouting.ShouldDeleteForumTopic(result.TopicCreatedByBot, result.RemainingInTopic))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.DeleteForumTopic(command.ChatId, session.ThreadId.Value, cancellationToken: ct);
|
await messenger.DeleteThreadAsync(
|
||||||
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", session.ThreadId.Value, session.BatchId);
|
new PlatformGroup(PlatformKind.Telegram, command.ChatId.ToString(), string.Empty, null, result.ThreadId.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||||
|
ct);
|
||||||
|
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", result.ThreadId.Value, result.GroupId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", session.ThreadId.Value);
|
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", result.ThreadId.Value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия удалена!", cancellationToken: ct);
|
await messenger.AnswerInteractionAsync(
|
||||||
|
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
|
||||||
|
ct);
|
||||||
|
|
||||||
// 5. Update the /listsessions message (we delete the message or edit it to remove the button)
|
// 5. Update the /listsessions message
|
||||||
// A simple way is to re-render the list:
|
var listCommand = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(platformGroup, platformUser);
|
||||||
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
|
var listResult = await listSessionsHandler.HandleAsync(listCommand, 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, s.max_players as MaxPlayers,
|
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM group_managers gm
|
|
||||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
|
||||||
WHERE gm.group_id = s.group_id
|
|
||||||
AND manager_player.telegram_id = @TelegramUserId
|
|
||||||
) AS CanManage
|
|
||||||
FROM sessions s
|
|
||||||
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, s.max_players, s.group_id
|
|
||||||
ORDER BY s.scheduled_at ASC",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
ChatId = command.ChatId,
|
|
||||||
command.TelegramUserId,
|
|
||||||
Cancelled = SessionStatus.Cancelled,
|
|
||||||
Active = ParticipantRegistrationStatus.Active,
|
|
||||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
|
||||||
});
|
|
||||||
|
|
||||||
var sessionsList = sessions.ToList();
|
if (listResult.Sessions.Count == 0)
|
||||||
|
|
||||||
if (sessionsList.Count == 0)
|
|
||||||
{
|
{
|
||||||
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch { }
|
try
|
||||||
|
{
|
||||||
|
await messenger.UpdateGroupMessageAsync(
|
||||||
|
scheduleMessage,
|
||||||
|
"📭 В этой группе нет предстоящих игр.",
|
||||||
|
[],
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var renderResult = SessionListMessageRenderer.Render(sessionsList);
|
var text = SessionListMessageRenderer.RenderText(listResult.Sessions);
|
||||||
|
var actions = listResult.CanManage ? SessionListMessageRenderer.RenderActions(listResult.Sessions) : [];
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.EditMessageText(
|
await messenger.UpdateGroupMessageAsync(scheduleMessage, text, actions, ct);
|
||||||
command.ChatId,
|
|
||||||
command.MessageId,
|
|
||||||
renderResult.Text,
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
cancellationToken: ct);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,114 +1,37 @@
|
|||||||
using Dapper;
|
using GmRelay.Shared.Platform;
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using Npgsql;
|
|
||||||
using Telegram.Bot;
|
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
|
|
||||||
|
|
||||||
internal static class SessionListMessageRenderer
|
|
||||||
{
|
|
||||||
public static (string Text, InlineKeyboardMarkup? Markup) Render(IReadOnlyList<SessionListItemDto> sessions)
|
|
||||||
{
|
|
||||||
var text = "📅 <b>Ближайшие игры:</b>\n\n";
|
|
||||||
foreach (var session in sessions)
|
|
||||||
{
|
|
||||||
var seats = session.MaxPlayers.HasValue
|
|
||||||
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
|
|
||||||
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
|
|
||||||
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
var canManage = sessions.Count > 0 && sessions.First().CanManage;
|
|
||||||
if (!canManage)
|
|
||||||
{
|
|
||||||
return (text, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
var buttons = new List<InlineKeyboardButton[]>();
|
|
||||||
foreach (var session in sessions)
|
|
||||||
{
|
|
||||||
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
|
||||||
buttons.Add(
|
|
||||||
[
|
|
||||||
InlineKeyboardButton.WithCallbackData($"❌ {dateTitle}", $"cancel_session:{session.Id}"),
|
|
||||||
InlineKeyboardButton.WithCallbackData($"⏰ {dateTitle}", $"reschedule_session:{session.Id}")
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
|
|
||||||
{
|
|
||||||
buttons.Add(
|
|
||||||
[
|
|
||||||
InlineKeyboardButton.WithCallbackData($"⬆️ Из ожидания {dateTitle}", $"promote_waitlist:{session.Id}")
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
buttons.Add(
|
|
||||||
[
|
|
||||||
InlineKeyboardButton.WithCallbackData($"🗑 Удалить {dateTitle}", $"delete_session:{session.Id}")
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (text, new InlineKeyboardMarkup(buttons));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ListSessionsHandler(
|
public sealed class ListSessionsHandler(
|
||||||
NpgsqlDataSource dataSource,
|
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler sharedHandler,
|
||||||
ITelegramBotClient botClient)
|
IPlatformMessenger messenger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
var command = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(
|
||||||
|
new PlatformGroup(
|
||||||
|
PlatformKind.Telegram,
|
||||||
|
message.Chat.Id.ToString(),
|
||||||
|
message.Chat.Title ?? "Private Chat",
|
||||||
|
message.MessageThreadId?.ToString()),
|
||||||
|
new PlatformUser(
|
||||||
|
PlatformKind.Telegram,
|
||||||
|
message.From?.Id.ToString() ?? string.Empty,
|
||||||
|
message.From?.FirstName ?? string.Empty,
|
||||||
|
message.From?.Username));
|
||||||
|
|
||||||
var sessions = await connection.QueryAsync<SessionListItemDto>(
|
var result = await sharedHandler.HandleAsync(command, cancellationToken);
|
||||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
|
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM group_managers gm
|
|
||||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
|
||||||
WHERE gm.group_id = s.group_id
|
|
||||||
AND manager_player.telegram_id = @TelegramUserId
|
|
||||||
) AS CanManage
|
|
||||||
FROM sessions s
|
|
||||||
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, s.max_players, s.group_id
|
|
||||||
ORDER BY s.scheduled_at ASC",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
ChatId = message.Chat.Id,
|
|
||||||
TelegramUserId = message.From?.Id,
|
|
||||||
Cancelled = SessionStatus.Cancelled,
|
|
||||||
Active = ParticipantRegistrationStatus.Active,
|
|
||||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
|
||||||
});
|
|
||||||
|
|
||||||
var sessionsList = sessions.ToList();
|
if (result.Sessions.Count == 0)
|
||||||
|
|
||||||
if (sessionsList.Count == 0)
|
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(command.Group, "📭 В этой группе нет предстоящих игр.", cancellationToken);
|
||||||
chatId: message.Chat.Id,
|
|
||||||
text: "📭 В этой группе нет предстоящих игр.",
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var renderResult = SessionListMessageRenderer.Render(sessionsList);
|
var text = SessionListMessageRenderer.RenderText(result.Sessions);
|
||||||
|
var actions = result.CanManage ? SessionListMessageRenderer.RenderActions(result.Sessions) : [];
|
||||||
|
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(command.Group, text, actions, cancellationToken);
|
||||||
chatId: message.Chat.Id,
|
|
||||||
text: renderResult.Text,
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using GmRelay.Shared.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
|
internal static class SessionListMessageRenderer
|
||||||
|
{
|
||||||
|
public static string RenderText(IReadOnlyList<SessionListItemDto> sessions)
|
||||||
|
{
|
||||||
|
var text = "📅 <b>Ближайшие игры:</b>\n\n";
|
||||||
|
foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
var seats = session.MaxPlayers.HasValue
|
||||||
|
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
|
||||||
|
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
|
||||||
|
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
|
||||||
|
{
|
||||||
|
if (sessions.Count == 0 || !sessions.First().CanManage)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var actions = new List<PlatformMessageAction>();
|
||||||
|
|
||||||
|
foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
||||||
|
|
||||||
|
actions.Add(new PlatformMessageAction(
|
||||||
|
$"cancel_session:{session.Id}",
|
||||||
|
$"❌ {dateTitle}",
|
||||||
|
$"cancel_session:{session.Id}"));
|
||||||
|
|
||||||
|
actions.Add(new PlatformMessageAction(
|
||||||
|
$"reschedule_session:{session.Id}",
|
||||||
|
$"⏰ {dateTitle}",
|
||||||
|
$"reschedule_session:{session.Id}"));
|
||||||
|
|
||||||
|
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
|
||||||
|
{
|
||||||
|
actions.Add(new PlatformMessageAction(
|
||||||
|
$"promote_waitlist:{session.Id}",
|
||||||
|
$"⬆️ Из ожидания {dateTitle}",
|
||||||
|
$"promote_waitlist:{session.Id}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.Add(new PlatformMessageAction(
|
||||||
|
$"delete_session:{session.Id}",
|
||||||
|
$"🗑 Удалить {dateTitle}",
|
||||||
|
$"delete_session:{session.Id}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
-211
@@ -12,241 +12,156 @@ using GmRelay.Bot.Infrastructure.Telegram;
|
|||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
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, int? ThreadId, string NotificationMode);
|
|
||||||
|
|
||||||
// ── Handler ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles text input from the GM who has an AwaitingTime proposal.
|
/// Telegram adapter for reschedule time input.
|
||||||
/// Parses reschedule options with a voting deadline, creates a voting message,
|
/// Delegates core logic to the shared handler, then performs Telegram-specific
|
||||||
/// and tags all participants.
|
/// message sending, DM notifications, vote_message_id storage, and cleanup.
|
||||||
/// If no participants are registered, reschedules immediately.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HandleRescheduleTimeInputHandler(
|
public sealed class HandleRescheduleTimeInputHandler(
|
||||||
|
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler sharedHandler,
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
IPlatformMessenger messenger,
|
IPlatformMessenger messenger,
|
||||||
DirectSessionNotificationSender directSender,
|
DirectSessionNotificationSender directSender,
|
||||||
ILogger<HandleRescheduleTimeInputHandler> logger)
|
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)
|
public async Task<bool> TryHandleAsync(Message message, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
|
if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var gmTelegramId = message.From.Id;
|
var command = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputCommand(
|
||||||
var chatId = message.Chat.Id;
|
new PlatformUser(
|
||||||
var text = message.Text.Trim();
|
PlatformKind.Telegram,
|
||||||
|
message.From.Id.ToString(),
|
||||||
|
message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"),
|
||||||
|
message.From.Username),
|
||||||
|
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title),
|
||||||
|
message.Text.Trim());
|
||||||
|
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
var result = await sharedHandler.HandleAsync(command, ct);
|
||||||
|
if (!result.Handled)
|
||||||
// 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,
|
|
||||||
s.thread_id AS ThreadId,
|
|
||||||
s.notification_mode AS NotificationMode
|
|
||||||
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
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM group_managers gm
|
|
||||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
|
||||||
WHERE gm.group_id = s.group_id
|
|
||||||
AND manager_player.telegram_id = @GmId
|
|
||||||
)
|
|
||||||
ORDER BY rp.created_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
new { GmId = gmTelegramId, ChatId = chatId });
|
|
||||||
|
|
||||||
if (proposal is null)
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// 2. Parse voting input
|
if (!string.IsNullOrEmpty(result.ReplyText) && !result.IsRescheduledImmediately)
|
||||||
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
|
|
||||||
{
|
{
|
||||||
await messenger.SendGroupMessageAsync(
|
await messenger.SendGroupMessageAsync(
|
||||||
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
|
command.Group,
|
||||||
$"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
|
$"""⚠️ {result.ReplyText}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>""",
|
||||||
ct);
|
ct);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Load participants (non-GM) signed up for this session
|
if (result.IsRescheduledImmediately)
|
||||||
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
|
||||||
"""
|
|
||||||
SELECT p.id AS PlayerId,
|
|
||||||
p.display_name AS DisplayName,
|
|
||||||
p.telegram_username AS TelegramUsername,
|
|
||||||
p.telegram_id AS TelegramId
|
|
||||||
FROM session_participants sp
|
|
||||||
JOIN players p ON p.id = sp.player_id
|
|
||||||
WHERE sp.session_id = @SessionId
|
|
||||||
AND sp.is_gm = false
|
|
||||||
AND sp.registration_status = @Active
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
|
||||||
|
|
||||||
// 4. If no participants — reschedule immediately
|
|
||||||
if (participants.Count == 0)
|
|
||||||
{
|
{
|
||||||
await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct);
|
if (result.UpdatedView is not null && result.BatchMessageId.HasValue)
|
||||||
await TryDeleteMessage(chatId, message.MessageId, ct);
|
{
|
||||||
|
await TryUpdateBatchMessage(
|
||||||
|
command.Group,
|
||||||
|
result.UpdatedView,
|
||||||
|
TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, result.BatchMessageId.Value),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await messenger.SendGroupMessageAsync(command.Group, result.ReplyText!, ct);
|
||||||
|
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Create voting message
|
// Voting mode
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
|
||||||
var options = votingInput.Options
|
|
||||||
.Select((proposedAt, index) => new RescheduleOptionDto(
|
|
||||||
Guid.NewGuid(),
|
|
||||||
index + 1,
|
|
||||||
proposedAt))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
|
||||||
UPDATE reschedule_proposals
|
|
||||||
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @ChatId
|
|
||||||
WHERE id = @Id
|
|
||||||
""",
|
|
||||||
new { votingInput.Deadline, ChatId = chatId, Id = proposal.Id },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
foreach (var option in options)
|
|
||||||
{
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
|
||||||
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
|
|
||||||
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
|
|
||||||
""",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
option.OptionId,
|
|
||||||
ProposalId = proposal.Id,
|
|
||||||
option.ProposedAt,
|
|
||||||
option.DisplayOrder
|
|
||||||
},
|
|
||||||
transaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
|
||||||
|
|
||||||
var voteText = BuildVotingMessage(
|
var voteText = BuildVotingMessage(
|
||||||
proposal.Title,
|
result.Title!,
|
||||||
proposal.CurrentScheduledAt,
|
result.CurrentScheduledAt,
|
||||||
votingInput.Deadline,
|
result.VotingDeadlineAt!.Value,
|
||||||
options,
|
result.Options,
|
||||||
participants,
|
result.Participants,
|
||||||
[]);
|
[]);
|
||||||
var keyboard = BuildVotingKeyboard(options);
|
|
||||||
|
var keyboard = BuildVotingKeyboard(result.Options);
|
||||||
|
|
||||||
var voteMsg = await bot.SendMessage(
|
var voteMsg = await bot.SendMessage(
|
||||||
chatId: chatId,
|
chatId: message.Chat.Id,
|
||||||
messageThreadId: proposal.ThreadId,
|
messageThreadId: message.MessageThreadId,
|
||||||
text: voteText,
|
text: voteText,
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
replyMarkup: keyboard,
|
replyMarkup: keyboard,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
|
|
||||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
|
var mode = await GetNotificationModeAsync(result.ProposalId!.Value, ct);
|
||||||
if (mode.ShouldSendDirectMessages())
|
if (mode.ShouldSendDirectMessages())
|
||||||
{
|
{
|
||||||
var optionsText = string.Join(
|
var optionsText = string.Join(
|
||||||
"\n",
|
"\n",
|
||||||
options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
|
result.Options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
|
||||||
var directText = $"""
|
var directText = $"""
|
||||||
🔄 <b>Голосование за перенос сессии</b>
|
🔄 <b>Голосование за перенос сессии</b>
|
||||||
|
|
||||||
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
|
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
|
||||||
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
📅 Текущее время: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
||||||
🗳 Варианты:
|
🗳 Варианты:
|
||||||
{optionsText}
|
{optionsText}
|
||||||
|
|
||||||
⏳ Дедлайн: <b>{votingInput.Deadline.FormatMoscow()}</b> (МСК)
|
⏳ Дедлайн: <b>{result.VotingDeadlineAt.Value.FormatMoscow()}</b> (МСК)
|
||||||
|
|
||||||
Проголосуйте кнопкой в групповом сообщении.
|
Проголосуйте кнопкой в групповом сообщении.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await directSender.SendAsync(
|
await directSender.SendAsync(
|
||||||
participants.Select(p => new DirectNotificationRecipient(
|
result.Participants.Select(p => new DirectNotificationRecipient(
|
||||||
p.TelegramId,
|
p.TelegramId,
|
||||||
p.DisplayName)),
|
p.DisplayName)),
|
||||||
directText,
|
directText,
|
||||||
"reschedule-vote",
|
"reschedule-vote",
|
||||||
proposal.SessionId,
|
result.ProposalId.Value,
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store vote message ID
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
|
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
|
||||||
new { MsgId = voteMsg.MessageId, Id = proposal.Id });
|
new { MsgId = voteMsg.MessageId, Id = result.ProposalId.Value });
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
|
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
|
||||||
proposal.SessionId,
|
result.ProposalId.Value,
|
||||||
proposal.Id,
|
result.ProposalId.Value,
|
||||||
options.Count,
|
result.Options.Count,
|
||||||
votingInput.Deadline);
|
result.VotingDeadlineAt.Value);
|
||||||
|
|
||||||
// Delete GM's time input message
|
|
||||||
await TryDeleteMessage(chatId, message.MessageId, ct);
|
|
||||||
|
|
||||||
|
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RescheduleImmediately(
|
private async Task<SessionNotificationMode> GetNotificationModeAsync(Guid proposalId, CancellationToken ct)
|
||||||
NpgsqlConnection connection, AwaitingProposalDto proposal,
|
|
||||||
DateTimeOffset newTime, long chatId, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
var raw = await connection.QuerySingleOrDefaultAsync<string?>(
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
"""
|
||||||
UPDATE sessions
|
SELECT s.notification_mode
|
||||||
SET scheduled_at = @NewTime,
|
FROM sessions s
|
||||||
status = @Status,
|
JOIN reschedule_proposals rp ON rp.session_id = s.id
|
||||||
confirmation_message_id = NULL,
|
WHERE rp.id = @Id
|
||||||
confirmation_sent_at = NULL,
|
|
||||||
one_hour_reminder_processed_at = NULL,
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = @SessionId
|
|
||||||
""",
|
""",
|
||||||
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
|
new { Id = proposalId });
|
||||||
transaction);
|
return SessionNotificationModeExtensions.FromDatabaseValue(raw ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
private async Task TryUpdateBatchMessage(
|
||||||
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
|
PlatformGroup group,
|
||||||
new { NewTime = newTime, Id = proposal.Id },
|
SessionBatchViewModel view,
|
||||||
transaction);
|
PlatformMessageRef scheduleMessage,
|
||||||
|
CancellationToken ct)
|
||||||
await transaction.CommitAsync(ct);
|
{
|
||||||
|
try
|
||||||
await messenger.SendGroupMessageAsync(
|
{
|
||||||
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
|
await messenger.UpdateScheduleAsync(
|
||||||
$"✅ Сессия «{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>",
|
new PlatformScheduleMessage(group, view, scheduleMessage),
|
||||||
ct);
|
ct);
|
||||||
|
}
|
||||||
// Re-render batch message with updated time
|
catch (Exception ex)
|
||||||
await TryUpdateBatchMessage(proposal, ct);
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule");
|
||||||
logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static string BuildVotingMessage(
|
internal static string BuildVotingMessage(
|
||||||
@@ -268,7 +183,7 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
|
|
||||||
var lines = new List<string>
|
var lines = new List<string>
|
||||||
{
|
{
|
||||||
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
|
$"""🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>""",
|
||||||
"",
|
"",
|
||||||
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
|
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
|
||||||
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
|
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
|
||||||
@@ -349,52 +264,6 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
"dd.MM HH:mm",
|
"dd.MM HH:mm",
|
||||||
System.Globalization.CultureInfo.InvariantCulture);
|
System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
|
||||||
|
|
||||||
var batchSessions = (await conn.QueryAsync<SessionBatchDto>(
|
|
||||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
|
||||||
new { proposal.BatchId })).ToList();
|
|
||||||
|
|
||||||
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
|
|
||||||
"""
|
|
||||||
SELECT sp.session_id AS SessionId,
|
|
||||||
p.display_name AS DisplayName,
|
|
||||||
p.telegram_username AS TelegramUsername,
|
|
||||||
sp.registration_status AS RegistrationStatus
|
|
||||||
FROM session_participants sp
|
|
||||||
JOIN players p ON sp.player_id = p.id
|
|
||||||
JOIN sessions s ON sp.session_id = s.id
|
|
||||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
|
||||||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
|
|
||||||
""",
|
|
||||||
new { proposal.BatchId })).ToList();
|
|
||||||
|
|
||||||
if (proposal.BatchMessageId.HasValue)
|
|
||||||
{
|
|
||||||
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
|
||||||
|
|
||||||
await messenger.UpdateScheduleAsync(
|
|
||||||
new PlatformScheduleMessage(
|
|
||||||
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
|
|
||||||
view,
|
|
||||||
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
|
|
||||||
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)
|
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
+35
-118
@@ -1,8 +1,7 @@
|
|||||||
using Dapper;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
using Npgsql;
|
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
@@ -15,130 +14,49 @@ public sealed record HandleRescheduleVoteCommand(
|
|||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
public sealed class HandleRescheduleVoteHandler(
|
public sealed class HandleRescheduleVoteHandler(
|
||||||
NpgsqlDataSource dataSource,
|
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
IPlatformMessenger messenger,
|
IPlatformMessenger messenger,
|
||||||
ILogger<HandleRescheduleVoteHandler> logger)
|
ILogger<HandleRescheduleVoteHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
var platformUser = new PlatformUser(
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
PlatformKind.Telegram,
|
||||||
|
command.TelegramUserId.ToString(),
|
||||||
|
string.Empty,
|
||||||
|
null);
|
||||||
|
|
||||||
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
|
var platformGroup = new PlatformGroup(
|
||||||
"""
|
PlatformKind.Telegram,
|
||||||
SELECT rp.id AS Id,
|
command.ChatId.ToString(),
|
||||||
rp.session_id AS SessionId,
|
string.Empty);
|
||||||
rp.voting_deadline_at AS VotingDeadlineAt,
|
|
||||||
s.title AS Title,
|
|
||||||
s.scheduled_at AS CurrentScheduledAt
|
|
||||||
FROM reschedule_options ro
|
|
||||||
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
|
|
||||||
JOIN sessions s ON s.id = rp.session_id
|
|
||||||
WHERE ro.id = @OptionId AND rp.status = 'Voting'
|
|
||||||
""",
|
|
||||||
new { command.OptionId },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
if (proposal is null)
|
var sharedCommand = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteCommand(
|
||||||
|
command.OptionId,
|
||||||
|
platformUser,
|
||||||
|
platformGroup,
|
||||||
|
command.CallbackQueryId,
|
||||||
|
TelegramPlatformIds.Message(command.ChatId, null, command.MessageId));
|
||||||
|
|
||||||
|
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
|
||||||
|
|
||||||
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
await AnswerAsync(command.CallbackQueryId, "Голосование уже завершено или не найдено.", ct);
|
await messenger.AnswerInteractionAsync(
|
||||||
|
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("дедлайн")),
|
||||||
|
ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
|
|
||||||
{
|
|
||||||
await AnswerAsync(command.CallbackQueryId, "Дедлайн уже прошёл. Результаты скоро будут применены.", ct, showAlert: true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
AND sp.registration_status = @Active
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
if (playerId is null)
|
|
||||||
{
|
|
||||||
await AnswerAsync(command.CallbackQueryId, "Вы не являетесь участником этой сессии.", ct);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
|
||||||
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
|
|
||||||
VALUES (@ProposalId, @PlayerId, @OptionId)
|
|
||||||
ON CONFLICT (proposal_id, player_id) DO UPDATE
|
|
||||||
SET option_id = EXCLUDED.option_id,
|
|
||||||
voted_at = now()
|
|
||||||
""",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
ProposalId = proposal.Id,
|
|
||||||
PlayerId = playerId.Value,
|
|
||||||
command.OptionId
|
|
||||||
},
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
|
||||||
"""
|
|
||||||
SELECT p.id AS PlayerId,
|
|
||||||
p.display_name AS DisplayName,
|
|
||||||
p.telegram_username AS TelegramUsername,
|
|
||||||
p.telegram_id AS TelegramId
|
|
||||||
FROM session_participants sp
|
|
||||||
JOIN players p ON p.id = sp.player_id
|
|
||||||
WHERE sp.session_id = @SessionId
|
|
||||||
AND sp.is_gm = false
|
|
||||||
AND sp.registration_status = @Active
|
|
||||||
ORDER BY p.display_name
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
|
||||||
transaction)).ToList();
|
|
||||||
|
|
||||||
var options = (await connection.QueryAsync<RescheduleOptionDto>(
|
|
||||||
"""
|
|
||||||
SELECT id AS OptionId,
|
|
||||||
display_order AS DisplayOrder,
|
|
||||||
proposed_at AS ProposedAt
|
|
||||||
FROM reschedule_options
|
|
||||||
WHERE proposal_id = @ProposalId
|
|
||||||
ORDER BY display_order
|
|
||||||
""",
|
|
||||||
new { ProposalId = proposal.Id },
|
|
||||||
transaction)).ToList();
|
|
||||||
|
|
||||||
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
|
|
||||||
"""
|
|
||||||
SELECT rov.option_id AS OptionId,
|
|
||||||
p.id AS PlayerId,
|
|
||||||
p.display_name AS DisplayName,
|
|
||||||
p.telegram_username AS TelegramUsername
|
|
||||||
FROM reschedule_option_votes rov
|
|
||||||
JOIN players p ON p.id = rov.player_id
|
|
||||||
WHERE rov.proposal_id = @ProposalId
|
|
||||||
ORDER BY rov.voted_at, p.display_name
|
|
||||||
""",
|
|
||||||
new { ProposalId = proposal.Id },
|
|
||||||
transaction)).ToList();
|
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
|
||||||
|
|
||||||
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||||
proposal.Title,
|
result.Title!,
|
||||||
proposal.CurrentScheduledAt,
|
result.CurrentScheduledAt,
|
||||||
proposal.VotingDeadlineAt,
|
result.VotingDeadlineAt,
|
||||||
options,
|
result.Options,
|
||||||
participants,
|
result.Participants,
|
||||||
votes);
|
result.Votes);
|
||||||
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
|
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(result.Options);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -152,12 +70,11 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
|
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", result.ProposalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await AnswerAsync(command.CallbackQueryId, "Ваш голос учтён. До дедлайна его можно изменить.", ct);
|
await messenger.AnswerInteractionAsync(
|
||||||
|
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
|
||||||
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
|
|
||||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,12 +45,13 @@ public sealed class InitiateRescheduleHandler(
|
|||||||
FROM group_managers gm
|
FROM group_managers gm
|
||||||
JOIN players p ON p.id = gm.player_id
|
JOIN players p ON p.id = gm.player_id
|
||||||
WHERE gm.group_id = s.group_id
|
WHERE gm.group_id = s.group_id
|
||||||
AND p.telegram_id = @TelegramUserId
|
AND p.platform = 'Telegram'
|
||||||
|
AND p.external_user_id = @ExternalUserId
|
||||||
) AS CanManage
|
) AS CanManage
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
WHERE s.id = @SessionId AND s.status != @Cancelled
|
WHERE s.id = @SessionId AND s.status != @Cancelled
|
||||||
""",
|
""",
|
||||||
new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled });
|
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString(), Cancelled = SessionStatus.Cancelled });
|
||||||
|
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
@@ -83,10 +84,10 @@ public sealed class InitiateRescheduleHandler(
|
|||||||
// 3. Create proposal in AwaitingTime status
|
// 3. Create proposal in AwaitingTime status
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
INSERT INTO reschedule_proposals (session_id, proposed_by, source_platform, status)
|
INSERT INTO reschedule_proposals (session_id, proposed_by_external_user_id, source_platform, status)
|
||||||
VALUES (@SessionId, @GmId, 'Telegram', 'AwaitingTime')
|
VALUES (@SessionId, @ProposedBy, 'Telegram', 'AwaitingTime')
|
||||||
""",
|
""",
|
||||||
new { command.SessionId, GmId = command.TelegramUserId });
|
new { command.SessionId, ProposedBy = command.TelegramUserId.ToString() });
|
||||||
|
|
||||||
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
|
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -79,7 +79,7 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
"""
|
"""
|
||||||
SELECT rp.vote_message_id AS VoteMessageId,
|
SELECT rp.vote_message_id AS VoteMessageId,
|
||||||
s.batch_message_id AS BatchMessageId,
|
s.batch_message_id AS BatchMessageId,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.external_group_id::BIGINT AS TelegramChatId,
|
||||||
s.thread_id AS ThreadId
|
s.thread_id AS ThreadId
|
||||||
FROM reschedule_proposals rp
|
FROM reschedule_proposals rp
|
||||||
JOIN sessions s ON s.id = rp.session_id
|
JOIN sessions s ON s.id = rp.session_id
|
||||||
@@ -169,7 +169,7 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
"""
|
"""
|
||||||
SELECT sp.session_id AS SessionId,
|
SELECT sp.session_id AS SessionId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
sp.registration_status AS RegistrationStatus
|
sp.registration_status AS RegistrationStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
|
|||||||
@@ -95,6 +95,68 @@ public sealed class TelegramPlatformMessenger(
|
|||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureTelegram(group.Platform);
|
||||||
|
await bot.SendMessage(
|
||||||
|
chatId: ParseLong(group.ExternalGroupId),
|
||||||
|
messageThreadId: ParseNullableInt(group.ExternalThreadId),
|
||||||
|
text: htmlText,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: BuildActionsMarkup(actions),
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureTelegram(messageRef.Platform);
|
||||||
|
await bot.EditMessageText(
|
||||||
|
chatId: ParseLong(messageRef.ExternalGroupId),
|
||||||
|
messageId: ParseInt(messageRef.ExternalMessageId),
|
||||||
|
text: htmlText,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: BuildActionsMarkup(actions),
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureTelegram(group.Platform);
|
||||||
|
var topic = await bot.CreateForumTopic(
|
||||||
|
chatId: ParseLong(group.ExternalGroupId),
|
||||||
|
name: title,
|
||||||
|
cancellationToken: ct);
|
||||||
|
|
||||||
|
return new PlatformMessageRef(
|
||||||
|
PlatformKind.Telegram,
|
||||||
|
group.ExternalGroupId,
|
||||||
|
topic.MessageThreadId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureTelegram(group.Platform);
|
||||||
|
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bot.DeleteForumTopic(
|
||||||
|
ParseLong(group.ExternalGroupId),
|
||||||
|
ParseInt(group.ExternalThreadId),
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureTelegram(messageRef.Platform);
|
||||||
|
return bot.DeleteMessage(
|
||||||
|
ParseLong(messageRef.ExternalGroupId),
|
||||||
|
ParseInt(messageRef.ExternalMessageId),
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
|
||||||
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
|
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
|
||||||
{
|
{
|
||||||
EnsureTelegram(message.Recipient.Platform);
|
EnsureTelegram(message.Recipient.Platform);
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ using GmRelay.Shared.Features.Sessions.CreateSession;
|
|||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Bot.Features.Sessions.ListSessions;
|
using GmRelay.Bot.Features.Sessions.ListSessions;
|
||||||
|
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
|
||||||
|
using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
|
||||||
|
using BotRescheduleVoteHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler;
|
||||||
using GmRelay.Bot.Features.Sessions.ExportCalendar;
|
using GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
@@ -20,7 +23,7 @@ namespace GmRelay.Bot.Infrastructure.Telegram;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UpdateRouter(
|
public sealed class UpdateRouter(
|
||||||
HandleRsvpHandler rsvpHandler,
|
HandleRsvpHandler rsvpHandler,
|
||||||
CreateSessionHandler createSessionHandler,
|
BotCreateSessionHandler createSessionHandler,
|
||||||
JoinSessionHandler joinSessionHandler,
|
JoinSessionHandler joinSessionHandler,
|
||||||
LeaveSessionHandler leaveSessionHandler,
|
LeaveSessionHandler leaveSessionHandler,
|
||||||
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
|
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
|
||||||
@@ -29,8 +32,8 @@ public sealed class UpdateRouter(
|
|||||||
ListSessionsHandler listSessionsHandler,
|
ListSessionsHandler listSessionsHandler,
|
||||||
ExportCalendarHandler exportCalendarHandler,
|
ExportCalendarHandler exportCalendarHandler,
|
||||||
InitiateRescheduleHandler initiateRescheduleHandler,
|
InitiateRescheduleHandler initiateRescheduleHandler,
|
||||||
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||||||
HandleRescheduleVoteHandler rescheduleVoteHandler,
|
BotRescheduleVoteHandler rescheduleVoteHandler,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
|
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- V020: Player identity linking for unified multi-platform accounts
|
||||||
|
-- =============================================================
|
||||||
|
-- Scope: Allow linking multiple platform identities (Telegram, Discord)
|
||||||
|
-- to a single "primary" player account. All group/session permissions
|
||||||
|
-- resolve through the effective (primary) player id.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- player_links: secondary player → primary player (1:1 on secondary)
|
||||||
|
CREATE TABLE player_links (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
primary_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||||
|
secondary_player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE,
|
||||||
|
linked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
linked_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
|
||||||
|
-- Prevent self-linking at the DB level
|
||||||
|
CONSTRAINT no_self_link CHECK (primary_player_id <> secondary_player_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_player_links_primary_player_id
|
||||||
|
ON player_links(primary_player_id);
|
||||||
|
|
||||||
|
-- identity_audit_log: security-sensitive link/unlink actions
|
||||||
|
CREATE TABLE identity_audit_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||||
|
action VARCHAR(50) NOT NULL, -- 'link', 'unlink', 'link_attempt_conflict'
|
||||||
|
target_platform VARCHAR(50),
|
||||||
|
target_external_user_id VARCHAR(255),
|
||||||
|
performed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
performed_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_identity_audit_log_player_id
|
||||||
|
ON identity_audit_log(player_id);
|
||||||
|
CREATE INDEX ix_identity_audit_log_performed_at
|
||||||
|
ON identity_audit_log(performed_at DESC);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- V021: Add avatar_url column to players table
|
||||||
|
-- =============================================================
|
||||||
|
-- Scope: Support storing avatar URLs for Discord and other platforms.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
ALTER TABLE players
|
||||||
|
ADD COLUMN avatar_url VARCHAR(500);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- V022: Fix incorrectly oriented player_links for Discord↔Telegram
|
||||||
|
-- =============================================================
|
||||||
|
-- Scope: Reverse player_links where Discord was incorrectly made primary
|
||||||
|
-- and Telegram secondary. Telegram (with historical group/session data)
|
||||||
|
-- must always be the primary account.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
UPDATE player_links pl
|
||||||
|
SET primary_player_id = pl.secondary_player_id,
|
||||||
|
secondary_player_id = pl.primary_player_id
|
||||||
|
FROM players p1, players p2
|
||||||
|
WHERE pl.primary_player_id = p1.id
|
||||||
|
AND pl.secondary_player_id = p2.id
|
||||||
|
AND p1.platform = 'Discord'
|
||||||
|
AND p2.platform = 'Telegram';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- V023: Make legacy Telegram columns nullable for multi-platform
|
||||||
|
-- =============================================================
|
||||||
|
-- Scope: Allow Discord (and future platforms) to create players
|
||||||
|
-- and game_groups without legacy telegram_* values.
|
||||||
|
-- Existing Telegram data was backfilled in V016.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
ALTER TABLE game_groups
|
||||||
|
ALTER COLUMN telegram_chat_id DROP NOT NULL,
|
||||||
|
ALTER COLUMN gm_telegram_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE players
|
||||||
|
ALTER COLUMN telegram_id DROP NOT NULL;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- V024: Deprecate legacy Telegram-specific columns
|
||||||
|
-- =============================================================
|
||||||
|
-- Scope: Complete platform migration by backfilling any remaining
|
||||||
|
-- external_* gaps and officially deprecating telegram_* columns.
|
||||||
|
-- No columns are dropped — rollback-safe.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- 1. Backfill players platform identity (safeguard for any rows missed in V016)
|
||||||
|
UPDATE players
|
||||||
|
SET platform = 'Telegram',
|
||||||
|
external_user_id = telegram_id::TEXT,
|
||||||
|
external_username = telegram_username
|
||||||
|
WHERE platform IS NULL;
|
||||||
|
|
||||||
|
-- 2. Backfill game_groups platform identity (safeguard for any rows missed in V016)
|
||||||
|
UPDATE game_groups
|
||||||
|
SET platform = 'Telegram',
|
||||||
|
external_group_id = telegram_chat_id::TEXT
|
||||||
|
WHERE platform IS NULL;
|
||||||
|
|
||||||
|
-- 3. Add platform identity to calendar_subscriptions
|
||||||
|
ALTER TABLE calendar_subscriptions
|
||||||
|
ADD COLUMN user_platform VARCHAR(50),
|
||||||
|
ADD COLUMN user_external_id VARCHAR(255);
|
||||||
|
|
||||||
|
UPDATE calendar_subscriptions
|
||||||
|
SET user_external_id = user_telegram_id::TEXT,
|
||||||
|
user_platform = 'Telegram'
|
||||||
|
WHERE user_platform IS NULL;
|
||||||
|
|
||||||
|
-- 4. Migrate calendar subscription index
|
||||||
|
DROP INDEX IF EXISTS ix_calendar_subscriptions_user_telegram_id;
|
||||||
|
CREATE INDEX ix_calendar_subscriptions_user_external_id ON calendar_subscriptions (user_external_id);
|
||||||
|
|
||||||
|
-- 5. Deprecation comments on legacy columns
|
||||||
|
COMMENT ON COLUMN players.telegram_id IS 'DEPRECATED: use platform + external_user_id';
|
||||||
|
COMMENT ON COLUMN players.telegram_username IS 'DEPRECATED: use external_username';
|
||||||
|
COMMENT ON COLUMN game_groups.telegram_chat_id IS 'DEPRECATED: use platform + external_group_id';
|
||||||
|
COMMENT ON COLUMN game_groups.gm_telegram_id IS 'DEPRECATED: group ownership is tracked in group_managers';
|
||||||
|
COMMENT ON COLUMN calendar_subscriptions.user_telegram_id IS 'DEPRECATED: use user_platform + user_external_id';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- V025: Backfill proposed_by_external_user_id for Telegram proposals
|
||||||
|
-- =============================================================
|
||||||
|
-- Scope: Ensure all reschedule_proposals have proposed_by_external_user_id
|
||||||
|
-- populated so that InitiateRescheduleHandler can stop writing proposed_by.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
UPDATE reschedule_proposals
|
||||||
|
SET proposed_by_external_user_id = proposed_by::TEXT
|
||||||
|
WHERE proposed_by_external_user_id IS NULL
|
||||||
|
AND proposed_by IS NOT NULL;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Public club pages and read-only schedule publication controls.
|
||||||
|
|
||||||
|
ALTER TABLE game_groups
|
||||||
|
ADD COLUMN public_slug VARCHAR(120),
|
||||||
|
ADD COLUMN public_schedule_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN public_schedule_updated_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX ux_game_groups_public_slug
|
||||||
|
ON game_groups (lower(public_slug))
|
||||||
|
WHERE public_slug IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX ix_sessions_public_schedule
|
||||||
|
ON sessions (group_id, scheduled_at)
|
||||||
|
WHERE is_public = true AND status <> 'Cancelled';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Showcase fields for game catalog / public session browsing.
|
||||||
|
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN system VARCHAR(50),
|
||||||
|
ADD COLUMN description TEXT,
|
||||||
|
ADD COLUMN cover_image_url TEXT,
|
||||||
|
ADD COLUMN duration_minutes INTEGER,
|
||||||
|
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
|
||||||
|
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE INDEX ix_sessions_showcase
|
||||||
|
ON sessions (scheduled_at, system, is_one_shot, format)
|
||||||
|
WHERE is_public = true AND status <> 'Cancelled';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Public GM profiles for catalog and club trust pages.
|
||||||
|
|
||||||
|
CREATE TABLE master_profiles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE,
|
||||||
|
public_slug VARCHAR(120),
|
||||||
|
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
display_name VARCHAR(255) NOT NULL,
|
||||||
|
bio TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX ux_master_profiles_public_slug
|
||||||
|
ON master_profiles (lower(public_slug))
|
||||||
|
WHERE public_slug IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX ix_master_profiles_public
|
||||||
|
ON master_profiles (lower(public_slug))
|
||||||
|
WHERE is_public = true AND public_slug IS NOT NULL;
|
||||||
@@ -66,18 +66,24 @@ builder.Services.AddSingleton<SendJoinLinkHandler>();
|
|||||||
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
|
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
|
||||||
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
||||||
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
|
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
|
||||||
builder.Services.AddSingleton<CreateSessionHandler>();
|
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler>();
|
||||||
|
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler>();
|
||||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||||
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
|
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
|
||||||
builder.Services.AddSingleton<CancelSessionHandler>();
|
builder.Services.AddSingleton<CancelSessionHandler>();
|
||||||
|
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler>();
|
||||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
|
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
|
||||||
|
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler>();
|
||||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>();
|
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>();
|
||||||
|
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
|
||||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
|
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
|
||||||
builder.Services.AddSingleton<InitiateRescheduleHandler>();
|
builder.Services.AddSingleton<InitiateRescheduleHandler>();
|
||||||
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
|
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
|
||||||
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
|
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
|
||||||
|
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
|
||||||
|
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
|
||||||
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
|
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||||
|
|
||||||
|
public sealed record DiscordDeleteSessionResult(
|
||||||
|
string ReplyText,
|
||||||
|
SessionBatchViewModel? UpdatedView,
|
||||||
|
string? EmptyMessage = null);
|
||||||
|
|
||||||
|
public sealed class DiscordDeleteSessionHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
DiscordPermissionChecker permissionChecker,
|
||||||
|
DiscordListSessionsHandler listSessionsHandler,
|
||||||
|
ILogger<DiscordDeleteSessionHandler> logger)
|
||||||
|
{
|
||||||
|
public async Task<DiscordDeleteSessionResult> HandleAsync(
|
||||||
|
string guildId,
|
||||||
|
string channelId,
|
||||||
|
ulong userId,
|
||||||
|
ulong resolvedPermissions,
|
||||||
|
ulong guildOwnerId,
|
||||||
|
Guid sessionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
|
||||||
|
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
||||||
|
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
JOIN game_groups g ON g.id = gm.group_id
|
||||||
|
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
||||||
|
new { GuildId = guildId });
|
||||||
|
|
||||||
|
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||||
|
{
|
||||||
|
return new DiscordDeleteSessionResult(
|
||||||
|
"Только owner, администратор или manager могут удалять сессии.",
|
||||||
|
UpdatedView: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
var deletedRows = await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
DELETE FROM sessions s
|
||||||
|
USING game_groups g
|
||||||
|
WHERE s.group_id = g.id
|
||||||
|
AND s.id = @SessionId
|
||||||
|
AND g.platform = 'Discord'
|
||||||
|
AND g.external_group_id = @GuildId
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId, GuildId = guildId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (deletedRows == 0)
|
||||||
|
{
|
||||||
|
return new DiscordDeleteSessionResult(
|
||||||
|
"Сессия не найдена или уже удалена.",
|
||||||
|
UpdatedView: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Deleted Discord session {SessionId} in guild {GuildId}", sessionId, guildId);
|
||||||
|
|
||||||
|
var updatedView = await listSessionsHandler.BuildScheduleAsync(
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
userId,
|
||||||
|
resolvedPermissions,
|
||||||
|
guildOwnerId,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return updatedView is null
|
||||||
|
? new DiscordDeleteSessionResult(
|
||||||
|
"Сессия удалена.",
|
||||||
|
UpdatedView: null,
|
||||||
|
EmptyMessage: "В этом сервере нет предстоящих игр.")
|
||||||
|
: new DiscordDeleteSessionResult("Сессия удалена.", updatedView);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
|
using NetCord;
|
||||||
using NetCord.Rest;
|
using NetCord.Rest;
|
||||||
using NetCord.Services.ApplicationCommands;
|
using NetCord.Services.ApplicationCommands;
|
||||||
|
|
||||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||||
|
|
||||||
[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
|
|
||||||
public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandContext>
|
public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandContext>
|
||||||
{
|
{
|
||||||
private readonly DiscordListSessionsHandler _handler;
|
private readonly DiscordListSessionsHandler _handler;
|
||||||
@@ -13,13 +13,23 @@ public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandC
|
|||||||
_handler = handler;
|
_handler = handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
|
||||||
public async Task ExecuteAsync()
|
public async Task ExecuteAsync()
|
||||||
{
|
{
|
||||||
var guildId = Context.Guild?.Id.ToString()
|
var guildId = Context.Interaction.GuildId?.ToString()
|
||||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
||||||
var channelId = Context.Channel.Id.ToString();
|
var channelId = Context.Channel.Id.ToString();
|
||||||
|
var member = Context.User as GuildInteractionUser;
|
||||||
|
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
|
||||||
|
var guildOwnerId = 0UL;
|
||||||
|
|
||||||
var view = await _handler.BuildScheduleAsync(guildId, channelId, CancellationToken.None);
|
var view = await _handler.BuildScheduleAsync(
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
Context.User.Id,
|
||||||
|
resolvedPermissions,
|
||||||
|
guildOwnerId,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
if (view is null)
|
if (view is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
@@ -9,11 +10,22 @@ internal sealed record DiscordSessionListItemDto(
|
|||||||
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
|
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
|
||||||
int PlayerCount, int WaitlistCount);
|
int PlayerCount, int WaitlistCount);
|
||||||
|
|
||||||
public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
public sealed class DiscordListSessionsHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
DiscordPermissionChecker permissionChecker)
|
||||||
{
|
{
|
||||||
|
public Task<SessionBatchViewModel?> BuildScheduleAsync(
|
||||||
|
string guildId,
|
||||||
|
string channelId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
BuildScheduleAsync(guildId, channelId, 0, 0, 0, cancellationToken);
|
||||||
|
|
||||||
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
|
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
|
||||||
string guildId,
|
string guildId,
|
||||||
string channelId,
|
string channelId,
|
||||||
|
ulong userId,
|
||||||
|
ulong resolvedPermissions,
|
||||||
|
ulong guildOwnerId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
@@ -21,15 +33,15 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
|||||||
var sessions = await connection.QueryAsync<DiscordSessionListItemDto>(
|
var sessions = await connection.QueryAsync<DiscordSessionListItemDto>(
|
||||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
|
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
|
||||||
s.max_players as MaxPlayers,
|
s.max_players as MaxPlayers,
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active)::int as PlayerCount,
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount
|
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted)::int as WaitlistCount
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
JOIN game_groups g ON s.group_id = g.id
|
||||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||||
WHERE g.platform = 'Discord'
|
WHERE g.platform = 'Discord'
|
||||||
AND g.external_group_id = @GuildId
|
AND g.external_group_id = @GuildId
|
||||||
AND s.status != @Cancelled
|
AND s.status != @Cancelled
|
||||||
AND s.scheduled_at > NOW()
|
AND s.scheduled_at > now() - interval '4 hours'
|
||||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
|
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
|
||||||
ORDER BY s.scheduled_at ASC",
|
ORDER BY s.scheduled_at ASC",
|
||||||
new
|
new
|
||||||
@@ -44,11 +56,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
|||||||
if (sessionList.Count == 0)
|
if (sessionList.Count == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
||||||
|
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
JOIN game_groups g ON g.id = gm.group_id
|
||||||
|
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
||||||
|
new { GuildId = guildId });
|
||||||
|
|
||||||
|
var canManage = permissionChecker.CanManageSchedule(
|
||||||
|
guildOwnerId,
|
||||||
|
userId,
|
||||||
|
dbManagerUserIds,
|
||||||
|
resolvedPermissions);
|
||||||
|
|
||||||
var sessionIds = sessionList.Select(s => s.Id).ToList();
|
var sessionIds = sessionList.Select(s => s.Id).ToList();
|
||||||
var participants = await connection.QueryAsync<ParticipantBatchDto>(
|
var participants = await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
@"SELECT sp.session_id as SessionId,
|
@"SELECT sp.session_id as SessionId,
|
||||||
p.display_name as DisplayName,
|
p.display_name as DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
|
p.external_username as TelegramUsername,
|
||||||
sp.registration_status as RegistrationStatus
|
sp.registration_status as RegistrationStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
@@ -60,6 +86,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
|||||||
var batchDtos = sessionList.Select(s => new SessionBatchDto(
|
var batchDtos = sessionList.Select(s => new SessionBatchDto(
|
||||||
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
|
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
|
||||||
|
|
||||||
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
|
var view = SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
|
||||||
|
return canManage ? AddManagerActions(view) : view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static SessionBatchViewModel AddManagerActions(SessionBatchViewModel view) =>
|
||||||
|
view with
|
||||||
|
{
|
||||||
|
Sessions = view.Sessions
|
||||||
|
.Select(session =>
|
||||||
|
{
|
||||||
|
if (SessionStatus.IsCancelled(session.Status))
|
||||||
|
return session;
|
||||||
|
|
||||||
|
var actions = session.AvailableActions
|
||||||
|
.Concat([new AvailableAction("delete_session", $"Удалить {session.ScheduledAt.FormatMoscowShort()}", session.SessionId)])
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return session with { AvailableActions = actions };
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
using GmRelay.DiscordBot.Rendering;
|
using GmRelay.DiscordBot.Rendering;
|
||||||
|
using NetCord;
|
||||||
using NetCord.Rest;
|
using NetCord.Rest;
|
||||||
using NetCord.Services.ApplicationCommands;
|
using NetCord.Services.ApplicationCommands;
|
||||||
|
|
||||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||||
|
|
||||||
[SlashCommand("newsession", "Create a new game session")]
|
|
||||||
public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandContext>
|
public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandContext>
|
||||||
{
|
{
|
||||||
private readonly DiscordNewSessionHandler _handler;
|
private readonly DiscordNewSessionHandler _handler;
|
||||||
@@ -16,15 +16,54 @@ public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandCon
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SlashCommand("newsession", "Create a new game session")]
|
||||||
public async Task ExecuteAsync(
|
public async Task ExecuteAsync(
|
||||||
[SlashCommandParameter(Name = "title", Description = "Game title")] string title,
|
[SlashCommandParameter(Name = "title", Description = "Game title")] string title,
|
||||||
[SlashCommandParameter(Name = "time", Description = "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)")] string time,
|
[SlashCommandParameter(Name = "time", Description = "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)")] string time,
|
||||||
[SlashCommandParameter(Name = "seats", Description = "Maximum number of players")] long? seats = null,
|
[SlashCommandParameter(Name = "seats", Description = "Maximum number of players")] long? seats = null,
|
||||||
[SlashCommandParameter(Name = "link", Description = "Join link")] string? link = null)
|
[SlashCommandParameter(Name = "link", Description = "Join link")] string? link = null)
|
||||||
{
|
{
|
||||||
var guild = Context.Guild
|
_logger.LogInformation(
|
||||||
|
"newsession called by user {UserId} ({UserType}) in guild {GuildId}, channel {ChannelId}",
|
||||||
|
Context.User.Id,
|
||||||
|
Context.User.GetType().Name,
|
||||||
|
Context.Interaction.GuildId,
|
||||||
|
Context.Channel?.Id);
|
||||||
|
|
||||||
|
var guildId = Context.Interaction.GuildId
|
||||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
||||||
|
|
||||||
|
var member = Context.User as GuildInteractionUser;
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
_logger.LogError("Context.User is not GuildInteractionUser. Actual type: {ActualType}", Context.User.GetType().Name);
|
||||||
|
throw new InvalidOperationException("Guild member data not available in interaction.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedPermissions = (ulong)member.Permissions;
|
||||||
|
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
|
||||||
|
|
||||||
|
ulong guildOwnerId = 0;
|
||||||
|
var guildName = guildId.ToString();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
|
||||||
|
guildOwnerId = guild.OwnerId;
|
||||||
|
guildName = guild.Name;
|
||||||
|
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
|
||||||
|
}
|
||||||
|
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"Bot is not a REST member of guild {GuildId}; using resolved permissions from interaction payload",
|
||||||
|
guildId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Unexpected error fetching guild {GuildId}", guildId);
|
||||||
|
}
|
||||||
|
|
||||||
var timeResult = DiscordNewSessionHandler.ParseTimeInput(time);
|
var timeResult = DiscordNewSessionHandler.ParseTimeInput(time);
|
||||||
if (!timeResult.IsSuccess)
|
if (!timeResult.IsSuccess)
|
||||||
{
|
{
|
||||||
@@ -33,55 +72,57 @@ public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandCon
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id);
|
// Defer the response to avoid Discord 3-second interaction timeout
|
||||||
|
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("Creating session for guild {GuildId}, user {UserId}", guildId, Context.User.Id);
|
||||||
|
|
||||||
var view = await _handler.HandleAsync(
|
var view = await _handler.HandleAsync(
|
||||||
guildId: guild.Id.ToString(),
|
guildId: guildId.ToString(),
|
||||||
channelId: Context.Channel.Id.ToString(),
|
channelId: Context.Channel!.Id.ToString(),
|
||||||
|
groupName: guildName,
|
||||||
userId: Context.User.Id,
|
userId: Context.User.Id,
|
||||||
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
|
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
|
||||||
resolvedPermissions: resolvedPermissions,
|
resolvedPermissions: resolvedPermissions,
|
||||||
guildOwnerId: guild.OwnerId,
|
guildOwnerId: guildOwnerId,
|
||||||
title: title,
|
title: title,
|
||||||
scheduledAt: timeResult.Value,
|
scheduledAt: timeResult.Value,
|
||||||
maxPlayers: seats is null ? null : (int)seats.Value,
|
maxPlayers: seats is null ? null : (int)seats.Value,
|
||||||
joinLink: link,
|
joinLink: link,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
|
_logger.LogInformation("Session created successfully. Building render.");
|
||||||
|
|
||||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
|
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
|
||||||
await Context.Interaction.SendResponseAsync(
|
|
||||||
InteractionCallback.Message(new InteractionMessageProperties()
|
_logger.LogInformation("Sending success response.");
|
||||||
.WithContent(":white_check_mark: **Session created successfully!**")
|
|
||||||
.WithEmbeds(embeds)
|
await Context.Interaction.ModifyResponseAsync(message =>
|
||||||
.WithComponents(actionRows)));
|
{
|
||||||
|
message.Content = ":white_check_mark: **Session created successfully!**";
|
||||||
|
message.Embeds = embeds;
|
||||||
|
message.Components = actionRows;
|
||||||
|
});
|
||||||
|
|
||||||
|
_logger.LogInformation("Success response sent.");
|
||||||
}
|
}
|
||||||
catch (UnauthorizedAccessException ex)
|
catch (UnauthorizedAccessException ex)
|
||||||
{
|
{
|
||||||
await Context.Interaction.SendResponseAsync(
|
_logger.LogWarning(ex, "Unauthorized session creation attempt by user {UserId}", Context.User.Id);
|
||||||
InteractionCallback.Message($":no_entry: {ex.Message}"));
|
await Context.Interaction.ModifyResponseAsync(message =>
|
||||||
|
{
|
||||||
|
message.Content = $":no_entry: {ex.Message}";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to create session for user {UserId} in guild {GuildId}", Context.User.Id, guild.Id);
|
_logger.LogError(ex, "Failed to create session for user {UserId} in guild {GuildId}", Context.User.Id, guildId);
|
||||||
await Context.Interaction.SendResponseAsync(
|
await Context.Interaction.ModifyResponseAsync(message =>
|
||||||
InteractionCallback.Message(":boom: An error occurred while creating the session."));
|
{
|
||||||
|
message.Content = ":boom: An error occurred while creating the session.";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ulong GetResolvedPermissions(NetCord.Gateway.Guild guild, ulong userId)
|
|
||||||
{
|
|
||||||
if (!guild.Users.TryGetValue(userId, out var guildUser))
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
ulong resolved = 0;
|
|
||||||
foreach (var roleId in guildUser.RoleIds)
|
|
||||||
{
|
|
||||||
if (guild.Roles.TryGetValue(roleId, out var role))
|
|
||||||
resolved |= (ulong)role.Permissions;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Platform;
|
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||||
|
|
||||||
@@ -12,35 +12,40 @@ public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, strin
|
|||||||
public sealed class DiscordNewSessionHandler(
|
public sealed class DiscordNewSessionHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
DiscordPermissionChecker permissionChecker,
|
DiscordPermissionChecker permissionChecker,
|
||||||
IPlatformMessenger messenger,
|
|
||||||
ILogger<DiscordNewSessionHandler> logger)
|
ILogger<DiscordNewSessionHandler> logger)
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||||
|
|
||||||
public static TimeParseResult ParseTimeInput(string input)
|
public static TimeParseResult ParseTimeInput(string input)
|
||||||
{
|
{
|
||||||
if (DateTimeOffset.TryParseExact(
|
var trimmed = input.Trim();
|
||||||
input.Trim(),
|
|
||||||
|
if (DateTime.TryParseExact(
|
||||||
|
trimmed,
|
||||||
"yyyy-MM-dd HH:mm",
|
"yyyy-MM-dd HH:mm",
|
||||||
System.Globalization.CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
System.Globalization.DateTimeStyles.AssumeUniversal,
|
DateTimeStyles.None,
|
||||||
out var result))
|
out var dt1))
|
||||||
{
|
{
|
||||||
if (result < DateTimeOffset.UtcNow)
|
var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime();
|
||||||
|
if (offset < DateTimeOffset.UtcNow)
|
||||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
||||||
|
|
||||||
return new TimeParseResult(true, result.ToUniversalTime(), null);
|
return new TimeParseResult(true, offset, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DateTimeOffset.TryParseExact(
|
if (DateTime.TryParseExact(
|
||||||
input.Trim(),
|
trimmed,
|
||||||
"dd.MM.yyyy HH:mm",
|
"dd.MM.yyyy HH:mm",
|
||||||
System.Globalization.CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
System.Globalization.DateTimeStyles.AssumeUniversal,
|
DateTimeStyles.None,
|
||||||
out var altResult))
|
out var dt2))
|
||||||
{
|
{
|
||||||
if (altResult < DateTimeOffset.UtcNow)
|
var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime();
|
||||||
|
if (offset < DateTimeOffset.UtcNow)
|
||||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
||||||
|
|
||||||
return new TimeParseResult(true, altResult.ToUniversalTime(), null);
|
return new TimeParseResult(true, offset, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
|
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
|
||||||
@@ -49,6 +54,7 @@ public sealed class DiscordNewSessionHandler(
|
|||||||
public async Task<SessionBatchViewModel> HandleAsync(
|
public async Task<SessionBatchViewModel> HandleAsync(
|
||||||
string guildId,
|
string guildId,
|
||||||
string channelId,
|
string channelId,
|
||||||
|
string groupName,
|
||||||
ulong userId,
|
ulong userId,
|
||||||
string userDisplayName,
|
string userDisplayName,
|
||||||
ulong resolvedPermissions,
|
ulong resolvedPermissions,
|
||||||
@@ -60,13 +66,18 @@ public sealed class DiscordNewSessionHandler(
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
var displayGroupName = string.IsNullOrWhiteSpace(groupName) || string.Equals(groupName, guildId, StringComparison.Ordinal)
|
||||||
|
? title
|
||||||
|
: groupName.Trim();
|
||||||
|
|
||||||
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
||||||
@"SELECT CAST(p.external_user_id AS BIGINT)
|
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||||
FROM group_managers gm
|
FROM group_managers gm
|
||||||
JOIN players p ON p.id = gm.player_id
|
JOIN players p ON p.id = gm.player_id
|
||||||
JOIN game_groups g ON g.id = gm.group_id
|
JOIN game_groups g ON g.id = gm.group_id
|
||||||
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
WHERE g.platform = 'Discord'
|
||||||
|
AND p.platform = 'Discord'
|
||||||
|
AND g.external_group_id = @GuildId",
|
||||||
new { GuildId = guildId });
|
new { GuildId = guildId });
|
||||||
|
|
||||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||||
@@ -75,6 +86,7 @@ public sealed class DiscordNewSessionHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
var transactionCommitted = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
@@ -89,13 +101,13 @@ public sealed class DiscordNewSessionHandler(
|
|||||||
|
|
||||||
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
|
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
|
||||||
VALUES (@GuildId, 'Discord', @GuildId, @ChannelId)
|
VALUES (@GroupName, 'Discord', @GuildId, @ChannelId)
|
||||||
ON CONFLICT (platform, external_group_id)
|
ON CONFLICT (platform, external_group_id)
|
||||||
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
|
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
|
||||||
DO UPDATE SET name = EXCLUDED.name,
|
DO UPDATE SET name = EXCLUDED.name,
|
||||||
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
|
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
|
||||||
RETURNING id",
|
RETURNING id",
|
||||||
new { GuildId = guildId, ChannelId = channelId },
|
new { GroupName = displayGroupName, GuildId = guildId, ChannelId = channelId },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
@@ -125,23 +137,19 @@ public sealed class DiscordNewSessionHandler(
|
|||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
transactionCommitted = true;
|
||||||
logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId);
|
logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId);
|
||||||
|
|
||||||
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
|
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
|
||||||
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
|
return SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||||
|
|
||||||
await messenger.SendScheduleAsync(
|
|
||||||
new PlatformScheduleMessage(
|
|
||||||
new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId),
|
|
||||||
view,
|
|
||||||
null),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return view;
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
if (!transactionCommitted)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||||
|
|
||||||
|
using NetCord;
|
||||||
using NetCord.Rest;
|
using NetCord.Rest;
|
||||||
using NetCord.Services.ApplicationCommands;
|
using NetCord.Services.ApplicationCommands;
|
||||||
|
|
||||||
[SlashCommand("reschedule", "Initiate reschedule voting for a session")]
|
|
||||||
public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandContext>
|
public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandContext>
|
||||||
{
|
{
|
||||||
private readonly DiscordRescheduleHandler _handler;
|
private readonly DiscordRescheduleHandler _handler;
|
||||||
@@ -15,6 +15,7 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SlashCommand("reschedule", "Initiate reschedule voting for a session")]
|
||||||
public async Task ExecuteAsync(
|
public async Task ExecuteAsync(
|
||||||
[SlashCommandParameter(Name = "session", Description = "Session ID to reschedule")] string sessionIdText,
|
[SlashCommandParameter(Name = "session", Description = "Session ID to reschedule")] string sessionIdText,
|
||||||
[SlashCommandParameter(Name = "option1", Description = "First time option (YYYY-MM-DD HH:mm)")] string option1,
|
[SlashCommandParameter(Name = "option1", Description = "First time option (YYYY-MM-DD HH:mm)")] string option1,
|
||||||
@@ -22,9 +23,44 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
|
|||||||
[SlashCommandParameter(Name = "option3", Description = "Third time option (optional)")] string? option3 = null,
|
[SlashCommandParameter(Name = "option3", Description = "Third time option (optional)")] string? option3 = null,
|
||||||
[SlashCommandParameter(Name = "deadline", Description = "Voting deadline (YYYY-MM-DD HH:mm)")] string deadline = "")
|
[SlashCommandParameter(Name = "deadline", Description = "Voting deadline (YYYY-MM-DD HH:mm)")] string deadline = "")
|
||||||
{
|
{
|
||||||
var guild = Context.Guild
|
_logger.LogInformation(
|
||||||
|
"reschedule called by user {UserId} ({UserType}) in guild {GuildId}",
|
||||||
|
Context.User.Id,
|
||||||
|
Context.User.GetType().Name,
|
||||||
|
Context.Interaction.GuildId);
|
||||||
|
|
||||||
|
var guildId = Context.Interaction.GuildId
|
||||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
||||||
|
|
||||||
|
var member = Context.User as GuildInteractionUser;
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
_logger.LogError("Context.User is not GuildInteractionUser. Actual type: {ActualType}", Context.User.GetType().Name);
|
||||||
|
throw new InvalidOperationException("Guild member data not available in interaction.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedPermissions = (ulong)member.Permissions;
|
||||||
|
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
|
||||||
|
|
||||||
|
ulong guildOwnerId = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
|
||||||
|
guildOwnerId = guild.OwnerId;
|
||||||
|
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
|
||||||
|
}
|
||||||
|
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"Bot is not a REST member of guild {GuildId}; using resolved permissions from interaction payload",
|
||||||
|
guildId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Unexpected error fetching guild {GuildId}", guildId);
|
||||||
|
}
|
||||||
|
|
||||||
if (!Guid.TryParse(sessionIdText, out var sessionId))
|
if (!Guid.TryParse(sessionIdText, out var sessionId))
|
||||||
{
|
{
|
||||||
await Context.Interaction.SendResponseAsync(
|
await Context.Interaction.SendResponseAsync(
|
||||||
@@ -64,54 +100,55 @@ public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandCon
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id);
|
// Defer the response to avoid Discord 3-second interaction timeout
|
||||||
|
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("Initiating reschedule for session {SessionId} in guild {GuildId}", sessionId, guildId);
|
||||||
|
|
||||||
var result = await _handler.HandleAsync(
|
var result = await _handler.HandleAsync(
|
||||||
guildId: guild.Id.ToString(),
|
guildId: guildId.ToString(),
|
||||||
channelId: Context.Channel.Id.ToString(),
|
channelId: Context.Channel!.Id.ToString(),
|
||||||
userId: Context.User.Id,
|
userId: Context.User.Id,
|
||||||
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
|
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
|
||||||
resolvedPermissions: resolvedPermissions,
|
resolvedPermissions: resolvedPermissions,
|
||||||
guildOwnerId: guild.OwnerId,
|
guildOwnerId: guildOwnerId,
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
options: parsedOptions,
|
options: parsedOptions,
|
||||||
deadline: deadlineResult.Value,
|
deadline: deadlineResult.Value,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
await Context.Interaction.SendResponseAsync(
|
_logger.LogInformation("Reschedule voting started for session {SessionId}, proposal {ProposalId}", sessionId, result.ProposalId);
|
||||||
InteractionCallback.Message(
|
|
||||||
$"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC."));
|
await Context.Interaction.ModifyResponseAsync(message =>
|
||||||
|
{
|
||||||
|
message.Content = $"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC.";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
catch (UnauthorizedAccessException ex)
|
catch (UnauthorizedAccessException ex)
|
||||||
{
|
{
|
||||||
await Context.Interaction.SendResponseAsync(
|
_logger.LogWarning(ex, "Unauthorized reschedule attempt by user {UserId}", Context.User.Id);
|
||||||
InteractionCallback.Message($":no_entry: {ex.Message}"));
|
await Context.Interaction.ModifyResponseAsync(message =>
|
||||||
|
{
|
||||||
|
message.Content = $":no_entry: {ex.Message}";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
await Context.Interaction.SendResponseAsync(
|
_logger.LogWarning(ex, "Invalid reschedule request by user {UserId}", Context.User.Id);
|
||||||
InteractionCallback.Message($":warning: {ex.Message}"));
|
await Context.Interaction.ModifyResponseAsync(message =>
|
||||||
|
{
|
||||||
|
message.Content = $":warning: {ex.Message}";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to initiate reschedule for session {SessionId}", sessionId);
|
_logger.LogError(ex, "Failed to initiate reschedule for session {SessionId}", sessionId);
|
||||||
await Context.Interaction.SendResponseAsync(
|
await Context.Interaction.ModifyResponseAsync(message =>
|
||||||
InteractionCallback.Message(":boom: Ошибка при запуске голосования."));
|
{
|
||||||
|
message.Content = ":boom: Ошибка при запуске голосования.";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ulong GetResolvedPermissions(NetCord.Gateway.Guild guild, ulong userId)
|
|
||||||
{
|
|
||||||
if (!guild.Users.TryGetValue(userId, out var guildUser))
|
|
||||||
return 0;
|
|
||||||
ulong resolved = 0;
|
|
||||||
foreach (var roleId in guildUser.RoleIds)
|
|
||||||
{
|
|
||||||
if (guild.Roles.TryGetValue(roleId, out var role))
|
|
||||||
resolved |= (ulong)role.Permissions;
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,114 +1,46 @@
|
|||||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
|
||||||
|
|
||||||
using Dapper;
|
|
||||||
using GmRelay.DiscordBot.Rendering;
|
using GmRelay.DiscordBot.Rendering;
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
using Npgsql;
|
|
||||||
using NetCord.Rest;
|
using NetCord.Rest;
|
||||||
|
|
||||||
|
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||||
|
|
||||||
public sealed record DiscordRescheduleVoteInput(
|
public sealed record DiscordRescheduleVoteInput(
|
||||||
Guid OptionId, ulong UserId, string InteractionId,
|
Guid OptionId,
|
||||||
string GuildId, string ChannelId, string MessageId);
|
ulong UserId,
|
||||||
|
string InteractionId,
|
||||||
|
string GuildId,
|
||||||
|
string ChannelId,
|
||||||
|
string MessageId);
|
||||||
|
|
||||||
public sealed class DiscordRescheduleVoteHandler(
|
public sealed class DiscordRescheduleVoteHandler(
|
||||||
NpgsqlDataSource dataSource,
|
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
|
||||||
RestClient restClient,
|
RestClient restClient,
|
||||||
ILogger<DiscordRescheduleVoteHandler> logger)
|
ILogger<DiscordRescheduleVoteHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task<string> HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct)
|
public async Task<string> HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
var command = new HandleRescheduleVoteCommand(
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
input.OptionId,
|
||||||
|
new PlatformUser(PlatformKind.Discord, input.UserId.ToString(), string.Empty, null),
|
||||||
|
new PlatformGroup(PlatformKind.Discord, input.GuildId, string.Empty, input.ChannelId),
|
||||||
|
input.InteractionId,
|
||||||
|
new PlatformMessageRef(PlatformKind.Discord, input.ChannelId, null, input.MessageId));
|
||||||
|
|
||||||
// 1. Load proposal + option
|
var result = await sharedHandler.HandleAsync(command, ct);
|
||||||
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
|
|
||||||
"""
|
|
||||||
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt,
|
|
||||||
s.title AS Title, s.scheduled_at AS CurrentScheduledAt
|
|
||||||
FROM reschedule_options ro
|
|
||||||
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
|
|
||||||
JOIN sessions s ON s.id = rp.session_id
|
|
||||||
WHERE ro.id = @OptionId AND rp.status = 'Voting'
|
|
||||||
""",
|
|
||||||
new { input.OptionId },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
if (proposal is null)
|
if (!result.Success)
|
||||||
return "Голосование уже завершено или не найдено.";
|
{
|
||||||
|
return result.ReplyText!;
|
||||||
|
}
|
||||||
|
|
||||||
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
|
|
||||||
return "Дедлайн уже прошёл. Результаты скоро будут применены.";
|
|
||||||
|
|
||||||
// 2. Verify participant (Discord platform)
|
|
||||||
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.platform = 'Discord'
|
|
||||||
AND p.external_user_id = @UserId
|
|
||||||
AND sp.is_gm = false
|
|
||||||
AND sp.registration_status = @Active
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId, UserId = input.UserId.ToString(), Active = ParticipantRegistrationStatus.Active },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
if (playerId is null)
|
|
||||||
return "Вы не являетесь участником этой сессии.";
|
|
||||||
|
|
||||||
// 3. Upsert vote
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
|
||||||
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
|
|
||||||
VALUES (@ProposalId, @PlayerId, @OptionId)
|
|
||||||
ON CONFLICT (proposal_id, player_id) DO UPDATE
|
|
||||||
SET option_id = EXCLUDED.option_id, voted_at = now()
|
|
||||||
""",
|
|
||||||
new { ProposalId = proposal.Id, PlayerId = playerId.Value, input.OptionId },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
// 4. Reload participants, options, votes for re-rendering
|
|
||||||
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
|
||||||
"""
|
|
||||||
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId
|
|
||||||
FROM session_participants sp
|
|
||||||
JOIN players p ON p.id = sp.player_id
|
|
||||||
WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active
|
|
||||||
ORDER BY p.display_name
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
|
||||||
transaction)).ToList();
|
|
||||||
|
|
||||||
var options = (await connection.QueryAsync<RescheduleOptionDto>(
|
|
||||||
"""
|
|
||||||
SELECT id AS OptionId, display_order AS DisplayOrder, proposed_at AS ProposedAt
|
|
||||||
FROM reschedule_options
|
|
||||||
WHERE proposal_id = @ProposalId
|
|
||||||
ORDER BY display_order
|
|
||||||
""",
|
|
||||||
new { ProposalId = proposal.Id },
|
|
||||||
transaction)).ToList();
|
|
||||||
|
|
||||||
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
|
|
||||||
"""
|
|
||||||
SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername
|
|
||||||
FROM reschedule_option_votes rov
|
|
||||||
JOIN players p ON p.id = rov.player_id
|
|
||||||
WHERE rov.proposal_id = @ProposalId
|
|
||||||
ORDER BY rov.voted_at, p.display_name
|
|
||||||
""",
|
|
||||||
new { ProposalId = proposal.Id },
|
|
||||||
transaction)).ToList();
|
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
|
||||||
|
|
||||||
// 5. Re-render and update Discord vote message
|
|
||||||
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
|
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
|
||||||
proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt,
|
result.Title!,
|
||||||
options, participants, votes);
|
result.CurrentScheduledAt,
|
||||||
|
result.VotingDeadlineAt,
|
||||||
|
result.Options,
|
||||||
|
result.Participants,
|
||||||
|
result.Votes);
|
||||||
|
|
||||||
var channelIdUlong = ulong.Parse(input.ChannelId);
|
var channelIdUlong = ulong.Parse(input.ChannelId);
|
||||||
var messageIdUlong = ulong.Parse(input.MessageId);
|
var messageIdUlong = ulong.Parse(input.MessageId);
|
||||||
@@ -123,9 +55,9 @@ public sealed class DiscordRescheduleVoteHandler(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id);
|
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", result.ProposalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return "Ваш голос учтён. До дедлайна его можно изменить.";
|
return result.ReplyText!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
|||||||
"""
|
"""
|
||||||
SELECT sp.session_id AS SessionId,
|
SELECT sp.session_id AS SessionId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
sp.registration_status AS RegistrationStatus
|
sp.registration_status AS RegistrationStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||||
|
using GmRelay.DiscordBot.Rendering;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
|
using System.Collections;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using NetCord;
|
using NetCord;
|
||||||
using NetCord.Rest;
|
using NetCord.Rest;
|
||||||
@@ -14,6 +16,7 @@ public sealed class DiscordSessionInteractionModule(
|
|||||||
JoinSessionHandler joinSessionHandler,
|
JoinSessionHandler joinSessionHandler,
|
||||||
LeaveSessionHandler leaveSessionHandler,
|
LeaveSessionHandler leaveSessionHandler,
|
||||||
HandleRsvpHandler rsvpHandler,
|
HandleRsvpHandler rsvpHandler,
|
||||||
|
DiscordDeleteSessionHandler deleteSessionHandler,
|
||||||
DiscordRescheduleVoteHandler voteHandler,
|
DiscordRescheduleVoteHandler voteHandler,
|
||||||
DiscordInteractionReplyCache interactionReplies,
|
DiscordInteractionReplyCache interactionReplies,
|
||||||
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
|
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
|
||||||
@@ -28,21 +31,22 @@ public sealed class DiscordSessionInteractionModule(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var input = CreateInput(parsedSessionId);
|
var input = CreateInput(parsedSessionId);
|
||||||
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||||
|
SessionInteractionResult result;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await joinSessionHandler.HandleAsync(
|
result = await joinSessionHandler.HandleAsync(
|
||||||
DiscordSessionInteractionMapper.CreateJoinCommand(input),
|
DiscordSessionInteractionMapper.CreateJoinCommand(input) with { DeferScheduleUpdate = true },
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
|
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
|
||||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await CompleteWithStoredReplyAsync(input.InteractionId);
|
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[ComponentInteraction("leave_session")]
|
[ComponentInteraction("leave_session")]
|
||||||
@@ -55,21 +59,56 @@ public sealed class DiscordSessionInteractionModule(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var input = CreateInput(parsedSessionId);
|
var input = CreateInput(parsedSessionId);
|
||||||
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||||
|
SessionInteractionResult result;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await leaveSessionHandler.HandleAsync(
|
result = await leaveSessionHandler.HandleAsync(
|
||||||
DiscordSessionInteractionMapper.CreateLeaveCommand(input),
|
DiscordSessionInteractionMapper.CreateLeaveCommand(input) with { DeferScheduleUpdate = true },
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
|
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
|
||||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await CompleteWithStoredReplyAsync(input.InteractionId);
|
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ComponentInteraction("delete_session")]
|
||||||
|
public async Task DeleteAsync(string sessionId)
|
||||||
|
{
|
||||||
|
if (!Guid.TryParse(sessionId, out var parsedSessionId))
|
||||||
|
{
|
||||||
|
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var input = CreateInput(parsedSessionId);
|
||||||
|
var member = Context.User as GuildInteractionUser;
|
||||||
|
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
|
||||||
|
|
||||||
|
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await deleteSessionHandler.HandleAsync(
|
||||||
|
guildId: input.GuildId,
|
||||||
|
channelId: input.ChannelId,
|
||||||
|
userId: input.UserId,
|
||||||
|
resolvedPermissions: resolvedPermissions,
|
||||||
|
guildOwnerId: 0,
|
||||||
|
sessionId: parsedSessionId,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
await CompleteDeleteResponseAsync(result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to handle Discord delete interaction for session {SessionId}", parsedSessionId);
|
||||||
|
await FollowupEphemeralAsync("Не удалось удалить сессию.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[ComponentInteraction("rsvp")]
|
[ComponentInteraction("rsvp")]
|
||||||
@@ -124,7 +163,7 @@ public sealed class DiscordSessionInteractionModule(
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
|
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
|
||||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +207,7 @@ public sealed class DiscordSessionInteractionModule(
|
|||||||
|
|
||||||
private DiscordSessionInteractionInput CreateInput(Guid sessionId)
|
private DiscordSessionInteractionInput CreateInput(Guid sessionId)
|
||||||
{
|
{
|
||||||
var guild = Context.Guild
|
var guildId = Context.Interaction.GuildId?.ToString(CultureInfo.InvariantCulture)
|
||||||
?? throw new InvalidOperationException("Session buttons can only be used in a guild.");
|
?? throw new InvalidOperationException("Session buttons can only be used in a guild.");
|
||||||
var message = Context.Interaction.Message
|
var message = Context.Interaction.Message
|
||||||
?? throw new InvalidOperationException("Session button interaction must include a message.");
|
?? throw new InvalidOperationException("Session button interaction must include a message.");
|
||||||
@@ -176,7 +215,7 @@ public sealed class DiscordSessionInteractionModule(
|
|||||||
return new DiscordSessionInteractionInput(
|
return new DiscordSessionInteractionInput(
|
||||||
SessionId: sessionId,
|
SessionId: sessionId,
|
||||||
InteractionId: Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
InteractionId: Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
GuildId: guild.Id.ToString(CultureInfo.InvariantCulture),
|
GuildId: guildId,
|
||||||
ChannelId: Context.Channel.Id.ToString(CultureInfo.InvariantCulture),
|
ChannelId: Context.Channel.Id.ToString(CultureInfo.InvariantCulture),
|
||||||
MessageId: message.Id.ToString(CultureInfo.InvariantCulture),
|
MessageId: message.Id.ToString(CultureInfo.InvariantCulture),
|
||||||
UserId: Context.User.Id,
|
UserId: Context.User.Id,
|
||||||
@@ -190,9 +229,85 @@ public sealed class DiscordSessionInteractionModule(
|
|||||||
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
|
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task CompleteScheduleUpdateResponseAsync(string interactionId, SessionInteractionResult result)
|
||||||
|
{
|
||||||
|
var updatedView = result.UpdatedView;
|
||||||
|
if (updatedView is not null && SourceMessageHasDeleteAction())
|
||||||
|
{
|
||||||
|
updatedView = DiscordListSessionsHandler.AddManagerActions(updatedView);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedView is not null)
|
||||||
|
{
|
||||||
|
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(updatedView);
|
||||||
|
await ModifyResponseAsync(options =>
|
||||||
|
{
|
||||||
|
options.Embeds = embeds;
|
||||||
|
options.Components = actionRows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var reply = interactionReplies.Take(interactionId);
|
||||||
|
await FollowupEphemeralAsync(reply?.Text ?? result.ReplyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CompleteDeleteResponseAsync(DiscordDeleteSessionResult result)
|
||||||
|
{
|
||||||
|
if (result.UpdatedView is not null)
|
||||||
|
{
|
||||||
|
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(result.UpdatedView);
|
||||||
|
await ModifyResponseAsync(options =>
|
||||||
|
{
|
||||||
|
options.Embeds = embeds;
|
||||||
|
options.Components = actionRows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (result.EmptyMessage is not null)
|
||||||
|
{
|
||||||
|
await ModifyResponseAsync(options =>
|
||||||
|
{
|
||||||
|
options.Content = result.EmptyMessage;
|
||||||
|
options.Embeds = [];
|
||||||
|
options.Components = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await FollowupEphemeralAsync(result.ReplyText);
|
||||||
|
}
|
||||||
|
|
||||||
private Task CompleteResponseAsync(string text) =>
|
private Task CompleteResponseAsync(string text) =>
|
||||||
ModifyResponseAsync(options => options.Content = text);
|
ModifyResponseAsync(options => options.Content = text);
|
||||||
|
|
||||||
|
private Task FollowupEphemeralAsync(string text) =>
|
||||||
|
FollowupAsync(new InteractionMessageProperties()
|
||||||
|
.WithContent(text)
|
||||||
|
.WithFlags(MessageFlags.Ephemeral));
|
||||||
|
|
||||||
|
private bool SourceMessageHasDeleteAction() =>
|
||||||
|
Context.Interaction.Message?.Components.Any(ComponentContainsDeleteAction) == true;
|
||||||
|
|
||||||
|
private static bool ComponentContainsDeleteAction(object? component)
|
||||||
|
{
|
||||||
|
if (component is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (component is IInteractiveComponent interactive
|
||||||
|
&& interactive.CustomId.StartsWith("delete_session:", StringComparison.Ordinal))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var nestedComponents = component.GetType().GetProperty("Components")?.GetValue(component) as IEnumerable;
|
||||||
|
if (nestedComponents is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
foreach (var nestedComponent in nestedComponents)
|
||||||
|
{
|
||||||
|
if (ComponentContainsDeleteAction(nestedComponent))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
|
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
|
||||||
InteractionCallback.Message(
|
InteractionCallback.Message(
|
||||||
new InteractionMessageProperties()
|
new InteractionMessageProperties()
|
||||||
|
|||||||
@@ -6,11 +6,14 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
|
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
|
||||||
|
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
|
||||||
|
<NoWarn>$(NoWarn);DAP005</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
|
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
|
||||||
<PackageReference Include="Dapper" Version="2.1.72" />
|
<PackageReference Include="Dapper" Version="2.1.72" />
|
||||||
|
<PackageReference Include="Dapper.AOT" Version="1.0.48" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
||||||
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
|
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
|
||||||
<PackageReference Include="NetCord.Hosting.Services" Version="1.0.0-alpha.489" />
|
<PackageReference Include="NetCord.Hosting.Services" Version="1.0.0-alpha.489" />
|
||||||
|
|||||||
@@ -77,6 +77,38 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
|||||||
await restClient.SendMessageAsync(GetChannelId(group), htmlText);
|
await restClient.SendMessageAsync(GetChannelId(group), htmlText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var rows = BuildActionRows(actions);
|
||||||
|
await restClient.SendMessageAsync(GetChannelId(group), new MessageProperties().WithContent(htmlText).WithComponents(rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
|
||||||
|
var messageId = ParseSnowflake(messageRef.ExternalMessageId);
|
||||||
|
var rows = BuildActionRows(actions);
|
||||||
|
await restClient.ModifyMessageAsync(channelId, messageId, options =>
|
||||||
|
{
|
||||||
|
options.Content = htmlText;
|
||||||
|
options.Components = rows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Discord thread creation is not implemented in this adapter
|
||||||
|
return Task.FromResult(new PlatformMessageRef(PlatformKind.Discord, group.ExternalGroupId, group.ExternalThreadId, string.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public async Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
|
||||||
|
await restClient.DeleteMessageAsync(channelId, ParseSnowflake(messageRef.ExternalMessageId));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
|
public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await SendDirectContentAsync(message.Recipient, message.HtmlText, ct);
|
await SendDirectContentAsync(message.Recipient, message.HtmlText, ct);
|
||||||
@@ -98,17 +130,34 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var channelId = GetChannelId(request.Group);
|
var channelId = GetChannelId(request.Group);
|
||||||
var message = await restClient.SendMessageAsync(
|
try
|
||||||
channelId,
|
{
|
||||||
new MessageProperties()
|
var message = await restClient.SendMessageAsync(
|
||||||
.WithEmbeds([BuildConfirmationEmbed(request)])
|
channelId,
|
||||||
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
|
new MessageProperties()
|
||||||
|
.WithEmbeds([BuildConfirmationEmbed(request)])
|
||||||
|
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
|
||||||
|
|
||||||
return new PlatformMessageRef(
|
logger?.LogInformation(
|
||||||
PlatformKind.Discord,
|
"Confirmation request sent to Discord channel {ChannelId}, message id {MessageId}",
|
||||||
request.Group.ExternalGroupId,
|
channelId,
|
||||||
null,
|
message.Id);
|
||||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
|
||||||
|
return new PlatformMessageRef(
|
||||||
|
PlatformKind.Discord,
|
||||||
|
request.Group.ExternalGroupId,
|
||||||
|
null,
|
||||||
|
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger?.LogError(
|
||||||
|
ex,
|
||||||
|
"Failed to send confirmation request to Discord channel {ChannelId} for session {SessionId}",
|
||||||
|
channelId,
|
||||||
|
request.SessionId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
|
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
|
||||||
@@ -135,15 +184,32 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var channelId = GetChannelId(notification.Group);
|
var channelId = GetChannelId(notification.Group);
|
||||||
var message = await restClient.SendMessageAsync(
|
try
|
||||||
channelId,
|
{
|
||||||
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
|
var message = await restClient.SendMessageAsync(
|
||||||
|
channelId,
|
||||||
|
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
|
||||||
|
|
||||||
return new PlatformMessageRef(
|
logger?.LogInformation(
|
||||||
PlatformKind.Discord,
|
"Join link sent to Discord channel {ChannelId}, message id {MessageId}",
|
||||||
notification.Group.ExternalGroupId,
|
channelId,
|
||||||
null,
|
message.Id);
|
||||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
|
||||||
|
return new PlatformMessageRef(
|
||||||
|
PlatformKind.Discord,
|
||||||
|
notification.Group.ExternalGroupId,
|
||||||
|
null,
|
||||||
|
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger?.LogError(
|
||||||
|
ex,
|
||||||
|
"Failed to send join link to Discord channel {ChannelId} for session {SessionId}",
|
||||||
|
channelId,
|
||||||
|
notification.SessionId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendDirectSessionNotificationAsync(
|
public async Task SendDirectSessionNotificationAsync(
|
||||||
@@ -272,14 +338,16 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
|||||||
? "—"
|
? "—"
|
||||||
: string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User)));
|
: string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User)));
|
||||||
|
|
||||||
return new EmbedProperties()
|
var embed = new EmbedProperties()
|
||||||
.WithTitle($"Ссылка на игру: {notification.Title}")
|
.WithTitle($"Ссылка на игру: {notification.Title}")
|
||||||
.WithDescription(
|
.WithDescription(
|
||||||
$"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" +
|
$"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" +
|
||||||
$"Ссылка: {notification.JoinLink}\n\n" +
|
$"Ссылка: {notification.JoinLink}\n\n" +
|
||||||
$"Участники: {mentions}")
|
$"Участники: {mentions}")
|
||||||
.WithUrl(notification.JoinLink)
|
|
||||||
.WithColor(new Color(0x57F287));
|
.WithColor(new Color(0x57F287));
|
||||||
|
|
||||||
|
var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(notification.JoinLink);
|
||||||
|
return embedUrl is null ? embed : embed.WithUrl(embedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<ActionRowProperties> BuildRsvpRows(Guid sessionId, bool disabled)
|
private static IReadOnlyList<ActionRowProperties> BuildRsvpRows(Guid sessionId, bool disabled)
|
||||||
@@ -367,6 +435,30 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
|||||||
return ParseSnowflake(channelId);
|
return ParseSnowflake(channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ActionRowProperties> BuildActionRows(IReadOnlyList<PlatformMessageAction> actions)
|
||||||
|
{
|
||||||
|
if (actions.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = new List<ActionRowProperties>();
|
||||||
|
foreach (var chunk in actions.Chunk(5))
|
||||||
|
{
|
||||||
|
var row = new ActionRowProperties();
|
||||||
|
foreach (var action in chunk)
|
||||||
|
{
|
||||||
|
row.Add(new ButtonProperties(action.Key, action.Label, ButtonStyle.Secondary)
|
||||||
|
{
|
||||||
|
CustomId = action.Payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
private static ulong ParseSnowflake(string value) =>
|
private static ulong ParseSnowflake(string value) =>
|
||||||
ulong.Parse(value, CultureInfo.InvariantCulture);
|
ulong.Parse(value, CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using NetCord;
|
using NetCord;
|
||||||
using NetCord.Gateway;
|
using NetCord.Gateway;
|
||||||
using NetCord.Hosting.Gateway;
|
using NetCord.Hosting.Gateway;
|
||||||
|
using NetCord.Hosting.Services;
|
||||||
using NetCord.Hosting.Services.ApplicationCommands;
|
using NetCord.Hosting.Services.ApplicationCommands;
|
||||||
using NetCord.Hosting.Services.ComponentInteractions;
|
using NetCord.Hosting.Services.ComponentInteractions;
|
||||||
|
using NetCord.Services.ApplicationCommands;
|
||||||
using NetCord.Services.ComponentInteractions;
|
using NetCord.Services.ComponentInteractions;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ discordOptions.Validate();
|
|||||||
|
|
||||||
builder.Services.AddSingleton(discordOptions);
|
builder.Services.AddSingleton(discordOptions);
|
||||||
|
|
||||||
|
builder.Logging.AddConsole();
|
||||||
|
|
||||||
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||||
{
|
{
|
||||||
var config = sp.GetRequiredService<IConfiguration>();
|
var config = sp.GetRequiredService<IConfiguration>();
|
||||||
@@ -52,8 +56,10 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
|||||||
|
|
||||||
builder.Services.AddSingleton<DiscordPermissionChecker>();
|
builder.Services.AddSingleton<DiscordPermissionChecker>();
|
||||||
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
||||||
|
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
|
||||||
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
||||||
builder.Services.AddSingleton<DiscordRescheduleHandler>();
|
builder.Services.AddSingleton<DiscordRescheduleHandler>();
|
||||||
|
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
|
||||||
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
|
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
|
||||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||||
@@ -82,12 +88,13 @@ builder.Services
|
|||||||
options.Token = discordOptions.Token;
|
options.Token = discordOptions.Token;
|
||||||
options.Intents = GatewayIntents.Guilds;
|
options.Intents = GatewayIntents.Guilds;
|
||||||
})
|
})
|
||||||
.AddApplicationCommands()
|
.AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>()
|
||||||
.AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>()
|
.AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>()
|
||||||
.AddGatewayHandlers(typeof(Program).Assembly);
|
.AddGatewayHandlers(typeof(Program).Assembly);
|
||||||
|
|
||||||
var host = builder.Build();
|
var host = builder.Build();
|
||||||
|
|
||||||
host.AddSlashCommand("ping", "Checks whether GM-Relay Discord is online.", () => "Pong!");
|
host.AddSlashCommand("ping", "Checks whether GM-Relay Discord is online.", () => "Pong!");
|
||||||
|
host.AddModules(typeof(Program).Assembly);
|
||||||
|
|
||||||
await host.RunAsync();
|
await host.RunAsync();
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
namespace GmRelay.DiscordBot.Rendering;
|
||||||
|
|
||||||
|
public static class DiscordEmbedUrls
|
||||||
|
{
|
||||||
|
public static string? NormalizeHttpUrl(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var candidate = value.Trim();
|
||||||
|
if (IsSupportedHttpUrl(candidate, out var normalized))
|
||||||
|
return normalized;
|
||||||
|
|
||||||
|
if (candidate.Contains("://", StringComparison.Ordinal))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return IsSupportedHttpUrl($"https://{candidate}", out normalized)
|
||||||
|
&& HasPublicHost(normalized)
|
||||||
|
? normalized
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSupportedHttpUrl(string value, out string normalized)
|
||||||
|
{
|
||||||
|
normalized = string.Empty;
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = uri.ToString();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasPublicHost(string value) =>
|
||||||
|
Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||||
|
&& uri.Host.Contains('.', StringComparison.Ordinal);
|
||||||
|
}
|
||||||
@@ -70,9 +70,10 @@ public static class DiscordSessionBatchRenderer
|
|||||||
.WithInline()
|
.WithInline()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(session.JoinLink))
|
var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(session.JoinLink);
|
||||||
|
if (embedUrl is not null)
|
||||||
{
|
{
|
||||||
embed = embed.WithUrl(session.JoinLink);
|
embed = embed.WithUrl(embedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
embed = embed.WithColor(GetColor(session));
|
embed = embed.WithColor(GetColor(session));
|
||||||
|
|||||||
@@ -28,6 +28,12 @@
|
|||||||
"resolved": "2.1.72",
|
"resolved": "2.1.72",
|
||||||
"contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
|
"contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
|
||||||
},
|
},
|
||||||
|
"Dapper.AOT": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[1.0.48, )",
|
||||||
|
"resolved": "1.0.48",
|
||||||
|
"contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA=="
|
||||||
|
},
|
||||||
"Microsoft.Extensions.Hosting": {
|
"Microsoft.Extensions.Hosting": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[10.0.5, )",
|
"requested": "[10.0.5, )",
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
public enum GameSystem
|
||||||
|
{
|
||||||
|
Dnd5e,
|
||||||
|
Pathfinder2e,
|
||||||
|
CallOfCthulhu7e,
|
||||||
|
Shadowdark,
|
||||||
|
OldSchoolEssentials,
|
||||||
|
Dragonbane,
|
||||||
|
BladesInTheDark,
|
||||||
|
Daggerheart,
|
||||||
|
CyberpunkRed,
|
||||||
|
Mothership,
|
||||||
|
AlienRpg,
|
||||||
|
WarhammerFantasy,
|
||||||
|
VampireMasquerade5e,
|
||||||
|
StarWarsFfg,
|
||||||
|
Genesys,
|
||||||
|
SavageWorlds,
|
||||||
|
GURPS,
|
||||||
|
Fate,
|
||||||
|
DungeonWorld,
|
||||||
|
Ironsworn,
|
||||||
|
Other
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class GameSystemExtensions
|
||||||
|
{
|
||||||
|
private static readonly FrozenDictionary<GameSystem, string> DisplayNames =
|
||||||
|
new Dictionary<GameSystem, string>
|
||||||
|
{
|
||||||
|
[GameSystem.Dnd5e] = "D&D 5e",
|
||||||
|
[GameSystem.Pathfinder2e] = "Pathfinder 2e",
|
||||||
|
[GameSystem.CallOfCthulhu7e] = "Call of Cthulhu 7e",
|
||||||
|
[GameSystem.Shadowdark] = "Shadowdark",
|
||||||
|
[GameSystem.OldSchoolEssentials] = "Old School Essentials",
|
||||||
|
[GameSystem.Dragonbane] = "Dragonbane",
|
||||||
|
[GameSystem.BladesInTheDark] = "Blades in the Dark",
|
||||||
|
[GameSystem.Daggerheart] = "Daggerheart",
|
||||||
|
[GameSystem.CyberpunkRed] = "Cyberpunk RED",
|
||||||
|
[GameSystem.Mothership] = "Mothership",
|
||||||
|
[GameSystem.AlienRpg] = "Alien RPG",
|
||||||
|
[GameSystem.WarhammerFantasy] = "Warhammer Fantasy",
|
||||||
|
[GameSystem.VampireMasquerade5e] = "Vampire: The Masquerade 5e",
|
||||||
|
[GameSystem.StarWarsFfg] = "Star Wars (FFG)",
|
||||||
|
[GameSystem.Genesys] = "Genesys",
|
||||||
|
[GameSystem.SavageWorlds] = "Savage Worlds",
|
||||||
|
[GameSystem.GURPS] = "GURPS",
|
||||||
|
[GameSystem.Fate] = "Fate",
|
||||||
|
[GameSystem.DungeonWorld] = "Dungeon World",
|
||||||
|
[GameSystem.Ironsworn] = "Ironsworn",
|
||||||
|
[GameSystem.Other] = "Другое"
|
||||||
|
}.ToFrozenDictionary();
|
||||||
|
|
||||||
|
public static string ToDisplayName(this GameSystem system) =>
|
||||||
|
DisplayNames.TryGetValue(system, out var name) ? name : "Другое";
|
||||||
|
|
||||||
|
public static GameSystem? TryParseFuzzy(string input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var normalized = input.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
if (Enum.TryParse<GameSystem>(normalized, true, out var exact))
|
||||||
|
return exact;
|
||||||
|
|
||||||
|
foreach (var value in Enum.GetValues<GameSystem>())
|
||||||
|
{
|
||||||
|
if (value == GameSystem.Other)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var display = value.ToDisplayName().ToLowerInvariant();
|
||||||
|
if (display == normalized || display.Contains(normalized) || normalized.Contains(display))
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return GameSystem.Other;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,8 +56,8 @@ public sealed class HandleRsvpHandler(
|
|||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
WHERE sp.session_id = @SessionId
|
WHERE sp.session_id = @SessionId
|
||||||
AND COALESCE(p.platform, 'Telegram') = @Platform
|
AND p.platform = @Platform
|
||||||
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @ExternalUserId
|
AND p.external_user_id = @ExternalUserId
|
||||||
AND sp.is_gm = false
|
AND sp.is_gm = false
|
||||||
AND sp.registration_status = @Active
|
AND sp.registration_status = @Active
|
||||||
)
|
)
|
||||||
@@ -90,8 +90,8 @@ public sealed class HandleRsvpHandler(
|
|||||||
AND player_id = (
|
AND player_id = (
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM players
|
FROM players
|
||||||
WHERE COALESCE(platform, 'Telegram') = @Platform
|
WHERE platform = @Platform
|
||||||
AND COALESCE(external_user_id, telegram_id::TEXT) = @ExternalUserId
|
AND external_user_id = @ExternalUserId
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
)
|
)
|
||||||
AND registration_status = @Active
|
AND registration_status = @Active
|
||||||
@@ -265,10 +265,10 @@ public sealed class HandleRsvpHandler(
|
|||||||
|
|
||||||
var participants = (await connection.QueryAsync<ParticipantRsvpRow>(
|
var participants = (await connection.QueryAsync<ParticipantRsvpRow>(
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
SELECT p.platform AS Platform,
|
||||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
p.external_user_id AS ExternalUserId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
p.external_username AS ExternalUsername,
|
||||||
sp.rsvp_status AS RsvpStatus,
|
sp.rsvp_status AS RsvpStatus,
|
||||||
sp.registration_status AS RegistrationStatus,
|
sp.registration_status AS RegistrationStatus,
|
||||||
sp.is_gm AS IsGm
|
sp.is_gm AS IsGm
|
||||||
@@ -312,23 +312,13 @@ public sealed class HandleRsvpHandler(
|
|||||||
var rows = await connection.QueryAsync<RsvpRecipientRow>(
|
var rows = await connection.QueryAsync<RsvpRecipientRow>(
|
||||||
"""
|
"""
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
COALESCE(p.platform, 'Telegram') AS Platform,
|
p.platform AS Platform,
|
||||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
p.external_user_id AS ExternalUserId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
|
p.external_username AS ExternalUsername
|
||||||
FROM group_managers gm
|
FROM group_managers gm
|
||||||
JOIN players p ON p.id = gm.player_id
|
JOIN players p ON p.id = gm.player_id
|
||||||
WHERE gm.group_id = @GroupId
|
WHERE gm.group_id = @GroupId
|
||||||
UNION
|
|
||||||
SELECT DISTINCT
|
|
||||||
COALESCE(p.platform, 'Telegram') AS Platform,
|
|
||||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
|
||||||
p.display_name AS DisplayName,
|
|
||||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
|
|
||||||
FROM game_groups g
|
|
||||||
JOIN players p ON p.telegram_id = g.gm_telegram_id
|
|
||||||
WHERE g.id = @GroupId
|
|
||||||
AND g.gm_telegram_id IS NOT NULL
|
|
||||||
""",
|
""",
|
||||||
new { GroupId = groupId },
|
new { GroupId = groupId },
|
||||||
transaction);
|
transaction);
|
||||||
|
|||||||
+6
-6
@@ -45,10 +45,10 @@ public sealed class SendConfirmationHandler(
|
|||||||
s.title,
|
s.title,
|
||||||
s.scheduled_at AS ScheduledAt,
|
s.scheduled_at AS ScheduledAt,
|
||||||
s.group_id AS GroupId,
|
s.group_id AS GroupId,
|
||||||
COALESCE(g.platform, 'Telegram') AS Platform,
|
g.platform AS Platform,
|
||||||
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
|
g.external_group_id AS ExternalGroupId,
|
||||||
g.name AS DisplayName,
|
g.name AS DisplayName,
|
||||||
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
|
g.external_channel_id AS ExternalChannelId,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
@@ -65,10 +65,10 @@ public sealed class SendConfirmationHandler(
|
|||||||
|
|
||||||
var participants = (await connection.QueryAsync<ConfirmationParticipantRow>(
|
var participants = (await connection.QueryAsync<ConfirmationParticipantRow>(
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
SELECT p.platform AS Platform,
|
||||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
p.external_user_id AS ExternalUserId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
p.external_username AS ExternalUsername,
|
||||||
sp.rsvp_status AS RsvpStatus,
|
sp.rsvp_status AS RsvpStatus,
|
||||||
sp.registration_status AS RegistrationStatus,
|
sp.registration_status AS RegistrationStatus,
|
||||||
sp.is_gm AS IsGm
|
sp.is_gm AS IsGm
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ public sealed class SendJoinLinkHandler(
|
|||||||
s.title,
|
s.title,
|
||||||
s.join_link AS JoinLink,
|
s.join_link AS JoinLink,
|
||||||
s.scheduled_at AS ScheduledAt,
|
s.scheduled_at AS ScheduledAt,
|
||||||
COALESCE(g.platform, 'Telegram') AS Platform,
|
g.platform AS Platform,
|
||||||
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
|
g.external_group_id AS ExternalGroupId,
|
||||||
g.name AS DisplayName,
|
g.name AS DisplayName,
|
||||||
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
|
g.external_channel_id AS ExternalChannelId,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
@@ -58,14 +58,14 @@ public sealed class SendJoinLinkHandler(
|
|||||||
WHERE s.id = @SessionId
|
WHERE s.id = @SessionId
|
||||||
AND s.status = @Confirmed
|
AND s.status = @Confirmed
|
||||||
AND (
|
AND (
|
||||||
(COALESCE(g.platform, 'Telegram') = 'Telegram' AND s.link_message_id IS NULL)
|
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
|
||||||
OR (
|
OR (
|
||||||
COALESCE(g.platform, 'Telegram') <> 'Telegram'
|
g.platform <> 'Telegram'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM platform_messages pm
|
FROM platform_messages pm
|
||||||
WHERE pm.session_id = s.id
|
WHERE pm.session_id = s.id
|
||||||
AND pm.platform = COALESCE(g.platform, 'Telegram')
|
AND pm.platform = g.platform
|
||||||
AND pm.purpose = 'join_link'
|
AND pm.purpose = 'join_link'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -81,10 +81,10 @@ public sealed class SendJoinLinkHandler(
|
|||||||
|
|
||||||
var players = (await connection.QueryAsync<JoinLinkPlayerRow>(
|
var players = (await connection.QueryAsync<JoinLinkPlayerRow>(
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
SELECT p.platform AS Platform,
|
||||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
p.external_user_id AS ExternalUserId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
p.external_username AS ExternalUsername,
|
||||||
sp.rsvp_status AS RsvpStatus,
|
sp.rsvp_status AS RsvpStatus,
|
||||||
sp.registration_status AS RegistrationStatus,
|
sp.registration_status AS RegistrationStatus,
|
||||||
sp.is_gm AS IsGm
|
sp.is_gm AS IsGm
|
||||||
|
|||||||
+3
-3
@@ -56,10 +56,10 @@ public sealed class SendOneHourReminderHandler(
|
|||||||
|
|
||||||
var recipients = (await connection.QueryAsync<OneHourReminderRecipientRow>(
|
var recipients = (await connection.QueryAsync<OneHourReminderRecipientRow>(
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
SELECT p.platform AS Platform,
|
||||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
p.external_user_id AS ExternalUserId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
|
p.external_username AS ExternalUsername
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
WHERE sp.session_id = @SessionId
|
WHERE sp.session_id = @SessionId
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed record CreateSessionCommand(
|
||||||
|
PlatformUser User,
|
||||||
|
PlatformGroup Group,
|
||||||
|
string Title,
|
||||||
|
string Link,
|
||||||
|
IReadOnlyList<DateTimeOffset> ScheduledTimes,
|
||||||
|
int? MaxPlayers,
|
||||||
|
string? ImageReference,
|
||||||
|
GameSystem? System = null,
|
||||||
|
string? Description = null,
|
||||||
|
string? Format = null,
|
||||||
|
int? DurationMinutes = null,
|
||||||
|
bool IsOneShot = false);
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
|
||||||
|
|
||||||
|
public sealed class CreateSessionHandler(
|
||||||
|
NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
public async Task<CreateSessionResult> HandleAsync(CreateSessionCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
var transactionCommitted = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var platform = command.User.Platform.ToString();
|
||||||
|
var externalUserId = command.User.ExternalUserId;
|
||||||
|
var displayName = command.User.DisplayName;
|
||||||
|
var externalUsername = command.User.ExternalUsername;
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO players (display_name, platform, external_user_id, external_username)
|
||||||
|
VALUES (@Name, @Platform, @ExternalId, @Username)
|
||||||
|
ON CONFLICT (platform, external_user_id)
|
||||||
|
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
||||||
|
DO UPDATE
|
||||||
|
SET display_name = EXCLUDED.display_name,
|
||||||
|
external_username = EXCLUDED.external_username;
|
||||||
|
""",
|
||||||
|
new { ExternalId = externalUserId, Name = displayName, Username = externalUsername, Platform = platform },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
||||||
|
"""
|
||||||
|
SELECT g.id AS GroupId,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
WHERE gm.group_id = g.id
|
||||||
|
AND p.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalGmId
|
||||||
|
) AS CanManage
|
||||||
|
FROM game_groups g
|
||||||
|
WHERE g.platform = @Platform
|
||||||
|
AND g.external_group_id = @ExternalGroupId
|
||||||
|
""",
|
||||||
|
new { Platform = platform, ExternalGroupId = command.Group.ExternalGroupId, ExternalGmId = externalUserId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
Guid groupId;
|
||||||
|
if (existingGroup is null)
|
||||||
|
{
|
||||||
|
groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
|
"""
|
||||||
|
INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
|
||||||
|
VALUES (@ChatName, @Platform, @ExternalGroupId, @ExternalChannelId)
|
||||||
|
RETURNING id;
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Platform = platform,
|
||||||
|
ExternalGroupId = command.Group.ExternalGroupId,
|
||||||
|
ExternalChannelId = command.Group.ExternalChannelId,
|
||||||
|
ChatName = command.Group.DisplayName
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO group_managers (group_id, player_id, role)
|
||||||
|
SELECT @GroupId, p.id, @OwnerRole
|
||||||
|
FROM players p
|
||||||
|
WHERE p.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalGmId
|
||||||
|
ON CONFLICT (group_id, player_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
new { GroupId = groupId, ExternalGmId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!existingGroup.CanManage)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
return new CreateSessionResult(
|
||||||
|
false,
|
||||||
|
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
Array.Empty<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
groupId = existingGroup.GroupId;
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE game_groups
|
||||||
|
SET name = @ChatName
|
||||||
|
WHERE id = @GroupId
|
||||||
|
""",
|
||||||
|
new { ChatName = command.Group.DisplayName, GroupId = groupId },
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
var batchId = Guid.NewGuid();
|
||||||
|
var sessions = new List<SessionBatchDto>();
|
||||||
|
var orderedTimes = command.ScheduledTimes.OrderBy(v => v).ToList();
|
||||||
|
|
||||||
|
foreach (var scheduledAt in orderedTimes)
|
||||||
|
{
|
||||||
|
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
|
"""
|
||||||
|
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url)
|
||||||
|
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl)
|
||||||
|
RETURNING id;
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
BatchId = batchId,
|
||||||
|
GroupId = groupId,
|
||||||
|
command.Title,
|
||||||
|
Link = command.Link,
|
||||||
|
ScheduledAt = scheduledAt,
|
||||||
|
Status = SessionStatus.Planned,
|
||||||
|
MaxPlayers = command.MaxPlayers,
|
||||||
|
System = command.System?.ToString(),
|
||||||
|
command.Description,
|
||||||
|
command.Format,
|
||||||
|
DurationMinutes = command.DurationMinutes,
|
||||||
|
IsOneShot = command.IsOneShot,
|
||||||
|
CoverImageUrl = command.ImageReference
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, command.MaxPlayers, command.Link));
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
transactionCommitted = true;
|
||||||
|
|
||||||
|
var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||||
|
|
||||||
|
return new CreateSessionResult(
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
view,
|
||||||
|
batchId,
|
||||||
|
groupId,
|
||||||
|
Array.Empty<string>());
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (!transactionCommitted)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed record CreateSessionResult(
|
||||||
|
bool Success,
|
||||||
|
string? ErrorMessage,
|
||||||
|
SessionBatchViewModel? View,
|
||||||
|
Guid? BatchId,
|
||||||
|
Guid? GroupId,
|
||||||
|
IReadOnlyList<string> Warnings);
|
||||||
@@ -13,7 +13,12 @@ public sealed record JoinSessionCommand(
|
|||||||
PlatformUser User,
|
PlatformUser User,
|
||||||
string InteractionId,
|
string InteractionId,
|
||||||
PlatformGroup Group,
|
PlatformGroup Group,
|
||||||
PlatformMessageRef ScheduleMessage);
|
PlatformMessageRef ScheduleMessage,
|
||||||
|
bool DeferScheduleUpdate = false);
|
||||||
|
|
||||||
|
public sealed record SessionInteractionResult(
|
||||||
|
string ReplyText,
|
||||||
|
SessionBatchViewModel? UpdatedView = null);
|
||||||
|
|
||||||
// DTOs for AOT compilation
|
// DTOs for AOT compilation
|
||||||
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
|
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
|
||||||
@@ -24,7 +29,7 @@ public sealed class JoinSessionHandler(
|
|||||||
IScheduleMessageUpdateLock scheduleUpdateLock,
|
IScheduleMessageUpdateLock scheduleUpdateLock,
|
||||||
ILogger<JoinSessionHandler> logger)
|
ILogger<JoinSessionHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
|
public async Task<SessionInteractionResult> HandleAsync(JoinSessionCommand command, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
@@ -35,30 +40,19 @@ public sealed class JoinSessionHandler(
|
|||||||
{
|
{
|
||||||
// 1. Убеждаемся, что игрок есть в базе
|
// 1. Убеждаемся, что игрок есть в базе
|
||||||
var platform = command.User.Platform.ToString();
|
var platform = command.User.Platform.ToString();
|
||||||
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
|
|
||||||
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
|
|
||||||
: (long?)null;
|
|
||||||
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
|
|
||||||
? command.User.ExternalUsername
|
|
||||||
: null;
|
|
||||||
|
|
||||||
var playerId = await connection.ExecuteScalarAsync<Guid>(
|
var playerId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
|
||||||
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
|
VALUES (@Name, @Platform, @ExternalUserId, @ExternalUsername)
|
||||||
ON CONFLICT (platform, external_user_id)
|
ON CONFLICT (platform, external_user_id)
|
||||||
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
||||||
DO UPDATE
|
DO UPDATE
|
||||||
SET display_name = EXCLUDED.display_name,
|
SET display_name = EXCLUDED.display_name,
|
||||||
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
|
|
||||||
platform = EXCLUDED.platform,
|
|
||||||
external_user_id = EXCLUDED.external_user_id,
|
|
||||||
external_username = EXCLUDED.external_username
|
external_username = EXCLUDED.external_username
|
||||||
RETURNING id;",
|
RETURNING id;",
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
LegacyTelegramId = legacyTelegramId,
|
|
||||||
Name = command.User.DisplayName,
|
Name = command.User.DisplayName,
|
||||||
LegacyTelegramUsername = legacyTelegramUsername,
|
|
||||||
Platform = platform,
|
Platform = platform,
|
||||||
command.User.ExternalUserId,
|
command.User.ExternalUserId,
|
||||||
command.User.ExternalUsername
|
command.User.ExternalUsername
|
||||||
@@ -77,15 +71,13 @@ public sealed class JoinSessionHandler(
|
|||||||
if (batchInfo is null)
|
if (batchInfo is null)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SessionStatus.IsCancelled(batchInfo.Status))
|
if (SessionStatus.IsCancelled(batchInfo.Status))
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
|
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
|
||||||
@@ -105,8 +97,7 @@ public sealed class JoinSessionHandler(
|
|||||||
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||||
? "Вы уже в листе ожидания!"
|
? "Вы уже в листе ожидания!"
|
||||||
: "Вы уже записаны!";
|
: "Вы уже записаны!";
|
||||||
await AnswerAsync(command.InteractionId, alreadyText, ct);
|
return await AnswerAsync(command.InteractionId, alreadyText, ct);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var activeParticipants = await connection.ExecuteScalarAsync<int>(
|
var activeParticipants = await connection.ExecuteScalarAsync<int>(
|
||||||
@@ -139,8 +130,7 @@ public sealed class JoinSessionHandler(
|
|||||||
if (inserted == 0)
|
if (inserted == 0)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
|
return await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем весь батч для перерисовки
|
// Загружаем весь батч для перерисовки
|
||||||
@@ -154,7 +144,7 @@ public sealed class JoinSessionHandler(
|
|||||||
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
|
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
@"SELECT sp.session_id as SessionId,
|
@"SELECT sp.session_id as SessionId,
|
||||||
p.display_name as DisplayName,
|
p.display_name as DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
|
p.external_username as TelegramUsername,
|
||||||
sp.registration_status as RegistrationStatus
|
sp.registration_status as RegistrationStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
@@ -168,17 +158,20 @@ public sealed class JoinSessionHandler(
|
|||||||
|
|
||||||
// 4. Перерисовываем сообщение
|
// 4. Перерисовываем сообщение
|
||||||
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||||
await messenger.UpdateScheduleAsync(
|
if (!command.DeferScheduleUpdate)
|
||||||
new PlatformScheduleMessage(
|
{
|
||||||
command.Group,
|
await messenger.UpdateScheduleAsync(
|
||||||
view,
|
new PlatformScheduleMessage(
|
||||||
command.ScheduleMessage),
|
command.Group,
|
||||||
ct);
|
view,
|
||||||
|
command.ScheduleMessage),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
|
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||||
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
||||||
: "Вы успешно записаны!";
|
: "Вы успешно записаны!";
|
||||||
await AnswerAsync(command.InteractionId, callbackText, ct);
|
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -191,10 +184,17 @@ public sealed class JoinSessionHandler(
|
|||||||
var errorText = transactionCommitted
|
var errorText = transactionCommitted
|
||||||
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
|
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
|
||||||
: "Произошла ошибка при регистрации.";
|
: "Произошла ошибка при регистрации.";
|
||||||
await AnswerAsync(command.InteractionId, errorText, ct);
|
return await AnswerAsync(command.InteractionId, errorText, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
|
private async Task<SessionInteractionResult> AnswerAsync(
|
||||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
string interactionId,
|
||||||
|
string text,
|
||||||
|
CancellationToken ct,
|
||||||
|
SessionBatchViewModel? updatedView = null)
|
||||||
|
{
|
||||||
|
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||||
|
return new SessionInteractionResult(text, updatedView);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ public sealed record LeaveSessionCommand(
|
|||||||
PlatformUser User,
|
PlatformUser User,
|
||||||
string InteractionId,
|
string InteractionId,
|
||||||
PlatformGroup Group,
|
PlatformGroup Group,
|
||||||
PlatformMessageRef ScheduleMessage);
|
PlatformMessageRef ScheduleMessage,
|
||||||
|
bool DeferScheduleUpdate = false);
|
||||||
|
|
||||||
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
|
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
|
||||||
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
|
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
|
||||||
@@ -24,7 +25,7 @@ public sealed class LeaveSessionHandler(
|
|||||||
IScheduleMessageUpdateLock scheduleUpdateLock,
|
IScheduleMessageUpdateLock scheduleUpdateLock,
|
||||||
ILogger<LeaveSessionHandler> logger)
|
ILogger<LeaveSessionHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
|
public async Task<SessionInteractionResult> HandleAsync(LeaveSessionCommand command, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
@@ -49,15 +50,13 @@ public sealed class LeaveSessionHandler(
|
|||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SessionStatus.IsCancelled(session.Status))
|
if (SessionStatus.IsCancelled(session.Status))
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var platform = command.User.Platform.ToString();
|
var platform = command.User.Platform.ToString();
|
||||||
@@ -81,8 +80,7 @@ public sealed class LeaveSessionHandler(
|
|||||||
if (participant is null)
|
if (participant is null)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
|
return await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
@@ -175,7 +173,7 @@ public sealed class LeaveSessionHandler(
|
|||||||
"""
|
"""
|
||||||
SELECT sp.session_id AS SessionId,
|
SELECT sp.session_id AS SessionId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
sp.registration_status AS RegistrationStatus
|
sp.registration_status AS RegistrationStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
@@ -190,12 +188,15 @@ public sealed class LeaveSessionHandler(
|
|||||||
transactionCommitted = true;
|
transactionCommitted = true;
|
||||||
|
|
||||||
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
||||||
await messenger.UpdateScheduleAsync(
|
if (!command.DeferScheduleUpdate)
|
||||||
new PlatformScheduleMessage(
|
{
|
||||||
command.Group,
|
await messenger.UpdateScheduleAsync(
|
||||||
view,
|
new PlatformScheduleMessage(
|
||||||
command.ScheduleMessage),
|
command.Group,
|
||||||
ct);
|
view,
|
||||||
|
command.ScheduleMessage),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||||
? "Вы удалены из листа ожидания."
|
? "Вы удалены из листа ожидания."
|
||||||
@@ -203,7 +204,7 @@ public sealed class LeaveSessionHandler(
|
|||||||
? "Вы отписались от сессии."
|
? "Вы отписались от сессии."
|
||||||
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
|
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
|
||||||
|
|
||||||
await AnswerAsync(command.InteractionId, callbackText, ct);
|
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -216,10 +217,17 @@ public sealed class LeaveSessionHandler(
|
|||||||
var errorText = transactionCommitted
|
var errorText = transactionCommitted
|
||||||
? "Запись снята, но не удалось обновить сообщение расписания."
|
? "Запись снята, но не удалось обновить сообщение расписания."
|
||||||
: "Произошла ошибка при отмене записи.";
|
: "Произошла ошибка при отмене записи.";
|
||||||
await AnswerAsync(command.InteractionId, errorText, ct);
|
return await AnswerAsync(command.InteractionId, errorText, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
|
private async Task<SessionInteractionResult> AnswerAsync(
|
||||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
string interactionId,
|
||||||
|
string text,
|
||||||
|
CancellationToken ct,
|
||||||
|
SessionBatchViewModel? updatedView = null)
|
||||||
|
{
|
||||||
|
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||||
|
return new SessionInteractionResult(text, updatedView);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
|
||||||
|
|
||||||
|
public sealed record ExportCalendarCommand(
|
||||||
|
PlatformGroup Group,
|
||||||
|
PlatformUser User);
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
|
||||||
|
|
||||||
|
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
||||||
|
|
||||||
|
public sealed class ExportCalendarHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
IPlatformMessenger messenger,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(ExportCalendarCommand command, 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.platform = @Platform"
|
||||||
|
+ " AND g.external_group_id = @ExternalGroupId"
|
||||||
|
+ " AND s.status = @Planned"
|
||||||
|
+ " AND s.scheduled_at > NOW()"
|
||||||
|
+ " ORDER BY s.scheduled_at ASC",
|
||||||
|
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId, Planned = SessionStatus.Planned });
|
||||||
|
|
||||||
|
var sessionsList = sessions.ToList();
|
||||||
|
|
||||||
|
if (sessionsList.Count == 0)
|
||||||
|
{
|
||||||
|
await messenger.SendGroupMessageAsync(
|
||||||
|
command.Group,
|
||||||
|
"📭 У этой группы нет запланированных сессий для экспорта.",
|
||||||
|
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}");
|
||||||
|
sb.AppendLine("END:VEVENT");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine("END:VCALENDAR");
|
||||||
|
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||||
|
|
||||||
|
// Create calendar subscription
|
||||||
|
string? subscriptionUrl = null;
|
||||||
|
var baseUrl = configuration["Web:BaseUrl"];
|
||||||
|
var senderId = command.User.ExternalUserId;
|
||||||
|
if (!string.IsNullOrWhiteSpace(baseUrl) && !string.IsNullOrWhiteSpace(senderId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = Guid.NewGuid().ToString("N");
|
||||||
|
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
|
||||||
|
@"SELECT id FROM game_groups WHERE platform = @Platform AND external_group_id = @ExternalGroupId",
|
||||||
|
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId });
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
@"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at)
|
||||||
|
VALUES (gen_random_uuid(), @token, @userPlatform, @userExternalId, @groupId, @filterType, now(), NULL)",
|
||||||
|
new { token, userPlatform = command.Group.Platform.ToString(), userExternalId = senderId, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
|
||||||
|
|
||||||
|
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Non-critical: if subscription creation fails, still send the file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var actions = subscriptionUrl is not null
|
||||||
|
? new[]
|
||||||
|
{
|
||||||
|
new PlatformMessageAction(
|
||||||
|
"calendar-subscription",
|
||||||
|
"🔗 Подписаться на календарь",
|
||||||
|
subscriptionUrl)
|
||||||
|
}
|
||||||
|
: Array.Empty<PlatformMessageAction>();
|
||||||
|
|
||||||
|
await messenger.SendCalendarFileAsync(
|
||||||
|
new PlatformCalendarFile(
|
||||||
|
command.Group,
|
||||||
|
"schedule.ics",
|
||||||
|
bytes,
|
||||||
|
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
||||||
|
actions),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
|
internal sealed record DeleteSessionInfoDto(
|
||||||
|
string Title,
|
||||||
|
Guid BatchId,
|
||||||
|
Guid GroupId,
|
||||||
|
bool CanManage,
|
||||||
|
int? ThreadId,
|
||||||
|
bool TopicCreatedByBot);
|
||||||
|
|
||||||
|
public sealed record DeleteSessionResult(
|
||||||
|
bool Success,
|
||||||
|
string? ReplyText,
|
||||||
|
string? Title,
|
||||||
|
Guid? GroupId,
|
||||||
|
int? ThreadId,
|
||||||
|
bool TopicCreatedByBot,
|
||||||
|
int RemainingInTopic);
|
||||||
|
|
||||||
|
public sealed class DeleteSessionHandler(
|
||||||
|
NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
public async Task<DeleteSessionResult> 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 group manager.
|
||||||
|
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
|
||||||
|
"""
|
||||||
|
SELECT s.title AS Title,
|
||||||
|
s.batch_id AS BatchId,
|
||||||
|
s.group_id AS GroupId,
|
||||||
|
s.thread_id AS ThreadId,
|
||||||
|
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
WHERE gm.group_id = s.group_id
|
||||||
|
AND p.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalUserId
|
||||||
|
) AS CanManage
|
||||||
|
FROM sessions s
|
||||||
|
WHERE s.id = @SessionId
|
||||||
|
""",
|
||||||
|
new { command.SessionId, Platform = command.User.Platform.ToString(), ExternalUserId = command.User.ExternalUserId }, transaction);
|
||||||
|
|
||||||
|
if (session == null)
|
||||||
|
{
|
||||||
|
return new DeleteSessionResult(false, "Сессия не найдена.", null, null, null, false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.CanManage)
|
||||||
|
{
|
||||||
|
return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete session
|
||||||
|
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
||||||
|
|
||||||
|
var remainingInTopic = session.ThreadId.HasValue
|
||||||
|
? await connection.ExecuteScalarAsync<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM sessions
|
||||||
|
WHERE group_id = @GroupId
|
||||||
|
AND thread_id = @ThreadId
|
||||||
|
""",
|
||||||
|
new { session.GroupId, ThreadId = session.ThreadId.Value },
|
||||||
|
transaction)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
return new DeleteSessionResult(
|
||||||
|
true,
|
||||||
|
"Сессия удалена!",
|
||||||
|
session.Title,
|
||||||
|
session.GroupId,
|
||||||
|
session.ThreadId,
|
||||||
|
session.TopicCreatedByBot,
|
||||||
|
remainingInTopic);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
|
public sealed record ListSessionsCommand(
|
||||||
|
PlatformGroup Group,
|
||||||
|
PlatformUser User);
|
||||||
|
|
||||||
|
public sealed record DeleteSessionCommand(
|
||||||
|
Guid SessionId,
|
||||||
|
PlatformUser User,
|
||||||
|
PlatformGroup Group,
|
||||||
|
PlatformMessageRef ScheduleMessage);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
|
public sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
|
||||||
|
|
||||||
|
public sealed record SessionListResult(
|
||||||
|
IReadOnlyList<SessionListItemDto> Sessions,
|
||||||
|
bool CanManage);
|
||||||
|
|
||||||
|
public sealed class ListSessionsHandler(
|
||||||
|
NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
public async Task<SessionListResult> HandleAsync(ListSessionsCommand command, 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, s.max_players as MaxPlayers,
|
||||||
|
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
||||||
|
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||||
|
WHERE gm.group_id = s.group_id
|
||||||
|
AND manager_player.platform = @Platform
|
||||||
|
AND manager_player.external_user_id = @ExternalUserId
|
||||||
|
) AS CanManage
|
||||||
|
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.platform = @Platform
|
||||||
|
AND g.external_group_id = @ExternalGroupId
|
||||||
|
AND s.status != @Cancelled
|
||||||
|
AND s.scheduled_at > NOW()
|
||||||
|
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
|
||||||
|
ORDER BY s.scheduled_at ASC",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Platform = command.Group.Platform.ToString(),
|
||||||
|
ExternalGroupId = command.Group.ExternalGroupId,
|
||||||
|
ExternalUserId = command.User.ExternalUserId,
|
||||||
|
Cancelled = SessionStatus.Cancelled,
|
||||||
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
|
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||||
|
});
|
||||||
|
|
||||||
|
var sessionsList = sessions.ToList();
|
||||||
|
var canManage = sessionsList.Count > 0 && sessionsList.First().CanManage;
|
||||||
|
|
||||||
|
return new SessionListResult(sessionsList, canManage);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
internal sealed record AwaitingProposalDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid SessionId,
|
||||||
|
string Title,
|
||||||
|
DateTime CurrentScheduledAt,
|
||||||
|
Guid BatchId,
|
||||||
|
int? BatchMessageId,
|
||||||
|
string ExternalGroupId,
|
||||||
|
int? ThreadId,
|
||||||
|
string NotificationMode);
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
public sealed record HandleRescheduleTimeInputCommand(
|
||||||
|
PlatformUser User,
|
||||||
|
PlatformGroup Group,
|
||||||
|
string Text);
|
||||||
|
|
||||||
|
public sealed record HandleRescheduleVoteCommand(
|
||||||
|
Guid OptionId,
|
||||||
|
PlatformUser User,
|
||||||
|
PlatformGroup Group,
|
||||||
|
string InteractionId,
|
||||||
|
PlatformMessageRef ScheduleMessage);
|
||||||
+181
@@ -0,0 +1,181 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
public sealed class HandleRescheduleTimeInputHandler(
|
||||||
|
NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
public async Task<HandleRescheduleTimeInputResult> HandleAsync(
|
||||||
|
HandleRescheduleTimeInputCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
|
var platform = command.User.Platform.ToString();
|
||||||
|
var externalGmId = command.User.ExternalUserId;
|
||||||
|
var externalGroupId = command.Group.ExternalGroupId;
|
||||||
|
|
||||||
|
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.external_group_id AS ExternalGroupId,
|
||||||
|
s.thread_id AS ThreadId,
|
||||||
|
s.notification_mode AS NotificationMode
|
||||||
|
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_external_user_id = @ExternalGmId
|
||||||
|
AND rp.status = 'AwaitingTime'
|
||||||
|
AND g.platform = @Platform
|
||||||
|
AND g.external_group_id = @ExternalGroupId
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||||
|
WHERE gm.group_id = s.group_id
|
||||||
|
AND manager_player.platform = @Platform
|
||||||
|
AND manager_player.external_user_id = @ExternalGmId
|
||||||
|
)
|
||||||
|
ORDER BY rp.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
new { ExternalGmId = externalGmId, Platform = platform, ExternalGroupId = externalGroupId });
|
||||||
|
|
||||||
|
if (proposal is null)
|
||||||
|
return new HandleRescheduleTimeInputResult(false, false, null, null, null, null, [], [], [], null, default, null);
|
||||||
|
|
||||||
|
if (!RescheduleVotingInput.TryParse(command.Text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
|
||||||
|
{
|
||||||
|
return new HandleRescheduleTimeInputResult(
|
||||||
|
true, false, parseError, null, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
||||||
|
"""
|
||||||
|
SELECT p.id AS PlayerId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.external_username AS TelegramUsername,
|
||||||
|
p.external_user_id::BIGINT AS TelegramId
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
|
""",
|
||||||
|
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||||||
|
|
||||||
|
if (participants.Count == 0)
|
||||||
|
{
|
||||||
|
var newTime = votingInput.Options[0];
|
||||||
|
var view = await RescheduleImmediatelyAsync(connection, proposal, newTime, ct);
|
||||||
|
var replyText =
|
||||||
|
$"""✅ Сессия «{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>""";
|
||||||
|
return new HandleRescheduleTimeInputResult(
|
||||||
|
true, true, replyText, view, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, proposal.BatchMessageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
var options = votingInput.Options
|
||||||
|
.Select((proposedAt, index) => new RescheduleOptionDto(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
index + 1,
|
||||||
|
proposedAt))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE reschedule_proposals
|
||||||
|
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @VoteChatId
|
||||||
|
WHERE id = @Id
|
||||||
|
""",
|
||||||
|
new { votingInput.Deadline, VoteChatId = externalGroupId, Id = proposal.Id },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
foreach (var option in options)
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
|
||||||
|
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
option.OptionId,
|
||||||
|
ProposalId = proposal.Id,
|
||||||
|
option.ProposedAt,
|
||||||
|
option.DisplayOrder
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
return new HandleRescheduleTimeInputResult(
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
proposal.Id,
|
||||||
|
votingInput.Deadline,
|
||||||
|
options,
|
||||||
|
participants,
|
||||||
|
[],
|
||||||
|
proposal.Title,
|
||||||
|
proposal.CurrentScheduledAt,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<SessionBatchViewModel?> RescheduleImmediatelyAsync(
|
||||||
|
NpgsqlConnection connection,
|
||||||
|
AwaitingProposalDto proposal,
|
||||||
|
DateTimeOffset newTime,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET scheduled_at = @NewTime,
|
||||||
|
status = @Status,
|
||||||
|
confirmation_message_id = NULL,
|
||||||
|
confirmation_sent_at = NULL,
|
||||||
|
one_hour_reminder_processed_at = NULL,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = @SessionId
|
||||||
|
""",
|
||||||
|
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
|
||||||
|
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);
|
||||||
|
|
||||||
|
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||||
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink 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.external_username AS TelegramUsername,
|
||||||
|
sp.registration_status AS RegistrationStatus
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON sp.player_id = p.id
|
||||||
|
JOIN sessions s ON sp.session_id = s.id
|
||||||
|
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||||
|
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
|
||||||
|
""",
|
||||||
|
new { proposal.BatchId })).ToList();
|
||||||
|
|
||||||
|
return SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
public sealed record HandleRescheduleTimeInputResult(
|
||||||
|
bool Handled,
|
||||||
|
bool IsRescheduledImmediately,
|
||||||
|
string? ReplyText,
|
||||||
|
SessionBatchViewModel? UpdatedView,
|
||||||
|
Guid? ProposalId,
|
||||||
|
DateTimeOffset? VotingDeadlineAt,
|
||||||
|
IReadOnlyList<RescheduleOptionDto> Options,
|
||||||
|
IReadOnlyList<VoteParticipantDto> Participants,
|
||||||
|
IReadOnlyList<RescheduleOptionVoteDto> Votes,
|
||||||
|
string? Title,
|
||||||
|
DateTime CurrentScheduledAt,
|
||||||
|
int? BatchMessageId);
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
public sealed class HandleRescheduleVoteHandler(
|
||||||
|
NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
public async Task<HandleRescheduleVoteResult> HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
|
||||||
|
"""
|
||||||
|
SELECT rp.id AS Id,
|
||||||
|
rp.session_id AS SessionId,
|
||||||
|
rp.voting_deadline_at AS VotingDeadlineAt,
|
||||||
|
s.title AS Title,
|
||||||
|
s.scheduled_at AS CurrentScheduledAt
|
||||||
|
FROM reschedule_options ro
|
||||||
|
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
|
||||||
|
JOIN sessions s ON s.id = rp.session_id
|
||||||
|
WHERE ro.id = @OptionId AND rp.status = 'Voting'
|
||||||
|
""",
|
||||||
|
new { command.OptionId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (proposal is null)
|
||||||
|
{
|
||||||
|
return new HandleRescheduleVoteResult(
|
||||||
|
false,
|
||||||
|
"Голосование уже завершено или не найдено.",
|
||||||
|
null, null, null, default, default,
|
||||||
|
Array.Empty<VoteParticipantDto>(),
|
||||||
|
Array.Empty<RescheduleOptionDto>(),
|
||||||
|
Array.Empty<RescheduleOptionVoteDto>());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
return new HandleRescheduleVoteResult(
|
||||||
|
false,
|
||||||
|
"Дедлайн уже прошёл. Результаты скоро будут применены.",
|
||||||
|
null, null, null, default, default,
|
||||||
|
Array.Empty<VoteParticipantDto>(),
|
||||||
|
Array.Empty<RescheduleOptionDto>(),
|
||||||
|
Array.Empty<RescheduleOptionVoteDto>());
|
||||||
|
}
|
||||||
|
|
||||||
|
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.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalUserId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
proposal.SessionId,
|
||||||
|
Platform = command.User.Platform.ToString(),
|
||||||
|
ExternalUserId = command.User.ExternalUserId,
|
||||||
|
Active = ParticipantRegistrationStatus.Active
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (playerId is null)
|
||||||
|
{
|
||||||
|
return new HandleRescheduleVoteResult(
|
||||||
|
false,
|
||||||
|
"Вы не являетесь участником этой сессии.",
|
||||||
|
null, null, null, default, default,
|
||||||
|
Array.Empty<VoteParticipantDto>(),
|
||||||
|
Array.Empty<RescheduleOptionDto>(),
|
||||||
|
Array.Empty<RescheduleOptionVoteDto>());
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
|
||||||
|
VALUES (@ProposalId, @PlayerId, @OptionId)
|
||||||
|
ON CONFLICT (proposal_id, player_id) DO UPDATE
|
||||||
|
SET option_id = EXCLUDED.option_id,
|
||||||
|
voted_at = now()
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ProposalId = proposal.Id,
|
||||||
|
PlayerId = playerId.Value,
|
||||||
|
command.OptionId
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
||||||
|
"""
|
||||||
|
SELECT p.id AS PlayerId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.external_username AS TelegramUsername,
|
||||||
|
p.external_user_id AS TelegramId
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
|
ORDER BY p.display_name
|
||||||
|
""",
|
||||||
|
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
|
var options = (await connection.QueryAsync<RescheduleOptionDto>(
|
||||||
|
"""
|
||||||
|
SELECT id AS OptionId,
|
||||||
|
display_order AS DisplayOrder,
|
||||||
|
proposed_at AS ProposedAt
|
||||||
|
FROM reschedule_options
|
||||||
|
WHERE proposal_id = @ProposalId
|
||||||
|
ORDER BY display_order
|
||||||
|
""",
|
||||||
|
new { ProposalId = proposal.Id },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
|
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
|
||||||
|
"""
|
||||||
|
SELECT rov.option_id AS OptionId,
|
||||||
|
p.id AS PlayerId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.external_username AS TelegramUsername
|
||||||
|
FROM reschedule_option_votes rov
|
||||||
|
JOIN players p ON p.id = rov.player_id
|
||||||
|
WHERE rov.proposal_id = @ProposalId
|
||||||
|
ORDER BY rov.voted_at, p.display_name
|
||||||
|
""",
|
||||||
|
new { ProposalId = proposal.Id },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
return new HandleRescheduleVoteResult(
|
||||||
|
true,
|
||||||
|
"Ваш голос учтён. До дедлайна его можно изменить.",
|
||||||
|
proposal.Id,
|
||||||
|
proposal.SessionId,
|
||||||
|
proposal.Title,
|
||||||
|
proposal.CurrentScheduledAt,
|
||||||
|
proposal.VotingDeadlineAt,
|
||||||
|
participants,
|
||||||
|
options,
|
||||||
|
votes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
public sealed record HandleRescheduleVoteResult(
|
||||||
|
bool Success,
|
||||||
|
string? ReplyText,
|
||||||
|
Guid? ProposalId,
|
||||||
|
Guid? SessionId,
|
||||||
|
string? Title,
|
||||||
|
DateTime CurrentScheduledAt,
|
||||||
|
DateTimeOffset VotingDeadlineAt,
|
||||||
|
IReadOnlyList<VoteParticipantDto> Participants,
|
||||||
|
IReadOnlyList<RescheduleOptionDto> Options,
|
||||||
|
IReadOnlyList<RescheduleOptionVoteDto> Votes);
|
||||||
@@ -78,8 +78,8 @@ public sealed class RescheduleVotingFinalizer(
|
|||||||
"""
|
"""
|
||||||
SELECT p.id AS PlayerId,
|
SELECT p.id AS PlayerId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
p.telegram_id AS TelegramId
|
p.external_user_id::BIGINT AS TelegramId
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
WHERE sp.session_id = @SessionId
|
WHERE sp.session_id = @SessionId
|
||||||
@@ -107,7 +107,7 @@ public sealed class RescheduleVotingFinalizer(
|
|||||||
SELECT rov.option_id AS OptionId,
|
SELECT rov.option_id AS OptionId,
|
||||||
p.id AS PlayerId,
|
p.id AS PlayerId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername
|
p.external_username AS TelegramUsername
|
||||||
FROM reschedule_option_votes rov
|
FROM reschedule_option_votes rov
|
||||||
JOIN players p ON p.id = rov.player_id
|
JOIN players p ON p.id = rov.player_id
|
||||||
WHERE rov.proposal_id = @ProposalId
|
WHERE rov.proposal_id = @ProposalId
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace GmRelay.Shared.Features.Showcase;
|
||||||
|
|
||||||
|
public sealed record ShowcaseFilter(
|
||||||
|
DateFilter Date = DateFilter.All,
|
||||||
|
SeatFilter Seats = SeatFilter.Any,
|
||||||
|
string? System = null,
|
||||||
|
bool? IsOneShot = null,
|
||||||
|
string? Format = null);
|
||||||
|
|
||||||
|
public enum DateFilter
|
||||||
|
{
|
||||||
|
Today,
|
||||||
|
Tomorrow,
|
||||||
|
ThisWeek,
|
||||||
|
All
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SeatFilter
|
||||||
|
{
|
||||||
|
Available,
|
||||||
|
Waitlist,
|
||||||
|
Any
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace GmRelay.Shared.Features.Showcase;
|
||||||
|
|
||||||
|
public sealed record ShowcaseSessionDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid GroupId,
|
||||||
|
string GroupName,
|
||||||
|
string? GroupSlug,
|
||||||
|
string Title,
|
||||||
|
DateTime ScheduledAt,
|
||||||
|
string Status,
|
||||||
|
string? System,
|
||||||
|
bool IsOneShot,
|
||||||
|
string? Format,
|
||||||
|
int? DurationMinutes,
|
||||||
|
string? CoverImageUrl,
|
||||||
|
int? MaxPlayers,
|
||||||
|
int ActivePlayerCount,
|
||||||
|
int WaitlistedPlayerCount,
|
||||||
|
bool AllowDirectRegistration,
|
||||||
|
string? Description,
|
||||||
|
string? MasterProfileSlug = null,
|
||||||
|
string? MasterDisplayName = null);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
|
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
|
||||||
using GmRelay.Shared.Features.Reminders.SendJoinLink;
|
using GmRelay.Shared.Features.Reminders.SendJoinLink;
|
||||||
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
||||||
@@ -20,6 +21,11 @@ public sealed class SessionSchedulerService(
|
|||||||
ILogger<SessionSchedulerService> logger) : BackgroundService
|
ILogger<SessionSchedulerService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
||||||
|
private static readonly TimeSpan BackoffDuration = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _confirmationBackoff = new();
|
||||||
|
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _oneHourBackoff = new();
|
||||||
|
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _joinLinkBackoff = new();
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
@@ -71,14 +77,30 @@ public sealed class SessionSchedulerService(
|
|||||||
|
|
||||||
foreach (var sessionId in sessionIds)
|
foreach (var sessionId in sessionIds)
|
||||||
{
|
{
|
||||||
|
if (_confirmationBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
|
||||||
|
{
|
||||||
|
logger.LogDebug(
|
||||||
|
"Skipping confirmation for session {SessionId} until {Backoff}",
|
||||||
|
sessionId,
|
||||||
|
backoffUntil);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await confirmationHandler.HandleAsync(sessionId, ct);
|
await confirmationHandler.HandleAsync(sessionId, ct);
|
||||||
|
_confirmationBackoff.TryRemove(sessionId, out _);
|
||||||
logger.LogInformation("Confirmation sent for session {SessionId}", sessionId);
|
logger.LogInformation("Confirmation sent for session {SessionId}", sessionId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to send confirmation for session {SessionId}", sessionId);
|
var nextAttempt = now.Add(BackoffDuration);
|
||||||
|
_confirmationBackoff[sessionId] = nextAttempt;
|
||||||
|
logger.LogError(
|
||||||
|
ex,
|
||||||
|
"Failed to send confirmation for session {SessionId}, backing off until {Backoff}",
|
||||||
|
sessionId,
|
||||||
|
nextAttempt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,14 +120,30 @@ public sealed class SessionSchedulerService(
|
|||||||
|
|
||||||
foreach (var sessionId in sessionIds)
|
foreach (var sessionId in sessionIds)
|
||||||
{
|
{
|
||||||
|
if (_oneHourBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
|
||||||
|
{
|
||||||
|
logger.LogDebug(
|
||||||
|
"Skipping one-hour reminder for session {SessionId} until {Backoff}",
|
||||||
|
sessionId,
|
||||||
|
backoffUntil);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await oneHourReminderHandler.HandleAsync(sessionId, ct);
|
await oneHourReminderHandler.HandleAsync(sessionId, ct);
|
||||||
|
_oneHourBackoff.TryRemove(sessionId, out _);
|
||||||
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
|
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId);
|
var nextAttempt = now.Add(BackoffDuration);
|
||||||
|
_oneHourBackoff[sessionId] = nextAttempt;
|
||||||
|
logger.LogError(
|
||||||
|
ex,
|
||||||
|
"Failed to process one-hour reminder for session {SessionId}, backing off until {Backoff}",
|
||||||
|
sessionId,
|
||||||
|
nextAttempt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,14 +163,30 @@ public sealed class SessionSchedulerService(
|
|||||||
|
|
||||||
foreach (var sessionId in sessionIds)
|
foreach (var sessionId in sessionIds)
|
||||||
{
|
{
|
||||||
|
if (_joinLinkBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
|
||||||
|
{
|
||||||
|
logger.LogDebug(
|
||||||
|
"Skipping join link for session {SessionId} until {Backoff}",
|
||||||
|
sessionId,
|
||||||
|
backoffUntil);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await joinLinkHandler.HandleAsync(sessionId, ct);
|
await joinLinkHandler.HandleAsync(sessionId, ct);
|
||||||
|
_joinLinkBackoff.TryRemove(sessionId, out _);
|
||||||
logger.LogInformation("Join link sent for session {SessionId}", sessionId);
|
logger.LogInformation("Join link sent for session {SessionId}", sessionId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to send join link for session {SessionId}", sessionId);
|
var nextAttempt = now.Add(BackoffDuration);
|
||||||
|
_joinLinkBackoff[sessionId] = nextAttempt;
|
||||||
|
logger.LogError(
|
||||||
|
ex,
|
||||||
|
"Failed to send join link for session {SessionId}, backing off until {Backoff}",
|
||||||
|
sessionId,
|
||||||
|
nextAttempt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,23 @@ namespace GmRelay.Shared.Platform;
|
|||||||
|
|
||||||
public interface IPlatformMessenger
|
public interface IPlatformMessenger
|
||||||
{
|
{
|
||||||
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
|
Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct) =>
|
||||||
|
throw new NotSupportedException("This platform messenger does not support messages with actions.");
|
||||||
|
|
||||||
|
Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct) =>
|
||||||
|
throw new NotSupportedException("This platform messenger does not support message updates with actions.");
|
||||||
|
|
||||||
|
Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct) =>
|
||||||
|
throw new NotSupportedException("This platform messenger does not support thread creation.");
|
||||||
|
|
||||||
|
Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) =>
|
||||||
|
throw new NotSupportedException("This platform messenger does not support thread deletion.");
|
||||||
|
|
||||||
|
Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct) =>
|
||||||
|
throw new NotSupportedException("This platform messenger does not support message deletion.");
|
||||||
|
|
||||||
|
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) =>
|
||||||
|
throw new NotSupportedException("This platform messenger does not support schedule messages.");
|
||||||
|
|
||||||
Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
|
Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Шаблоны
|
Шаблоны
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink class="nav-item" href="profile" @onclick="CloseMenu">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="12" cy="7" r="4"/>
|
||||||
|
</svg>
|
||||||
|
Профиль
|
||||||
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-footer">
|
<div class="nav-footer">
|
||||||
@@ -66,7 +73,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v2.8.0</div>
|
<div class="nav-version">v3.5.0</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
<div class="public-shell">
|
||||||
|
<header class="public-topbar">
|
||||||
|
<a class="public-brand" href="/">
|
||||||
|
<img src="/logo.png" alt="GM-Relay" />
|
||||||
|
<span>GM-Relay</span>
|
||||||
|
</a>
|
||||||
|
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="public-content">
|
||||||
|
@Body
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="blazor-error-ui" data-nosnippet>
|
||||||
|
Произошла непредвиденная ошибка.
|
||||||
|
<a href="." class="reload">Перезагрузить</a>
|
||||||
|
<span class="dismiss">×</span>
|
||||||
|
</div>
|
||||||
@@ -40,8 +40,8 @@
|
|||||||
</span>
|
</span>
|
||||||
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
|
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
|
||||||
{
|
{
|
||||||
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.ExternalUserId)" @onclick="() => RemoveCoGm(manager.ExternalUserId ?? manager.TelegramId.ToString())">
|
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == ManagerKey(manager))" @onclick="() => RemoveCoGm(manager)">
|
||||||
@(removingCoGmId == manager.ExternalUserId ? "⏳ Удаляем..." : "Убрать")
|
@(removingCoGmId == ManagerKey(manager) ? "⏳ Удаляем..." : "Убрать")
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,8 +52,8 @@
|
|||||||
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
|
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
|
||||||
<div class="batch-bulk-fields">
|
<div class="batch-bulk-fields">
|
||||||
<div class="gm-form-group">
|
<div class="gm-form-group">
|
||||||
<label class="gm-form-label">Telegram ID co-GM</label>
|
<label class="gm-form-label">@CoGmIdLabel</label>
|
||||||
<InputNumber @bind-Value="coGmModel.TelegramId" class="gm-form-control" min="1" />
|
<InputText @bind-Value="coGmModel.ExternalUserId" class="gm-form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="gm-form-group">
|
<div class="gm-form-group">
|
||||||
<label class="gm-form-label">Имя</label>
|
<label class="gm-form-label">Имя</label>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="gm-form-group">
|
<div class="gm-form-group">
|
||||||
<label class="gm-form-label">Username</label>
|
<label class="gm-form-label">Username</label>
|
||||||
<InputText @bind-Value="coGmModel.TelegramUsername" class="gm-form-control" />
|
<InputText @bind-Value="coGmModel.ExternalUsername" class="gm-form-control" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
|
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
|
||||||
@@ -72,6 +72,58 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (publicSettings is not null)
|
||||||
|
{
|
||||||
|
<div class="glass-card animate-slide-up public-settings-panel" style="margin-bottom: 1rem;">
|
||||||
|
<div class="batch-bulk-header">
|
||||||
|
<div>
|
||||||
|
<h3>Публичная страница клуба</h3>
|
||||||
|
<p>@publicSettings.PublicSessionCount опубликованных игр без состава игроков и приватных ссылок</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge @(publicSettings.PublicScheduleEnabled ? "status-success" : "status-neutral")">
|
||||||
|
@(publicSettings.PublicScheduleEnabled ? "Включена" : "Выключена")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="@publicSettingsModel" OnValidSubmit="SavePublicSettings">
|
||||||
|
<div class="batch-bulk-fields">
|
||||||
|
<div class="gm-form-group public-toggle-field">
|
||||||
|
<label class="gm-checkbox-label">
|
||||||
|
<InputCheckbox @bind-Value="publicSettingsModel.PublicScheduleEnabled" />
|
||||||
|
<span>Включить публичное расписание</span>
|
||||||
|
</label>
|
||||||
|
<div class="gm-form-hint">Если выключено, публичная страница и ссылки на сессии недоступны.</div>
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Короткий адрес</label>
|
||||||
|
<InputText @bind-Value="publicSettingsModel.PublicSlug" class="gm-form-control" />
|
||||||
|
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-club`.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="public-settings-actions">
|
||||||
|
<button type="submit" class="btn-gm btn-gm-primary" disabled="@savingPublicSettings">
|
||||||
|
@(savingPublicSettings ? "Сохраняем..." : "Сохранить публикацию")
|
||||||
|
</button>
|
||||||
|
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
|
||||||
|
{
|
||||||
|
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">
|
||||||
|
Открыть публичную страницу
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
|
||||||
|
{
|
||||||
|
<div class="public-link-row">
|
||||||
|
<span>Ссылка клуба</span>
|
||||||
|
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(errorMessage))
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
{
|
{
|
||||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||||
@@ -201,6 +253,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</EditForm>
|
</EditForm>
|
||||||
|
|
||||||
|
<div class="batch-publish-row">
|
||||||
|
<span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
|
||||||
|
@FormatBatchPublication(batch)
|
||||||
|
</span>
|
||||||
|
<button type="button" class="btn-gm btn-gm-outline" disabled="@IsBatchPublishBusy(batch)" @onclick="() => SetBatchPublic(batch, !batch.AllSessionsPublic)">
|
||||||
|
@(IsBatchPublishBusy(batch)
|
||||||
|
? "Обновляем..."
|
||||||
|
: batch.AllSessionsPublic ? "Скрыть batch" : "Опубликовать batch")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="batch-clone-row">
|
<div class="batch-clone-row">
|
||||||
<select @bind="batch.CloneInterval" class="gm-form-control">
|
<select @bind="batch.CloneInterval" class="gm-form-control">
|
||||||
<option value="week">Следующая неделя</option>
|
<option value="week">Следующая неделя</option>
|
||||||
@@ -249,6 +312,16 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="session-table-actions">
|
<div class="session-table-actions">
|
||||||
|
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span>
|
||||||
|
<button type="button" class="btn-gm btn-gm-outline" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
|
||||||
|
@(publishingSessionId == session.Id
|
||||||
|
? "Обновляем..."
|
||||||
|
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
||||||
|
</button>
|
||||||
|
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
||||||
|
{
|
||||||
|
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a>
|
||||||
|
}
|
||||||
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline">
|
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline">
|
||||||
✏️ Изменить
|
✏️ Изменить
|
||||||
</a>
|
</a>
|
||||||
@@ -337,6 +410,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-card-actions">
|
<div class="session-card-actions">
|
||||||
|
<button type="button" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
|
||||||
|
@(publishingSessionId == session.Id
|
||||||
|
? "Обновляем..."
|
||||||
|
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
||||||
|
</button>
|
||||||
|
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
||||||
|
{
|
||||||
|
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">Публичная ссылка</a>
|
||||||
|
}
|
||||||
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
|
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
|
||||||
✏️ Изменить
|
✏️ Изменить
|
||||||
</a>
|
</a>
|
||||||
@@ -398,17 +480,23 @@
|
|||||||
private List<WebSession>? sessions;
|
private List<WebSession>? sessions;
|
||||||
private List<WebCampaignTemplate>? campaignTemplates;
|
private List<WebCampaignTemplate>? campaignTemplates;
|
||||||
private WebGroupManagement? groupManagement;
|
private WebGroupManagement? groupManagement;
|
||||||
|
private WebPublicGroupSettings? publicSettings;
|
||||||
private List<BatchBulkEditModel> batchModels = [];
|
private List<BatchBulkEditModel> batchModels = [];
|
||||||
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
||||||
private Guid? promotingSessionId;
|
private Guid? promotingSessionId;
|
||||||
private Guid? processingBatchId;
|
private Guid? processingBatchId;
|
||||||
private Guid? processingTemplateId;
|
private Guid? processingTemplateId;
|
||||||
|
private Guid? publishingBatchId;
|
||||||
|
private Guid? publishingSessionId;
|
||||||
private string? removingCoGmId;
|
private string? removingCoGmId;
|
||||||
private bool isAddingCoGm;
|
private bool isAddingCoGm;
|
||||||
|
private bool savingPublicSettings;
|
||||||
|
private string? currentPlatform;
|
||||||
private string? externalUserId;
|
private string? externalUserId;
|
||||||
private string? errorMessage;
|
private string? errorMessage;
|
||||||
private string? successMessage;
|
private string? successMessage;
|
||||||
private CoGmEditModel coGmModel = new();
|
private CoGmEditModel coGmModel = new();
|
||||||
|
private PublicSettingsEditModel publicSettingsModel = new();
|
||||||
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
|
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
|
||||||
private HashSet<Guid> expandedSessions = new();
|
private HashSet<Guid> expandedSessions = new();
|
||||||
private Guid? kickingParticipantId;
|
private Guid? kickingParticipantId;
|
||||||
@@ -423,6 +511,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentPlatform = platform;
|
||||||
await LoadSessions();
|
await LoadSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +531,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
publicSettings = await SessionService.GetPublicGroupSettingsForCurrentUserAsync(GroupId);
|
||||||
|
if (publicSettings is null)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
|
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
|
||||||
if (campaignTemplates is null)
|
if (campaignTemplates is null)
|
||||||
{
|
{
|
||||||
@@ -451,6 +547,92 @@
|
|||||||
|
|
||||||
RebuildBatchModels();
|
RebuildBatchModels();
|
||||||
RebuildCampaignTemplateModels();
|
RebuildCampaignTemplateModels();
|
||||||
|
RebuildPublicSettingsModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SavePublicSettings()
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
savingPublicSettings = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SessionService.UpdatePublicGroupSettingsForCurrentUserAsync(
|
||||||
|
GroupId,
|
||||||
|
publicSettingsModel.PublicSlug,
|
||||||
|
publicSettingsModel.PublicScheduleEnabled);
|
||||||
|
successMessage = "Настройки публичной страницы обновлены.";
|
||||||
|
await LoadSessions();
|
||||||
|
}
|
||||||
|
catch (SessionAccessDeniedException)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось обновить публичную страницу: " + ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
savingPublicSettings = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetBatchPublic(BatchBulkEditModel batch, bool isPublic)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
publishingBatchId = batch.BatchId;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SessionService.SetBatchPublicForCurrentUserAsync(batch.BatchId, isPublic);
|
||||||
|
successMessage = isPublic
|
||||||
|
? "Batch опубликован в публичном расписании."
|
||||||
|
: "Batch скрыт из публичного расписания.";
|
||||||
|
await LoadSessions();
|
||||||
|
}
|
||||||
|
catch (SessionAccessDeniedException)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось обновить публичность batch: " + ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
publishingBatchId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetSessionPublic(Guid sessionId, bool isPublic)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
publishingSessionId = sessionId;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SessionService.SetSessionPublicForCurrentUserAsync(sessionId, isPublic);
|
||||||
|
successMessage = isPublic
|
||||||
|
? "Сессия опубликована в публичном расписании."
|
||||||
|
: "Сессия скрыта из публичного расписания.";
|
||||||
|
await LoadSessions();
|
||||||
|
}
|
||||||
|
catch (SessionAccessDeniedException)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось обновить публичность сессии: " + ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
publishingSessionId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddCoGm()
|
private async Task AddCoGm()
|
||||||
@@ -458,9 +640,16 @@
|
|||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
successMessage = null;
|
successMessage = null;
|
||||||
|
|
||||||
if (!coGmModel.TelegramId.HasValue || coGmModel.TelegramId.Value <= 0)
|
var coGmExternalUserId = coGmModel.ExternalUserId.Trim();
|
||||||
|
if (coGmExternalUserId.Length == 0)
|
||||||
{
|
{
|
||||||
errorMessage = "Telegram ID co-GM должен быть положительным числом.";
|
errorMessage = $"{CoGmIdLabel} должен быть заполнен.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsValidPlatformUserId(CoGmPlatform, coGmExternalUserId))
|
||||||
|
{
|
||||||
|
errorMessage = $"{CoGmIdLabel} должен быть положительным числом.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,10 +659,10 @@
|
|||||||
{
|
{
|
||||||
await SessionService.AddCoGmForOwnerAsync(
|
await SessionService.AddCoGmForOwnerAsync(
|
||||||
GroupId,
|
GroupId,
|
||||||
"Telegram",
|
CoGmPlatform,
|
||||||
coGmModel.TelegramId.Value.ToString(),
|
coGmExternalUserId,
|
||||||
coGmModel.DisplayName,
|
coGmModel.DisplayName,
|
||||||
coGmModel.TelegramUsername);
|
coGmModel.ExternalUsername);
|
||||||
|
|
||||||
coGmModel = new();
|
coGmModel = new();
|
||||||
successMessage = "Co-GM добавлен.";
|
successMessage = "Co-GM добавлен.";
|
||||||
@@ -493,15 +682,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RemoveCoGm(string coGmExternalUserId)
|
private async Task RemoveCoGm(WebGroupManager manager)
|
||||||
{
|
{
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
successMessage = null;
|
successMessage = null;
|
||||||
removingCoGmId = coGmExternalUserId;
|
removingCoGmId = ManagerKey(manager);
|
||||||
|
var platform = ManagerPlatform(manager);
|
||||||
|
var coGmExternalUserId = ManagerExternalUserId(manager);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SessionService.RemoveCoGmForOwnerAsync(GroupId, "Telegram", coGmExternalUserId);
|
await SessionService.RemoveCoGmForOwnerAsync(GroupId, platform, coGmExternalUserId);
|
||||||
successMessage = "Co-GM удалён.";
|
successMessage = "Co-GM удалён.";
|
||||||
await LoadSessions();
|
await LoadSessions();
|
||||||
}
|
}
|
||||||
@@ -795,7 +986,9 @@
|
|||||||
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
|
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
|
||||||
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
|
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
|
||||||
IntervalDays = InferIntervalDays(orderedSessions),
|
IntervalDays = InferIntervalDays(orderedSessions),
|
||||||
SessionCount = orderedSessions.Count
|
SessionCount = orderedSessions.Count,
|
||||||
|
PublicSessionCount = orderedSessions.Count(session => session.IsPublic),
|
||||||
|
AllSessionsPublic = orderedSessions.All(session => session.IsPublic)
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.OrderBy(batch => batch.FirstScheduledAtLocal)
|
.OrderBy(batch => batch.FirstScheduledAtLocal)
|
||||||
@@ -823,6 +1016,20 @@
|
|||||||
.ToList() ?? [];
|
.ToList() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RebuildPublicSettingsModel()
|
||||||
|
{
|
||||||
|
if (publicSettings is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
publicSettingsModel = new PublicSettingsEditModel
|
||||||
|
{
|
||||||
|
PublicScheduleEnabled = publicSettings.PublicScheduleEnabled,
|
||||||
|
PublicSlug = publicSettings.PublicSlug ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
|
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
|
||||||
{
|
{
|
||||||
batch.Title = batch.Title.Trim();
|
batch.Title = batch.Title.Trim();
|
||||||
@@ -832,24 +1039,76 @@
|
|||||||
|
|
||||||
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
|
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
|
||||||
|
|
||||||
|
private bool IsBatchPublishBusy(BatchBulkEditModel batch) => publishingBatchId == batch.BatchId;
|
||||||
|
|
||||||
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
|
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
|
||||||
|
|
||||||
|
private string? PublicClubUrl =>
|
||||||
|
string.IsNullOrWhiteSpace(publicSettings?.PublicSlug)
|
||||||
|
? null
|
||||||
|
: Navigation.ToAbsoluteUri($"/club/{publicSettings.PublicSlug}").ToString();
|
||||||
|
|
||||||
|
private string PublicSessionUrl(Guid sessionId) =>
|
||||||
|
Navigation.ToAbsoluteUri($"/s/{sessionId}").ToString();
|
||||||
|
|
||||||
|
private static string FormatPublicationStatus(WebSession session) =>
|
||||||
|
session.IsPublic ? "Опубликована" : "Скрыта";
|
||||||
|
|
||||||
|
private static string GetPublicationStatusClass(WebSession session) =>
|
||||||
|
session.IsPublic ? "status-success" : "status-neutral";
|
||||||
|
|
||||||
|
private static string FormatBatchPublication(BatchBulkEditModel batch) =>
|
||||||
|
batch.PublicSessionCount == 0
|
||||||
|
? "Все игры скрыты"
|
||||||
|
: batch.PublicSessionCount == batch.SessionCount
|
||||||
|
? "Все игры опубликованы"
|
||||||
|
: $"{batch.PublicSessionCount}/{batch.SessionCount} опубликовано";
|
||||||
|
|
||||||
|
private string CoGmPlatform =>
|
||||||
|
string.IsNullOrWhiteSpace(groupManagement?.Group.Platform)
|
||||||
|
? "Telegram"
|
||||||
|
: groupManagement.Group.Platform;
|
||||||
|
|
||||||
|
private string CoGmIdLabel => $"{CoGmPlatform} ID co-GM";
|
||||||
|
|
||||||
private string CurrentUserRole =>
|
private string CurrentUserRole =>
|
||||||
groupManagement?.Managers.FirstOrDefault(manager => manager.ExternalUserId == externalUserId)?.Role
|
groupManagement?.Managers.FirstOrDefault(manager =>
|
||||||
|
string.Equals(ManagerPlatform(manager), currentPlatform, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
ManagerExternalUserId(manager) == externalUserId)?.Role
|
||||||
?? GroupManagerRoleExtensions.CoGmValue;
|
?? GroupManagerRoleExtensions.CoGmValue;
|
||||||
|
|
||||||
private static string FormatRole(string role) =>
|
private static string FormatRole(string role) =>
|
||||||
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
||||||
|
|
||||||
private static string FormatManager(WebGroupManager manager)
|
private string FormatManager(WebGroupManager manager)
|
||||||
{
|
{
|
||||||
var username = string.IsNullOrWhiteSpace(manager.TelegramUsername)
|
var username = string.IsNullOrWhiteSpace(manager.ExternalUsername)
|
||||||
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
? manager.TelegramUsername
|
||||||
: "@" + manager.TelegramUsername;
|
: manager.ExternalUsername;
|
||||||
|
var identity = string.IsNullOrWhiteSpace(username)
|
||||||
|
? $"{ManagerPlatform(manager)} {ManagerExternalUserId(manager)}"
|
||||||
|
: "@" + username.TrimStart('@');
|
||||||
|
|
||||||
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {username}";
|
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {identity}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string ManagerPlatform(WebGroupManager manager) =>
|
||||||
|
string.IsNullOrWhiteSpace(manager.Platform) ? CoGmPlatform : manager.Platform;
|
||||||
|
|
||||||
|
private static string ManagerExternalUserId(WebGroupManager manager) =>
|
||||||
|
string.IsNullOrWhiteSpace(manager.ExternalUserId)
|
||||||
|
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||||
|
: manager.ExternalUserId;
|
||||||
|
|
||||||
|
private string ManagerKey(WebGroupManager manager) =>
|
||||||
|
$"{ManagerPlatform(manager)}:{ManagerExternalUserId(manager)}";
|
||||||
|
|
||||||
|
private static bool IsValidPlatformUserId(string platform, string externalUserId) =>
|
||||||
|
string.Equals(platform, "Telegram", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? long.TryParse(externalUserId, out var telegramId) && telegramId > 0
|
||||||
|
: !string.Equals(platform, "Discord", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
(ulong.TryParse(externalUserId, out var platformId) && platformId > 0);
|
||||||
|
|
||||||
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
|
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
|
||||||
{
|
{
|
||||||
if (orderedSessions.Count < 2)
|
if (orderedSessions.Count < 2)
|
||||||
@@ -926,6 +1185,8 @@
|
|||||||
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
|
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
|
||||||
public int IntervalDays { get; set; } = 7;
|
public int IntervalDays { get; set; } = 7;
|
||||||
public int SessionCount { get; init; }
|
public int SessionCount { get; init; }
|
||||||
|
public int PublicSessionCount { get; init; }
|
||||||
|
public bool AllSessionsPublic { get; init; }
|
||||||
public string CloneInterval { get; set; } = "week";
|
public string CloneInterval { get; set; } = "week";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -944,8 +1205,14 @@
|
|||||||
|
|
||||||
private sealed class CoGmEditModel
|
private sealed class CoGmEditModel
|
||||||
{
|
{
|
||||||
public long? TelegramId { get; set; }
|
public string ExternalUserId { get; set; } = "";
|
||||||
public string DisplayName { get; set; } = "";
|
public string DisplayName { get; set; } = "";
|
||||||
public string? TelegramUsername { get; set; }
|
public string? ExternalUsername { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PublicSettingsEditModel
|
||||||
|
{
|
||||||
|
public bool PublicScheduleEnabled { get; set; }
|
||||||
|
public string? PublicSlug { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,14 @@
|
|||||||
<div class="group-card-icon">🎮</div>
|
<div class="group-card-icon">🎮</div>
|
||||||
<h3 class="group-card-title">@group.Name</h3>
|
<h3 class="group-card-title">@group.Name</h3>
|
||||||
<p class="group-card-id">ID: @(group.Platform == "Discord" ? group.ExternalGroupId : group.TelegramChatId.ToString())</p>
|
<p class="group-card-id">ID: @(group.Platform == "Discord" ? group.ExternalGroupId : group.TelegramChatId.ToString())</p>
|
||||||
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;">
|
<div class="group-card-meta">
|
||||||
@FormatRole(group.ManagerRole)
|
<span class="status-badge platform-badge">
|
||||||
</span>
|
@FormatPlatform(group.Platform)
|
||||||
|
</span>
|
||||||
|
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
|
||||||
|
@FormatRole(group.ManagerRole)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
|
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
|
||||||
Посмотреть игры →
|
Посмотреть игры →
|
||||||
</a>
|
</a>
|
||||||
@@ -81,6 +86,20 @@
|
|||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group-card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge {
|
||||||
|
background: rgba(88, 101, 242, 0.15);
|
||||||
|
color: #9ea8ff;
|
||||||
|
border-color: rgba(88, 101, 242, 0.35);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -104,4 +123,7 @@
|
|||||||
|
|
||||||
private static string FormatRole(string role) =>
|
private static string FormatRole(string role) =>
|
||||||
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
||||||
|
|
||||||
|
private static string FormatPlatform(string? platform) =>
|
||||||
|
string.Equals(platform, "Discord", StringComparison.OrdinalIgnoreCase) ? "Discord" : "Telegram";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
@page "/profile"
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using Microsoft.Extensions.Configuration
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject ISessionStore SessionStore
|
||||||
|
@inject AuthorizedSessionService AuthorizedSessionService
|
||||||
|
@inject IConfiguration Configuration
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<PageTitle>Профиль — GM-Relay</PageTitle>
|
||||||
|
|
||||||
|
<div class="profile-container">
|
||||||
|
<h1 class="page-title">Профиль</h1>
|
||||||
|
|
||||||
|
@if (masterProfile is not null)
|
||||||
|
{
|
||||||
|
<div class="profile-card master-profile-card">
|
||||||
|
<div class="profile-card-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Публичный профиль мастера</h2>
|
||||||
|
<p class="muted-text">Показывается в каталоге, опубликованных играх и публичных страницах клуба.</p>
|
||||||
|
</div>
|
||||||
|
<span class="identity-badge">@(masterProfile.IsPublic ? "Публичный" : "Скрыт")</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="@masterProfileModel" OnValidSubmit="SaveMasterProfile">
|
||||||
|
<div class="gm-form-group public-toggle-field">
|
||||||
|
<label class="gm-checkbox-label">
|
||||||
|
<InputCheckbox @bind-Value="masterProfileModel.IsPublic" />
|
||||||
|
<span>Опубликовать профиль</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-form-grid">
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Имя в публичном профиле</label>
|
||||||
|
<InputText @bind-Value="masterProfileModel.DisplayName" class="gm-form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Короткий адрес</label>
|
||||||
|
<InputText @bind-Value="masterProfileModel.PublicSlug" class="gm-form-control" />
|
||||||
|
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-gm`.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Описание</label>
|
||||||
|
<InputTextArea @bind-Value="masterProfileModel.Bio" class="gm-form-control master-profile-bio" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="public-settings-actions">
|
||||||
|
<button type="submit" class="btn-gm btn-gm-primary" disabled="@savingMasterProfile">
|
||||||
|
@(savingMasterProfile ? "Сохраняем..." : "Сохранить профиль")
|
||||||
|
</button>
|
||||||
|
@if (PublicMasterProfileUrl is not null)
|
||||||
|
{
|
||||||
|
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">
|
||||||
|
Открыть публичный профиль
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
@if (PublicMasterProfileUrl is not null)
|
||||||
|
{
|
||||||
|
<div class="public-link-row">
|
||||||
|
<span>Ссылка профиля</span>
|
||||||
|
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer">@PublicMasterProfileUrl</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (identities is null)
|
||||||
|
{
|
||||||
|
<p class="loading-text">Загрузка...</p>
|
||||||
|
}
|
||||||
|
else if (identities.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="profile-card">
|
||||||
|
<p>Связанные аккаунты не найдены.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="profile-card">
|
||||||
|
<h2 class="section-title">Связанные аккаунты</h2>
|
||||||
|
<ul class="identity-list">
|
||||||
|
@foreach (var id in identities)
|
||||||
|
{
|
||||||
|
<li class="identity-item">
|
||||||
|
<div class="identity-info">
|
||||||
|
<span class="identity-platform">@id.Platform</span>
|
||||||
|
<span class="identity-name">@id.DisplayName</span>
|
||||||
|
</div>
|
||||||
|
@if (id.Platform != currentPlatform || id.ExternalUserId != currentExternalUserId)
|
||||||
|
{
|
||||||
|
<button class="btn btn-secondary btn-small"
|
||||||
|
@onclick="() => Unlink(id.Platform, id.ExternalUserId)"
|
||||||
|
disabled="@isUnlinking">
|
||||||
|
Отвязать
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="identity-badge">Текущий</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="profile-card">
|
||||||
|
<h2 class="section-title">Добавить аккаунт</h2>
|
||||||
|
@if (!HasLinkedPlatform("Discord"))
|
||||||
|
{
|
||||||
|
<a href="/auth/discord" class="btn btn-primary">
|
||||||
|
Привязать Discord
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="muted-text">Discord уже привязан.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (currentPlatform == "Discord" && !HasLinkedPlatform("Telegram"))
|
||||||
|
{
|
||||||
|
var botUsername = Configuration["Telegram__BotUsername"] ?? Configuration["Telegram:BotUsername"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(botUsername))
|
||||||
|
{
|
||||||
|
var authUrl = new Uri(new Uri(Navigation.BaseUri), "auth/telegram").ToString();
|
||||||
|
var widgetHtml = $"<script async src=\"https://telegram.org/js/telegram-widget.js?22\" data-telegram-login=\"{botUsername}\" data-size=\"large\" data-auth-url=\"{authUrl}\" data-request-access=\"write\"></script>";
|
||||||
|
<div class="telegram-widget-wrapper">
|
||||||
|
@((MarkupString)widgetHtml)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-error">@errorMessage</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(successMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">@successMessage</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<LinkedIdentity>? identities;
|
||||||
|
private MasterProfileSettings? masterProfile;
|
||||||
|
private string? currentPlatform;
|
||||||
|
private string? currentExternalUserId;
|
||||||
|
private bool isUnlinking;
|
||||||
|
private bool savingMasterProfile;
|
||||||
|
private string? errorMessage;
|
||||||
|
private string? successMessage;
|
||||||
|
private MasterProfileEditModel masterProfileModel = new();
|
||||||
|
|
||||||
|
[CascadingParameter]
|
||||||
|
private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery]
|
||||||
|
public string? Linked { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = "link_error")]
|
||||||
|
public string? LinkError { get; set; }
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (AuthenticationStateTask is not null)
|
||||||
|
{
|
||||||
|
var authState = await AuthenticationStateTask;
|
||||||
|
var user = authState.User;
|
||||||
|
if (user.TryGetPlatformIdentity(out var plat, out var extId))
|
||||||
|
{
|
||||||
|
currentPlatform = plat;
|
||||||
|
currentExternalUserId = extId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Linked))
|
||||||
|
{
|
||||||
|
successMessage = $"{Linked} аккаунт успешно привязан!";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(LinkError))
|
||||||
|
{
|
||||||
|
errorMessage = $"Ошибка привязки: {Uri.UnescapeDataString(LinkError)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadIdentities();
|
||||||
|
await LoadMasterProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadIdentities()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (currentPlatform is not null && currentExternalUserId is not null)
|
||||||
|
{
|
||||||
|
identities = await SessionStore.GetLinkedIdentitiesAsync(currentPlatform, currentExternalUserId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
identities = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = $"Не удалось загрузить аккаунты: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadMasterProfile()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
masterProfile = await AuthorizedSessionService.GetMasterProfileSettingsForCurrentUserAsync();
|
||||||
|
if (masterProfile is not null)
|
||||||
|
{
|
||||||
|
masterProfileModel = new MasterProfileEditModel
|
||||||
|
{
|
||||||
|
DisplayName = masterProfile.DisplayName,
|
||||||
|
PublicSlug = masterProfile.PublicSlug ?? string.Empty,
|
||||||
|
IsPublic = masterProfile.IsPublic,
|
||||||
|
Bio = masterProfile.Bio ?? string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = $"Не удалось загрузить профиль мастера: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? PublicMasterProfileUrl =>
|
||||||
|
masterProfile?.IsPublic == true && !string.IsNullOrWhiteSpace(masterProfile.PublicSlug)
|
||||||
|
? Navigation.ToAbsoluteUri($"/gm/{masterProfile.PublicSlug}").ToString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private async Task SaveMasterProfile()
|
||||||
|
{
|
||||||
|
savingMasterProfile = true;
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await AuthorizedSessionService.UpdateMasterProfileSettingsForCurrentUserAsync(
|
||||||
|
masterProfileModel.PublicSlug,
|
||||||
|
masterProfileModel.IsPublic,
|
||||||
|
masterProfileModel.DisplayName,
|
||||||
|
masterProfileModel.Bio);
|
||||||
|
|
||||||
|
successMessage = "Публичный профиль мастера обновлён.";
|
||||||
|
await LoadMasterProfile();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = $"Не удалось сохранить профиль мастера: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
savingMasterProfile = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasLinkedPlatform(string platform)
|
||||||
|
{
|
||||||
|
return identities?.Any(i => i.Platform == platform) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Unlink(string platform, string externalUserId)
|
||||||
|
{
|
||||||
|
isUnlinking = true;
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (currentPlatform is null || currentExternalUserId is null)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось определить текущего пользователя.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SessionStore.UnlinkIdentityAsync(currentPlatform, currentExternalUserId, platform, externalUserId);
|
||||||
|
successMessage = $"{platform} аккаунт отвязан.";
|
||||||
|
await LoadIdentities();
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
errorMessage = $"Ошибка отвязки: {ex.Message}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = $"Ошибка отвязки: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isUnlinking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MasterProfileEditModel
|
||||||
|
{
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string PublicSlug { get; set; } = string.Empty;
|
||||||
|
public bool IsPublic { get; set; }
|
||||||
|
public string Bio { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
@page "/club/{Slug}"
|
||||||
|
@layout PublicLayout
|
||||||
|
@inject ISessionStore SessionStore
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<PageTitle>@PageTitleText</PageTitle>
|
||||||
|
|
||||||
|
@if (loaded && club is null)
|
||||||
|
{
|
||||||
|
<HeadContent>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
</HeadContent>
|
||||||
|
|
||||||
|
<section class="public-hero public-hero-compact">
|
||||||
|
<span class="status-badge status-neutral">Недоступно</span>
|
||||||
|
<h1>Публичная страница не найдена</h1>
|
||||||
|
<p>Расписание клуба выключено или адрес больше не используется.</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else if (!loaded)
|
||||||
|
{
|
||||||
|
<section class="public-hero public-hero-compact">
|
||||||
|
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 75%;"></div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else if (club is not null)
|
||||||
|
{
|
||||||
|
<HeadContent>
|
||||||
|
<meta name="description" content="@($"Публичное расписание клуба {club.Name} в GM-Relay.")" />
|
||||||
|
</HeadContent>
|
||||||
|
|
||||||
|
<section class="public-hero">
|
||||||
|
<span class="status-badge status-success">Публичное расписание</span>
|
||||||
|
<h1>@club.Name</h1>
|
||||||
|
<p>Открытые игры клуба без состава игроков, личных данных и приватных ссылок.</p>
|
||||||
|
<div class="public-share-row">
|
||||||
|
<span>Ссылка клуба</span>
|
||||||
|
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(club.MasterProfileSlug))
|
||||||
|
{
|
||||||
|
<div class="public-share-row">
|
||||||
|
<span>Мастер</span>
|
||||||
|
<a href="@MasterProfilePath(club.MasterProfileSlug)" target="_blank" rel="noopener noreferrer">
|
||||||
|
@(club.MasterDisplayName ?? "Профиль мастера")
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (club.Sessions.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="glass-card public-empty-state">
|
||||||
|
<h2>Опубликованных игр пока нет</h2>
|
||||||
|
<p>Когда owner или co-GM откроет сессии для публичного расписания, они появятся здесь.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="public-session-list">
|
||||||
|
@foreach (var session in club.Sessions)
|
||||||
|
{
|
||||||
|
<article class="public-session-card">
|
||||||
|
<div class="public-session-main">
|
||||||
|
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||||
|
<h2>@session.Title</h2>
|
||||||
|
<div class="public-session-meta">
|
||||||
|
<span>@session.ScheduledAt.FormatMoscow()</span>
|
||||||
|
<span>@FormatSeats(session)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string? Slug { get; set; }
|
||||||
|
|
||||||
|
private WebPublicClub? club;
|
||||||
|
private bool loaded;
|
||||||
|
|
||||||
|
private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay";
|
||||||
|
|
||||||
|
private string PublicClubUrl =>
|
||||||
|
club is null
|
||||||
|
? Navigation.ToAbsoluteUri($"/club/{Slug}").ToString()
|
||||||
|
: Navigation.ToAbsoluteUri($"/club/{club.Slug}").ToString();
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
loaded = false;
|
||||||
|
club = string.IsNullOrWhiteSpace(Slug)
|
||||||
|
? null
|
||||||
|
: await SessionStore.GetPublicClubBySlugAsync(Slug.Trim());
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string PublicSessionPath(Guid sessionId) => $"/s/{sessionId}";
|
||||||
|
|
||||||
|
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
|
||||||
|
|
||||||
|
private static string FormatSeats(WebPublicSession session)
|
||||||
|
{
|
||||||
|
var seats = session.MaxPlayers.HasValue
|
||||||
|
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
||||||
|
: $"{session.ActivePlayerCount} игроков";
|
||||||
|
|
||||||
|
return session.WaitlistedPlayerCount > 0
|
||||||
|
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
|
||||||
|
: seats;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetStatusClass(string status) => status switch
|
||||||
|
{
|
||||||
|
SessionStatus.Confirmed => "status-success",
|
||||||
|
SessionStatus.ConfirmationSent => "status-warning",
|
||||||
|
SessionStatus.Planned => "status-info",
|
||||||
|
_ => "status-neutral"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string TranslateStatus(string status) => status switch
|
||||||
|
{
|
||||||
|
SessionStatus.Planned => "Запланировано",
|
||||||
|
SessionStatus.ConfirmationSent => "Ждем подтверждения",
|
||||||
|
SessionStatus.Confirmed => "Подтверждено",
|
||||||
|
_ => status
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
@page "/gm/{Slug}"
|
||||||
|
@layout PublicLayout
|
||||||
|
@inject ISessionStore SessionStore
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<PageTitle>@PageTitleText</PageTitle>
|
||||||
|
|
||||||
|
@if (loaded && profile is null)
|
||||||
|
{
|
||||||
|
<HeadContent>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
</HeadContent>
|
||||||
|
|
||||||
|
<section class="public-hero public-hero-compact">
|
||||||
|
<span class="status-badge status-neutral">Недоступно</span>
|
||||||
|
<h1>Профиль мастера не найден</h1>
|
||||||
|
<p>Мастер скрыл профиль или этот короткий адрес больше не используется.</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else if (!loaded)
|
||||||
|
{
|
||||||
|
<section class="public-hero public-hero-compact">
|
||||||
|
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 75%;"></div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else if (profile is not null)
|
||||||
|
{
|
||||||
|
<HeadContent>
|
||||||
|
<meta name="description" content="@($"Публичный профиль мастера {profile.DisplayName} в GM-Relay.")" />
|
||||||
|
</HeadContent>
|
||||||
|
|
||||||
|
<section class="public-hero public-hero-compact master-profile-hero">
|
||||||
|
<span class="status-badge status-success">Мастер</span>
|
||||||
|
<h1>@profile.DisplayName</h1>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(profile.Bio))
|
||||||
|
{
|
||||||
|
<p>@profile.Bio</p>
|
||||||
|
}
|
||||||
|
<div class="public-share-row">
|
||||||
|
<span>Ссылка профиля</span>
|
||||||
|
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer">@PublicMasterProfileUrl</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (profile.Clubs.Count > 0)
|
||||||
|
{
|
||||||
|
<section class="glass-card master-profile-section">
|
||||||
|
<h2>Клубы</h2>
|
||||||
|
<div class="master-profile-club-list">
|
||||||
|
@foreach (var club in profile.Clubs)
|
||||||
|
{
|
||||||
|
<a class="status-badge status-info" href="@($"/club/{club.Slug}")">@club.Name</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (profile.Sessions.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="glass-card public-empty-state">
|
||||||
|
<h2>Опубликованных игр пока нет</h2>
|
||||||
|
<p>Когда мастер откроет игры для каталога, они появятся здесь.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="public-session-list">
|
||||||
|
@foreach (var session in profile.Sessions)
|
||||||
|
{
|
||||||
|
<article class="public-session-card">
|
||||||
|
<div class="public-session-main">
|
||||||
|
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||||
|
<h2>@session.Title</h2>
|
||||||
|
<div class="public-session-meta">
|
||||||
|
<span>@session.GroupName</span>
|
||||||
|
<span>@session.ScheduledAt.FormatMoscow()</span>
|
||||||
|
<span>@FormatSeats(session)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Открыть</a>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string? Slug { get; set; }
|
||||||
|
|
||||||
|
private GmRelay.Web.Services.PublicMasterProfile? profile;
|
||||||
|
private bool loaded;
|
||||||
|
|
||||||
|
private string PageTitleText => profile is null ? "Профиль мастера — GM-Relay" : $"{profile.DisplayName} — GM-Relay";
|
||||||
|
|
||||||
|
private string PublicMasterProfileUrl =>
|
||||||
|
profile is null
|
||||||
|
? Navigation.ToAbsoluteUri($"/gm/{Slug}").ToString()
|
||||||
|
: Navigation.ToAbsoluteUri($"/gm/{profile.Slug}").ToString();
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
loaded = false;
|
||||||
|
profile = string.IsNullOrWhiteSpace(Slug)
|
||||||
|
? null
|
||||||
|
: await SessionStore.GetPublicMasterProfileBySlugAsync(Slug.Trim());
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatSeats(WebPublicSession session)
|
||||||
|
{
|
||||||
|
var seats = session.MaxPlayers.HasValue
|
||||||
|
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
||||||
|
: $"{session.ActivePlayerCount} игроков";
|
||||||
|
|
||||||
|
return session.WaitlistedPlayerCount > 0
|
||||||
|
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
|
||||||
|
: seats;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetStatusClass(string status) => status switch
|
||||||
|
{
|
||||||
|
SessionStatus.Confirmed => "status-success",
|
||||||
|
SessionStatus.ConfirmationSent => "status-warning",
|
||||||
|
SessionStatus.Planned => "status-info",
|
||||||
|
_ => "status-neutral"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string TranslateStatus(string status) => status switch
|
||||||
|
{
|
||||||
|
SessionStatus.Planned => "Запланировано",
|
||||||
|
SessionStatus.ConfirmationSent => "Ждем подтверждения",
|
||||||
|
SessionStatus.Confirmed => "Подтверждено",
|
||||||
|
_ => status
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
@page "/s/{SessionId:guid}"
|
||||||
|
@layout PublicLayout
|
||||||
|
@inject ISessionStore SessionStore
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@using GmRelay.Shared.Features.Showcase
|
||||||
|
@using GmRelay.Web.Services
|
||||||
|
|
||||||
|
<PageTitle>@PageTitleText</PageTitle>
|
||||||
|
|
||||||
|
@if (loaded && session is null)
|
||||||
|
{
|
||||||
|
<HeadContent>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
</HeadContent>
|
||||||
|
|
||||||
|
<section class="public-hero public-hero-compact">
|
||||||
|
<span class="status-badge status-neutral">Недоступно</span>
|
||||||
|
<h1>Сессия не опубликована</h1>
|
||||||
|
<p>Эта игра скрыта, отменена, уже прошла или клуб выключил публичное расписание.</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else if (!loaded)
|
||||||
|
{
|
||||||
|
<section class="public-hero public-hero-compact">
|
||||||
|
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 75%;"></div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else if (session is not null)
|
||||||
|
{
|
||||||
|
<HeadContent>
|
||||||
|
<meta name="description" content="@($"Публичная сессия {session.Title} клуба {session.GroupName} в GM-Relay.")" />
|
||||||
|
</HeadContent>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(session.CoverImageUrl))
|
||||||
|
{
|
||||||
|
<div class="session-cover-hero" style="background-image: url('@session.CoverImageUrl')"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="public-hero public-hero-compact">
|
||||||
|
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||||
|
<h1>@session.Title</h1>
|
||||||
|
<p>@session.GroupName</p>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
|
||||||
|
{
|
||||||
|
<div class="public-master-link">
|
||||||
|
<span>Мастер</span>
|
||||||
|
<a href="@MasterProfilePath(session.MasterProfileSlug)">@(session.MasterDisplayName ?? "Профиль мастера")</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="session-badges">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(session.System))
|
||||||
|
{
|
||||||
|
<span class="status-badge status-info">@GetSystemDisplayName(session.System)</span>
|
||||||
|
}
|
||||||
|
@if (session.IsOneShot)
|
||||||
|
{
|
||||||
|
<span class="status-badge status-warning">Ваншот</span>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(session.Format))
|
||||||
|
{
|
||||||
|
<span class="status-badge status-neutral">@TranslateFormat(session.Format)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<article class="glass-card public-session-detail">
|
||||||
|
<div class="public-detail-grid">
|
||||||
|
<div>
|
||||||
|
<span>Время</span>
|
||||||
|
<strong>@session.ScheduledAt.FormatMoscow()</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Места</span>
|
||||||
|
<strong>@FormatSeats(session)</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Статус</span>
|
||||||
|
<strong>@TranslateStatus(session.Status)</strong>
|
||||||
|
</div>
|
||||||
|
@if (session.DurationMinutes.HasValue)
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
<span>Длительность</span>
|
||||||
|
<strong>@FormatDuration(session.DurationMinutes.Value)</strong>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(session.Description))
|
||||||
|
{
|
||||||
|
<div class="session-description">
|
||||||
|
<h3>Описание</h3>
|
||||||
|
<p>@session.Description</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (registrationResult is not null)
|
||||||
|
{
|
||||||
|
<div class="glass-card @GetRegistrationResultClass()">
|
||||||
|
<p>@registrationResult</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="public-settings-actions">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(session.GroupSlug))
|
||||||
|
{
|
||||||
|
<a class="btn-gm btn-gm-primary" href="@($"/club/{session.GroupSlug}")">Расписание клуба</a>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
|
||||||
|
{
|
||||||
|
<a class="btn-gm btn-gm-outline" href="@MasterProfilePath(session.MasterProfileSlug)">Мастер</a>
|
||||||
|
}
|
||||||
|
<a class="btn-gm btn-gm-outline" href="@PublicSessionUrl" target="_blank" rel="noopener noreferrer">Ссылка на сессию</a>
|
||||||
|
@if (session.AllowDirectRegistration)
|
||||||
|
{
|
||||||
|
@if (isAuthenticated)
|
||||||
|
{
|
||||||
|
<button class="btn-gm btn-gm-primary" @onclick="RegisterAsync">Записаться</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="btn-gm btn-gm-primary" href="@GetLoginUrl()">Войти, чтобы записаться</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public Guid SessionId { get; set; }
|
||||||
|
|
||||||
|
private ShowcaseSessionDto? session;
|
||||||
|
private bool loaded;
|
||||||
|
private bool isAuthenticated;
|
||||||
|
private string? registrationResult;
|
||||||
|
|
||||||
|
private string PageTitleText => session is null ? "Публичная сессия — GM-Relay" : $"{session.Title} — GM-Relay";
|
||||||
|
|
||||||
|
private string PublicSessionUrl => Navigation.ToAbsoluteUri($"/s/{SessionId}").ToString();
|
||||||
|
|
||||||
|
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
loaded = false;
|
||||||
|
registrationResult = null;
|
||||||
|
session = await SessionStore.GetShowcaseSessionAsync(SessionId);
|
||||||
|
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
isAuthenticated = authState.User.Identity?.IsAuthenticated ?? false;
|
||||||
|
|
||||||
|
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||||
|
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
var shouldRegister = query.TryGetValue("register", out var val) && val == "1";
|
||||||
|
|
||||||
|
if (session is not null && shouldRegister && session.AllowDirectRegistration)
|
||||||
|
{
|
||||||
|
if (isAuthenticated && authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||||
|
{
|
||||||
|
var success = await SessionStore.RegisterFromShowcaseAsync(SessionId, platform, externalUserId, authState.User.Identity?.Name ?? "Игрок");
|
||||||
|
registrationResult = success
|
||||||
|
? "Вы успешно записались на игру!"
|
||||||
|
: "Не удалось записаться. Возможно, места закончились или вы уже зарегистрированы.";
|
||||||
|
}
|
||||||
|
else if (!isAuthenticated)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString($"/s/{SessionId}")}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RegisterAsync()
|
||||||
|
{
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
if (authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||||
|
{
|
||||||
|
var success = await SessionStore.RegisterFromShowcaseAsync(SessionId, platform, externalUserId, authState.User.Identity?.Name ?? "Игрок");
|
||||||
|
registrationResult = success
|
||||||
|
? "Вы успешно записались на игру!"
|
||||||
|
: "Не удалось записаться. Возможно, места закончились или вы уже зарегистрированы.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetLoginUrl() => $"/login?returnUrl={Uri.EscapeDataString($"/s/{SessionId}?register=1")}";
|
||||||
|
|
||||||
|
private string GetRegistrationResultClass() => registrationResult?.StartsWith("Вы успешно") == true ? "status-success-bg" : "status-warning-bg";
|
||||||
|
|
||||||
|
private static string FormatSeats(ShowcaseSessionDto session)
|
||||||
|
{
|
||||||
|
var seats = session.MaxPlayers.HasValue
|
||||||
|
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
||||||
|
: $"{session.ActivePlayerCount} игроков";
|
||||||
|
|
||||||
|
return session.WaitlistedPlayerCount > 0
|
||||||
|
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
|
||||||
|
: seats;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDuration(int minutes)
|
||||||
|
{
|
||||||
|
if (minutes < 60)
|
||||||
|
return $"{minutes} мин";
|
||||||
|
|
||||||
|
var hours = minutes / 60;
|
||||||
|
var mins = minutes % 60;
|
||||||
|
return mins > 0 ? $"{hours} ч {mins} мин" : $"{hours} ч";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSystemDisplayName(string? system)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(system))
|
||||||
|
return system ?? string.Empty;
|
||||||
|
|
||||||
|
if (Enum.TryParse<GameSystem>(system, out var gs))
|
||||||
|
return gs.ToDisplayName();
|
||||||
|
|
||||||
|
return system;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TranslateFormat(string format) => format switch
|
||||||
|
{
|
||||||
|
"Online" => "Онлайн",
|
||||||
|
"Offline" => "Офлайн",
|
||||||
|
"Hybrid" => "Гибрид",
|
||||||
|
_ => format
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetStatusClass(string status) => status switch
|
||||||
|
{
|
||||||
|
SessionStatus.Confirmed => "status-success",
|
||||||
|
SessionStatus.ConfirmationSent => "status-warning",
|
||||||
|
SessionStatus.Planned => "status-info",
|
||||||
|
_ => "status-neutral"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string TranslateStatus(string status) => status switch
|
||||||
|
{
|
||||||
|
SessionStatus.Planned => "Запланировано",
|
||||||
|
SessionStatus.ConfirmationSent => "Ждем подтверждения",
|
||||||
|
SessionStatus.Confirmed => "Подтверждено",
|
||||||
|
_ => status
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
@page "/showcase"
|
||||||
|
@layout PublicLayout
|
||||||
|
@inject ISessionStore SessionStore
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@using GmRelay.Shared.Features.Showcase
|
||||||
|
|
||||||
|
<PageTitle>Каталог игр — GM-Relay</PageTitle>
|
||||||
|
|
||||||
|
<HeadContent>
|
||||||
|
<meta name="description" content="Каталог настольных ролевых игр GM-Relay. Найдите игру по душе — ваншоты, кампании, онлайн и офлайн." />
|
||||||
|
</HeadContent>
|
||||||
|
|
||||||
|
<section class="public-hero">
|
||||||
|
<h1>Каталог игр</h1>
|
||||||
|
<p>Найдите настольную ролевую игру по душе — ваншоты, кампании, онлайн и офлайн.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="glass-card showcase-filters">
|
||||||
|
<div class="showcase-filter-group">
|
||||||
|
<span class="showcase-filter-label">Когда</span>
|
||||||
|
<div class="showcase-filter-buttons">
|
||||||
|
<button class="btn-gm @(filter.Date == DateFilter.Today ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.Today)">Сегодня</button>
|
||||||
|
<button class="btn-gm @(filter.Date == DateFilter.Tomorrow ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.Tomorrow)">Завтра</button>
|
||||||
|
<button class="btn-gm @(filter.Date == DateFilter.ThisWeek ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.ThisWeek)">На этой неделе</button>
|
||||||
|
<button class="btn-gm @(filter.Date == DateFilter.All ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.All)">Все</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="showcase-filter-group">
|
||||||
|
<span class="showcase-filter-label">Места</span>
|
||||||
|
<div class="showcase-filter-buttons">
|
||||||
|
<button class="btn-gm @(filter.Seats == SeatFilter.Available ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetSeats(SeatFilter.Available)">Есть места</button>
|
||||||
|
<button class="btn-gm @(filter.Seats == SeatFilter.Waitlist ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetSeats(SeatFilter.Waitlist)">Лист ожидания</button>
|
||||||
|
<button class="btn-gm @(filter.Seats == SeatFilter.Any ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetSeats(SeatFilter.Any)">Любые</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="showcase-filter-group">
|
||||||
|
<label class="showcase-filter-label" for="system-filter">Система</label>
|
||||||
|
<select id="system-filter" class="gm-form-control showcase-filter-select" aria-label="Система" @onchange="OnSystemChanged">
|
||||||
|
<option value="" selected="@(filter.System is null)">Любая</option>
|
||||||
|
@foreach (var system in Enum.GetValues<GameSystem>())
|
||||||
|
{
|
||||||
|
var name = system.ToString();
|
||||||
|
<option value="@name" selected="@(filter.System == name)">@system.ToDisplayName()</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="showcase-filter-group">
|
||||||
|
<span class="showcase-filter-label">Тип</span>
|
||||||
|
<div class="showcase-filter-buttons">
|
||||||
|
<button class="btn-gm @(filter.IsOneShot == true ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetOneShot(true)">Ваншот</button>
|
||||||
|
<button class="btn-gm @(filter.IsOneShot == false ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetOneShot(false)">Кампания</button>
|
||||||
|
<button class="btn-gm @(filter.IsOneShot is null ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetOneShot(null)">Любое</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="showcase-filter-group">
|
||||||
|
<span class="showcase-filter-label">Формат</span>
|
||||||
|
<div class="showcase-filter-buttons">
|
||||||
|
<button class="btn-gm @(filter.Format == "Online" ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat("Online"))">Онлайн</button>
|
||||||
|
<button class="btn-gm @(filter.Format == "Offline" ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat("Offline"))">Офлайн</button>
|
||||||
|
<button class="btn-gm @(filter.Format == "Hybrid" ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat("Hybrid"))">Гибрид</button>
|
||||||
|
<button class="btn-gm @(filter.Format is null ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat((string?)null))">Любой</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (loading && sessions.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="showcase-grid">
|
||||||
|
@for (var i = 0; i < 6; i++)
|
||||||
|
{
|
||||||
|
<div class="glass-card showcase-card showcase-skeleton">
|
||||||
|
<div class="skeleton showcase-skeleton-image"></div>
|
||||||
|
<div class="showcase-card-body">
|
||||||
|
<div class="skeleton skeleton-text" style="width: 70%;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 45%;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 55%;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (!loading && sessions.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="glass-card public-empty-state">
|
||||||
|
<h2>Игры не найдены</h2>
|
||||||
|
<p>Попробуйте изменить фильтры или загляните позже — новые сессии появляются каждый день.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="showcase-grid">
|
||||||
|
@foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
<article class="glass-card showcase-card animate-fade-in">
|
||||||
|
<div class="showcase-card-image"
|
||||||
|
style="@(string.IsNullOrWhiteSpace(session.CoverImageUrl)
|
||||||
|
? $"background: {GetGradientStyle(session.Id)}; background-size: cover; background-position: center;"
|
||||||
|
: $"background-image: url({session.CoverImageUrl}); background-size: cover; background-position: center;")">
|
||||||
|
</div>
|
||||||
|
<div class="showcase-card-body">
|
||||||
|
<div class="showcase-card-badges">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(session.System))
|
||||||
|
{
|
||||||
|
<span class="status-badge status-info">@GetSystemDisplayName(session.System)</span>
|
||||||
|
}
|
||||||
|
@if (session.IsOneShot)
|
||||||
|
{
|
||||||
|
<span class="status-badge status-warning">Ваншот</span>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(session.Format))
|
||||||
|
{
|
||||||
|
<span class="status-badge status-neutral">@TranslateFormat(session.Format)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<h2 class="showcase-card-title">@session.Title</h2>
|
||||||
|
<div class="showcase-card-meta">
|
||||||
|
<span>@session.ScheduledAt.FormatMoscow()</span>
|
||||||
|
@if (session.DurationMinutes.HasValue)
|
||||||
|
{
|
||||||
|
<span>@FormatDuration(session.DurationMinutes.Value)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="showcase-card-seats">
|
||||||
|
<span>@FormatSeats(session)</span>
|
||||||
|
</div>
|
||||||
|
<div class="showcase-card-club">
|
||||||
|
<span>@session.GroupName</span>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
|
||||||
|
{
|
||||||
|
<div class="showcase-card-master">
|
||||||
|
<a href="@MasterProfilePath(session.MasterProfileSlug)">@(session.MasterDisplayName ?? "Профиль мастера")</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="showcase-card-actions">
|
||||||
|
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Подробнее</a>
|
||||||
|
@if (session.AllowDirectRegistration)
|
||||||
|
{
|
||||||
|
<a class="btn-gm btn-gm-primary" href="@($"/s/{session.Id}?register=1")">Записаться</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (hasMore)
|
||||||
|
{
|
||||||
|
<div class="showcase-load-more">
|
||||||
|
<button class="btn-gm btn-gm-primary" @onclick="LoadMoreAsync" disabled="@loading">
|
||||||
|
@if (loading)
|
||||||
|
{
|
||||||
|
<span>Загрузка...</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Загрузить ещё</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.showcase-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-filter-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-family: 'Jura', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-filter-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-filter-buttons .btn-gm {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-filter-select {
|
||||||
|
min-width: 180px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (min-width: 640px) {
|
||||||
|
.showcase-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (min-width: 1024px) {
|
||||||
|
.showcase-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-card {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-card-image {
|
||||||
|
height: 160px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-card-body {
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-card-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-card-title {
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-card-meta,
|
||||||
|
.showcase-card-seats,
|
||||||
|
.showcase-card-club,
|
||||||
|
.showcase-card-master {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'Jura', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-card-club {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-card-master a {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-card-actions {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-card-actions .btn-gm {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-load-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-skeleton {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-skeleton-image {
|
||||||
|
height: 160px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-skeleton .showcase-card-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-skeleton .skeleton-text {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private ShowcaseFilter filter = new();
|
||||||
|
private List<ShowcaseSessionDto> sessions = new();
|
||||||
|
private bool loading;
|
||||||
|
private bool hasMore;
|
||||||
|
private int page = 1;
|
||||||
|
private const int PageSize = 12;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
loading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
page = 1;
|
||||||
|
sessions.Clear();
|
||||||
|
var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize);
|
||||||
|
sessions.AddRange(results);
|
||||||
|
hasMore = results.Count == PageSize;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadMoreAsync()
|
||||||
|
{
|
||||||
|
if (loading)
|
||||||
|
return;
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
page++;
|
||||||
|
var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize);
|
||||||
|
sessions.AddRange(results);
|
||||||
|
hasMore = results.Count == PageSize;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnFilterChanged()
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetDate(DateFilter value)
|
||||||
|
{
|
||||||
|
filter = filter with { Date = value };
|
||||||
|
await OnFilterChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetSeats(SeatFilter value)
|
||||||
|
{
|
||||||
|
filter = filter with { Seats = value };
|
||||||
|
await OnFilterChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnSystemChanged(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
var value = e.Value?.ToString();
|
||||||
|
filter = filter with { System = string.IsNullOrWhiteSpace(value) ? null : value };
|
||||||
|
await OnFilterChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetOneShot(bool? value)
|
||||||
|
{
|
||||||
|
filter = filter with { IsOneShot = value };
|
||||||
|
await OnFilterChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetFormat(string? value)
|
||||||
|
{
|
||||||
|
filter = filter with { Format = value };
|
||||||
|
await OnFilterChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetGradientStyle(Guid id)
|
||||||
|
{
|
||||||
|
var bytes = id.ToByteArray();
|
||||||
|
var hue1 = bytes[0] % 360;
|
||||||
|
var hue2 = (bytes[1] + 120) % 360;
|
||||||
|
return $"linear-gradient(135deg, hsl({hue1}, 55%, 28%) 0%, hsl({hue2}, 55%, 20%) 100%)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSystemDisplayName(string? system)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(system))
|
||||||
|
return system ?? string.Empty;
|
||||||
|
|
||||||
|
if (Enum.TryParse<GameSystem>(system, out var gs))
|
||||||
|
return gs.ToDisplayName();
|
||||||
|
|
||||||
|
return system;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatSeats(ShowcaseSessionDto session)
|
||||||
|
{
|
||||||
|
var seats = session.MaxPlayers.HasValue
|
||||||
|
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
||||||
|
: $"{session.ActivePlayerCount} игроков";
|
||||||
|
|
||||||
|
if (session.WaitlistedPlayerCount > 0)
|
||||||
|
seats += $", ожидание {session.WaitlistedPlayerCount}";
|
||||||
|
|
||||||
|
return seats;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDuration(int minutes)
|
||||||
|
{
|
||||||
|
if (minutes < 60)
|
||||||
|
return $"{minutes} мин";
|
||||||
|
|
||||||
|
var hours = minutes / 60;
|
||||||
|
var mins = minutes % 60;
|
||||||
|
return mins > 0 ? $"{hours} ч {mins} мин" : $"{hours} ч";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
|
||||||
|
|
||||||
|
private static string TranslateFormat(string format) => format switch
|
||||||
|
{
|
||||||
|
"Online" => "Онлайн",
|
||||||
|
"Offline" => "Офлайн",
|
||||||
|
"Hybrid" => "Гибрид",
|
||||||
|
_ => format
|
||||||
|
};
|
||||||
|
}
|
||||||
+92
-25
@@ -39,6 +39,7 @@ builder.AddNpgsqlDataSource("gmrelaydb");
|
|||||||
builder.Services.AddSingleton<TelegramAuthService>();
|
builder.Services.AddSingleton<TelegramAuthService>();
|
||||||
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
|
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
|
||||||
builder.Services.AddSingleton<DiscordAuthService>();
|
builder.Services.AddSingleton<DiscordAuthService>();
|
||||||
|
builder.Services.AddSingleton<DiscordOAuthStateStore>();
|
||||||
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
||||||
builder.Services.AddScoped<AuthorizedSessionService>();
|
builder.Services.AddScoped<AuthorizedSessionService>();
|
||||||
builder.Services.AddScoped<CalendarSubscriptionService>();
|
builder.Services.AddScoped<CalendarSubscriptionService>();
|
||||||
@@ -60,7 +61,7 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
|
|||||||
options.AccessDeniedPath = "/access-denied";
|
options.AccessDeniedPath = "/access-denied";
|
||||||
options.Cookie.HttpOnly = true;
|
options.Cookie.HttpOnly = true;
|
||||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||||
options.Cookie.SameSite = SameSiteMode.Strict;
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||||
options.SlidingExpiration = true;
|
options.SlidingExpiration = true;
|
||||||
});
|
});
|
||||||
@@ -122,24 +123,45 @@ app.MapHealthChecks("/alive", new HealthCheckOptions
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Endpoint to handle Telegram Login callback
|
// Endpoint to handle Telegram Login callback
|
||||||
app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService authService) =>
|
app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService authService, ISessionStore sessionStore) =>
|
||||||
{
|
{
|
||||||
if (authService.Verify(context.Request.Query, out var telegramId, out var name))
|
if (!authService.Verify(context.Request.Query, out var telegramId, out var name))
|
||||||
|
return Results.Redirect("/login?error=auth_failed");
|
||||||
|
|
||||||
|
await sessionStore.UpsertPlayerAsync("Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), name, null);
|
||||||
|
|
||||||
|
// If already authenticated via another platform, link instead of replacing session
|
||||||
|
if (context.User.Identity?.IsAuthenticated == true
|
||||||
|
&& context.User.TryGetPlatformIdentity(out var currentPlatform, out var currentExternalUserId)
|
||||||
|
&& currentPlatform != "Telegram")
|
||||||
{
|
{
|
||||||
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
try
|
||||||
await context.SignInAsync(
|
{
|
||||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
// Always make Telegram the primary (it has the historical data/groups)
|
||||||
CreateTelegramPrincipal(telegramId, name),
|
await sessionStore.LinkIdentityAsync(
|
||||||
authProperties);
|
"Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
return Results.Redirect("/");
|
currentPlatform, currentExternalUserId,
|
||||||
|
name);
|
||||||
|
return Results.Redirect("/profile?linked=telegram");
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return Results.Redirect($"/profile?link_error={Uri.EscapeDataString(ex.Message)}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Redirect("/login?error=auth_failed");
|
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
||||||
|
await context.SignInAsync(
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||||
|
CreateTelegramPrincipal(telegramId, name),
|
||||||
|
authProperties);
|
||||||
|
return Results.Redirect("/");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapPost("/auth/telegram-webapp", async (
|
app.MapPost("/auth/telegram-webapp", async (
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
TelegramAuthService authService,
|
TelegramAuthService authService,
|
||||||
|
ISessionStore sessionStore,
|
||||||
TelegramWebAppAuthRequest request) =>
|
TelegramWebAppAuthRequest request) =>
|
||||||
{
|
{
|
||||||
if (!authService.VerifyWebAppInitData(request.InitData, out var telegramId, out var name))
|
if (!authService.VerifyWebAppInitData(request.InitData, out var telegramId, out var name))
|
||||||
@@ -147,6 +169,8 @@ app.MapPost("/auth/telegram-webapp", async (
|
|||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await sessionStore.UpsertPlayerAsync("Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), name, null);
|
||||||
|
|
||||||
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
||||||
await context.SignInAsync(
|
await context.SignInAsync(
|
||||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||||
@@ -159,6 +183,7 @@ app.MapPost("/auth/telegram-webapp", async (
|
|||||||
app.MapPost("/auth/telegram-login", async (
|
app.MapPost("/auth/telegram-login", async (
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
TelegramAuthService authService,
|
TelegramAuthService authService,
|
||||||
|
ISessionStore sessionStore,
|
||||||
TelegramLoginPayload request) =>
|
TelegramLoginPayload request) =>
|
||||||
{
|
{
|
||||||
if (!authService.VerifyLoginPayload(request, out var telegramId, out var name))
|
if (!authService.VerifyLoginPayload(request, out var telegramId, out var name))
|
||||||
@@ -166,6 +191,8 @@ app.MapPost("/auth/telegram-login", async (
|
|||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await sessionStore.UpsertPlayerAsync("Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), name, null);
|
||||||
|
|
||||||
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
||||||
await context.SignInAsync(
|
await context.SignInAsync(
|
||||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||||
@@ -185,16 +212,9 @@ app.MapPost("/auth/logout", async (HttpContext context) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Discord OAuth endpoints
|
// Discord OAuth endpoints
|
||||||
app.MapGet("/auth/discord", (HttpContext context, DiscordAuthService discordAuth) =>
|
app.MapGet("/auth/discord", (DiscordAuthService discordAuth, DiscordOAuthStateStore stateStore) =>
|
||||||
{
|
{
|
||||||
var state = Guid.NewGuid().ToString("N");
|
var state = stateStore.CreateState();
|
||||||
context.Response.Cookies.Append("__DiscordOAuthState", state, new CookieOptions
|
|
||||||
{
|
|
||||||
HttpOnly = true,
|
|
||||||
Secure = true,
|
|
||||||
SameSite = SameSiteMode.Strict,
|
|
||||||
MaxAge = TimeSpan.FromMinutes(5)
|
|
||||||
});
|
|
||||||
var url = discordAuth.BuildAuthorizeUrl(state);
|
var url = discordAuth.BuildAuthorizeUrl(state);
|
||||||
return Results.Redirect(url);
|
return Results.Redirect(url);
|
||||||
});
|
});
|
||||||
@@ -202,19 +222,15 @@ app.MapGet("/auth/discord", (HttpContext context, DiscordAuthService discordAuth
|
|||||||
app.MapGet("/auth/discord/callback", async (
|
app.MapGet("/auth/discord/callback", async (
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
DiscordAuthService discordAuth,
|
DiscordAuthService discordAuth,
|
||||||
|
DiscordOAuthStateStore stateStore,
|
||||||
ISessionStore sessionStore) =>
|
ISessionStore sessionStore) =>
|
||||||
{
|
{
|
||||||
var code = context.Request.Query["code"].ToString();
|
var code = context.Request.Query["code"].ToString();
|
||||||
var state = context.Request.Query["state"].ToString();
|
var state = context.Request.Query["state"].ToString();
|
||||||
var storedState = context.Request.Cookies["__DiscordOAuthState"];
|
|
||||||
|
|
||||||
context.Response.Cookies.Delete("__DiscordOAuthState");
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(code) ||
|
if (string.IsNullOrWhiteSpace(code) ||
|
||||||
string.IsNullOrWhiteSpace(state) ||
|
string.IsNullOrWhiteSpace(state) ||
|
||||||
!CryptographicOperations.FixedTimeEquals(
|
!stateStore.ValidateAndRemove(state))
|
||||||
System.Text.Encoding.UTF8.GetBytes(state),
|
|
||||||
System.Text.Encoding.UTF8.GetBytes(storedState ?? string.Empty)))
|
|
||||||
{
|
{
|
||||||
return Results.Redirect("/login?error=auth_failed");
|
return Results.Redirect("/login?error=auth_failed");
|
||||||
}
|
}
|
||||||
@@ -225,6 +241,25 @@ app.MapGet("/auth/discord/callback", async (
|
|||||||
|
|
||||||
await sessionStore.UpsertDiscordUserAsync(user.Id, user.DisplayName, user.AvatarUrl);
|
await sessionStore.UpsertDiscordUserAsync(user.Id, user.DisplayName, user.AvatarUrl);
|
||||||
|
|
||||||
|
// If already authenticated via another platform, link instead of replacing session
|
||||||
|
if (context.User.Identity?.IsAuthenticated == true
|
||||||
|
&& context.User.TryGetPlatformIdentity(out var currentPlatform, out var currentExternalUserId)
|
||||||
|
&& currentPlatform != "Discord")
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await sessionStore.LinkIdentityAsync(
|
||||||
|
currentPlatform, currentExternalUserId,
|
||||||
|
"Discord", user.Id,
|
||||||
|
user.DisplayName);
|
||||||
|
return Results.Redirect("/profile?linked=discord");
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return Results.Redirect($"/profile?link_error={Uri.EscapeDataString(ex.Message)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
||||||
await context.SignInAsync(
|
await context.SignInAsync(
|
||||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||||
@@ -234,6 +269,38 @@ app.MapGet("/auth/discord/callback", async (
|
|||||||
return Results.Redirect("/");
|
return Results.Redirect("/");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Identity linking API endpoints
|
||||||
|
app.MapGet("/api/me/identities", async (
|
||||||
|
HttpContext context,
|
||||||
|
ISessionStore sessionStore) =>
|
||||||
|
{
|
||||||
|
if (!context.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
var identities = await sessionStore.GetLinkedIdentitiesAsync(platform, externalUserId);
|
||||||
|
return Results.Ok(identities);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapDelete("/api/me/identities/{targetPlatform}/{targetExternalUserId}", async (
|
||||||
|
HttpContext context,
|
||||||
|
ISessionStore sessionStore,
|
||||||
|
string targetPlatform,
|
||||||
|
string targetExternalUserId) =>
|
||||||
|
{
|
||||||
|
if (!context.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await sessionStore.UnlinkIdentityAsync(platform, externalUserId, targetPlatform, targetExternalUserId);
|
||||||
|
return Results.NoContent();
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// Public calendar subscription endpoint (no auth required)
|
// Public calendar subscription endpoint (no auth required)
|
||||||
app.MapGet("/calendar/{token}.ics", async (
|
app.MapGet("/calendar/{token}.ics", async (
|
||||||
string token,
|
string token,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
namespace GmRelay.Web.Services;
|
namespace GmRelay.Web.Services;
|
||||||
@@ -54,6 +55,117 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
|||||||
return await sessionStore.GetUpcomingSessionsAsync(groupId);
|
return await sessionStore.GetUpcomingSessionsAsync(groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<WebPublicGroupSettings?> GetPublicGroupSettingsForCurrentUserAsync(Guid groupId)
|
||||||
|
{
|
||||||
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return await sessionStore.GetPublicGroupSettingsAsync(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdatePublicGroupSettingsForCurrentUserAsync(
|
||||||
|
Guid groupId,
|
||||||
|
string? publicSlug,
|
||||||
|
bool publicScheduleEnabled)
|
||||||
|
{
|
||||||
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
|
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||||
|
{
|
||||||
|
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedSlug = NormalizePublicSlug(publicSlug);
|
||||||
|
if (publicScheduleEnabled && normalizedSlug is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Для публичной страницы нужен короткий адрес.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await sessionStore.UpdatePublicGroupSettingsAsync(groupId, normalizedSlug, publicScheduleEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MasterProfileSettings?> GetMasterProfileSettingsForCurrentUserAsync()
|
||||||
|
{
|
||||||
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
return Task.FromResult<MasterProfileSettings?>(null);
|
||||||
|
|
||||||
|
return sessionStore.GetMasterProfileSettingsAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateMasterProfileSettingsForCurrentUserAsync(
|
||||||
|
string? publicSlug,
|
||||||
|
bool isPublic,
|
||||||
|
string displayName,
|
||||||
|
string? bio)
|
||||||
|
{
|
||||||
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
|
var normalizedDisplayName = displayName.Trim();
|
||||||
|
if (normalizedDisplayName.Length is < 2 or > 120)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Имя профиля должно быть от 2 до 120 символов.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedBio = string.IsNullOrWhiteSpace(bio) ? null : bio.Trim();
|
||||||
|
if (normalizedBio?.Length > 1200)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Описание профиля должно быть не длиннее 1200 символов.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedSlug = NormalizeMasterProfileSlug(publicSlug);
|
||||||
|
if (isPublic && normalizedSlug is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Для публичного профиля нужен короткий адрес.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await sessionStore.UpdateMasterProfileSettingsAsync(
|
||||||
|
identity.Value.Platform,
|
||||||
|
identity.Value.ExternalUserId,
|
||||||
|
normalizedSlug,
|
||||||
|
isPublic,
|
||||||
|
normalizedDisplayName,
|
||||||
|
normalizedBio);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
|
||||||
|
{
|
||||||
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
|
var session = await GetSessionForCurrentUserAsync(sessionId);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sessionStore.SetSessionPublicAsync(sessionId, session.GroupId, isPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetBatchPublicForCurrentUserAsync(Guid batchId, bool isPublic)
|
||||||
|
{
|
||||||
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
|
var batch = await GetBatchForCurrentUserAsync(batchId);
|
||||||
|
if (batch is null)
|
||||||
|
{
|
||||||
|
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sessionStore.SetBatchPublicAsync(batchId, batch.GroupId, isPublic);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
|
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
|
||||||
{
|
{
|
||||||
var identity = GetCurrentIdentity();
|
var identity = GetCurrentIdentity();
|
||||||
@@ -390,4 +502,22 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
|||||||
JoinLink = joinLink
|
JoinLink = joinLink
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? NormalizePublicSlug(string? publicSlug)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(publicSlug))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var slug = Regex.Replace(publicSlug.Trim().ToLowerInvariant(), @"[\s_]+", "-").Trim('-');
|
||||||
|
if (slug.Length is < 3 or > 80 || !Regex.IsMatch(slug, "^[a-z0-9]+(?:-[a-z0-9]+)*$"))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Короткий адрес может содержать только латинские буквы, цифры и дефисы.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeMasterProfileSlug(string? publicSlug) => NormalizePublicSlug(publicSlug);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
|
|||||||
public string GenerateToken() => Guid.NewGuid().ToString("N");
|
public string GenerateToken() => Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
public async Task<string> CreateSubscriptionAsync(
|
public async Task<string> CreateSubscriptionAsync(
|
||||||
long userTelegramId,
|
string userPlatform,
|
||||||
|
string userExternalId,
|
||||||
Guid? groupId,
|
Guid? groupId,
|
||||||
CalendarSubscriptionFilter filter,
|
CalendarSubscriptionFilter filter,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
@@ -20,9 +21,9 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
|
|||||||
var token = GenerateToken();
|
var token = GenerateToken();
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
|
@"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at)
|
||||||
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
|
VALUES (gen_random_uuid(), @token, @userPlatform, @userExternalId, @groupId, @filterType, now(), NULL)",
|
||||||
new { token, userTelegramId, groupId, filterType = (int)filter });
|
new { token, userPlatform, userExternalId, groupId, filterType = (int)filter });
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
|
|||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
var subscription = await connection.QueryFirstOrDefaultAsync<SubscriptionRecord>(
|
var subscription = await connection.QueryFirstOrDefaultAsync<SubscriptionRecord>(
|
||||||
@"SELECT id, user_telegram_id as UserTelegramId, group_id as GroupId, filter_type as FilterType
|
@"SELECT id, group_id as GroupId, filter_type as FilterType
|
||||||
FROM calendar_subscriptions
|
FROM calendar_subscriptions
|
||||||
WHERE token = @token
|
WHERE token = @token
|
||||||
AND (expires_at IS NULL OR expires_at > now())",
|
AND (expires_at IS NULL OR expires_at > now())",
|
||||||
@@ -88,6 +89,6 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
|
|||||||
.Replace("\n", "\\n")
|
.Replace("\n", "\\n")
|
||||||
.Replace("\r", "");
|
.Replace("\r", "");
|
||||||
|
|
||||||
private sealed record SubscriptionRecord(Guid Id, long UserTelegramId, Guid? GroupId, int FilterType);
|
private sealed record SubscriptionRecord(Guid Id, Guid? GroupId, int FilterType);
|
||||||
private sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
private sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
namespace GmRelay.Web.Services;
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
public sealed class DiscordAuthService(IHttpClientFactory httpClientFactory, IConfiguration configuration)
|
public sealed class DiscordAuthService(IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger<DiscordAuthService> logger)
|
||||||
{
|
{
|
||||||
private readonly DiscordOAuthOptions _options = configuration.GetSection("Discord").Get<DiscordOAuthOptions>() ?? new DiscordOAuthOptions();
|
private readonly DiscordOAuthOptions _options = configuration.GetSection("Discord").Get<DiscordOAuthOptions>() ?? new DiscordOAuthOptions();
|
||||||
|
|
||||||
@@ -40,10 +40,14 @@ public sealed class DiscordAuthService(IHttpClientFactory httpClientFactory, ICo
|
|||||||
});
|
});
|
||||||
|
|
||||||
var response = await client.PostAsync("https://discord.com/api/oauth2/token", content);
|
var response = await client.PostAsync("https://discord.com/api/oauth2/token", content);
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
logger.LogError("Discord token exchange failed: {StatusCode} {Body}. client_id={ClientId}, redirect_uri={RedirectUri}",
|
||||||
|
(int)response.StatusCode, json, _options.ClientId, _options.RedirectUri);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return JsonSerializer.Deserialize<DiscordTokenResponse>(json);
|
return JsonSerializer.Deserialize<DiscordTokenResponse>(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
|
public sealed class DiscordOAuthStateStore(ILogger<DiscordOAuthStateStore> logger)
|
||||||
|
{
|
||||||
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _states = new();
|
||||||
|
|
||||||
|
public string CreateState()
|
||||||
|
{
|
||||||
|
var state = Guid.NewGuid().ToString("N");
|
||||||
|
_states[state] = DateTime.UtcNow.AddMinutes(5);
|
||||||
|
logger.LogDebug("Discord OAuth state created: {State}", state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ValidateAndRemove(string state)
|
||||||
|
{
|
||||||
|
if (!_states.TryRemove(state, out var expiresAt))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Discord OAuth state not found or already used: {State}", state);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.UtcNow > expiresAt)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Discord OAuth state expired: {State}", state);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogDebug("Discord OAuth state validated: {State}", state);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Features.Showcase;
|
||||||
|
|
||||||
namespace GmRelay.Web.Services;
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
@@ -24,10 +25,64 @@ public sealed record SessionAuditLogEntry(
|
|||||||
string? NewValue,
|
string? NewValue,
|
||||||
DateTime ChangedAt);
|
DateTime ChangedAt);
|
||||||
|
|
||||||
|
public sealed record WebPublicGroupSettings(
|
||||||
|
Guid GroupId,
|
||||||
|
string GroupName,
|
||||||
|
string? PublicSlug,
|
||||||
|
bool PublicScheduleEnabled,
|
||||||
|
int PublicSessionCount);
|
||||||
|
|
||||||
|
public sealed record WebPublicSession(
|
||||||
|
Guid Id,
|
||||||
|
Guid GroupId,
|
||||||
|
string GroupName,
|
||||||
|
string? GroupSlug,
|
||||||
|
string Title,
|
||||||
|
DateTime ScheduledAt,
|
||||||
|
string Status,
|
||||||
|
int? MaxPlayers,
|
||||||
|
int ActivePlayerCount,
|
||||||
|
int WaitlistedPlayerCount,
|
||||||
|
string? MasterProfileSlug = null,
|
||||||
|
string? MasterDisplayName = null);
|
||||||
|
|
||||||
|
public sealed record WebPublicClub(
|
||||||
|
Guid GroupId,
|
||||||
|
string Name,
|
||||||
|
string Slug,
|
||||||
|
IReadOnlyList<WebPublicSession> Sessions,
|
||||||
|
string? MasterProfileSlug = null,
|
||||||
|
string? MasterDisplayName = null);
|
||||||
|
|
||||||
|
public sealed record MasterProfileSettings(
|
||||||
|
Guid PlayerId,
|
||||||
|
string DisplayName,
|
||||||
|
string? PublicSlug,
|
||||||
|
bool IsPublic,
|
||||||
|
string? Bio);
|
||||||
|
|
||||||
|
public sealed record PublicMasterClub(
|
||||||
|
Guid GroupId,
|
||||||
|
string Name,
|
||||||
|
string Slug);
|
||||||
|
|
||||||
|
public sealed record PublicMasterProfile(
|
||||||
|
string Slug,
|
||||||
|
string DisplayName,
|
||||||
|
string? Bio,
|
||||||
|
IReadOnlyList<PublicMasterClub> Clubs,
|
||||||
|
IReadOnlyList<WebPublicSession> Sessions);
|
||||||
|
|
||||||
public interface ISessionStore
|
public interface ISessionStore
|
||||||
{
|
{
|
||||||
Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId);
|
Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId);
|
||||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||||
|
Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId);
|
||||||
|
Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled);
|
||||||
|
Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic);
|
||||||
|
Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic);
|
||||||
|
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug);
|
||||||
|
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId);
|
||||||
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
|
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
|
||||||
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
|
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
|
||||||
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
||||||
@@ -53,4 +108,27 @@ public interface ISessionStore
|
|||||||
Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue);
|
Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue);
|
||||||
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
|
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
|
||||||
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
|
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
|
||||||
|
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
|
||||||
|
Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio);
|
||||||
|
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug);
|
||||||
|
|
||||||
|
// --- Identity linking (issue #35) ---
|
||||||
|
Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId);
|
||||||
|
Task<List<LinkedIdentity>> GetLinkedIdentitiesAsync(string platform, string externalUserId);
|
||||||
|
Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName);
|
||||||
|
Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId);
|
||||||
|
Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl);
|
||||||
|
|
||||||
|
// --- Showcase / game catalog (issue #39) ---
|
||||||
|
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize);
|
||||||
|
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
|
||||||
|
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record LinkedIdentity(
|
||||||
|
string Platform,
|
||||||
|
string ExternalUserId,
|
||||||
|
string DisplayName,
|
||||||
|
string? ExternalUsername,
|
||||||
|
string? AvatarUrl,
|
||||||
|
DateTime LinkedAt);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -785,6 +785,114 @@ select option {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-publish-row,
|
||||||
|
.public-settings-actions,
|
||||||
|
.public-link-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-publish-row {
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-settings-panel {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-toggle-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-checkbox-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-checkbox-label input {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
accent-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-settings-actions {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-link-row {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-link-row a {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-profile-bio {
|
||||||
|
min-height: 7rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-profile-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-profile-section h2 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-profile-club-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-profile-club-list a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-master-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'Jura', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-master-link a {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Campaign templates === */
|
/* === Campaign templates === */
|
||||||
.campaign-template-panel {
|
.campaign-template-panel {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
@@ -1620,6 +1728,169 @@ body.telegram-mini-app .session-card-mobile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Public pages === */
|
||||||
|
.public-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: rgba(5, 8, 16, 0.82);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-brand img {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-content {
|
||||||
|
width: min(960px, calc(100% - 2rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 0 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-hero {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 2rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-hero-compact {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-hero h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 0.75rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-hero p {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-session-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-session-card,
|
||||||
|
.public-session-detail {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-session-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-session-main h2 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin: 0.625rem 0 0.375rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-session-meta,
|
||||||
|
.public-detail-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem 1.25rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-detail-grid div {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-detail-grid span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-detail-grid strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-description {
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-description h3 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-description p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-empty-state h2 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.public-topbar {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-session-card,
|
||||||
|
.public-detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-session-card .btn-gm {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* === Discord Login Button === */
|
/* === Discord Login Button === */
|
||||||
.login-btn-discord {
|
.login-btn-discord {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -33,7 +33,17 @@ public sealed class DiscordListSessionsHandlerTests
|
|||||||
|
|
||||||
Assert.Contains("platform = 'Discord'", handler, StringComparison.Ordinal);
|
Assert.Contains("platform = 'Discord'", handler, StringComparison.Ordinal);
|
||||||
Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal);
|
Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal);
|
||||||
Assert.Contains("scheduled_at > NOW()", handler, StringComparison.Ordinal);
|
Assert.Contains("scheduled_at > now() - interval '4 hours'", handler, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Handler_ShouldIncludeRecentlyStartedSessionsForCleanup()
|
||||||
|
{
|
||||||
|
var repoRoot = GetRepoRoot();
|
||||||
|
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs");
|
||||||
|
var handler = File.ReadAllText(handlerPath);
|
||||||
|
|
||||||
|
Assert.Contains("now() - interval '4 hours'", handler, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -47,6 +57,17 @@ public sealed class DiscordListSessionsHandlerTests
|
|||||||
Assert.DoesNotContain("telegram_id", handler, StringComparison.Ordinal);
|
Assert.DoesNotContain("telegram_id", handler, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Handler_ShouldExposeDeleteActionForManagers()
|
||||||
|
{
|
||||||
|
var repoRoot = GetRepoRoot();
|
||||||
|
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs");
|
||||||
|
var handler = File.ReadAllText(handlerPath);
|
||||||
|
|
||||||
|
Assert.Contains("delete_session", handler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("CanManageSchedule", handler, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Command_ShouldExist()
|
public void Command_ShouldExist()
|
||||||
{
|
{
|
||||||
@@ -66,4 +87,18 @@ public sealed class DiscordListSessionsHandlerTests
|
|||||||
Assert.Contains("SlashCommand", command, StringComparison.Ordinal);
|
Assert.Contains("SlashCommand", command, StringComparison.Ordinal);
|
||||||
Assert.Contains("listsessions", command, StringComparison.Ordinal);
|
Assert.Contains("listsessions", command, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteHandler_ShouldDeleteOnlySessionsFromTheInteractionGuild()
|
||||||
|
{
|
||||||
|
var repoRoot = GetRepoRoot();
|
||||||
|
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordDeleteSessionHandler.cs");
|
||||||
|
|
||||||
|
Assert.True(File.Exists(handlerPath), "DiscordDeleteSessionHandler should exist.");
|
||||||
|
|
||||||
|
var handler = File.ReadAllText(handlerPath);
|
||||||
|
Assert.Contains("DELETE FROM sessions", handler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("CanManageSchedule", handler, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ public sealed class DiscordNewSessionHandlerTests
|
|||||||
|
|
||||||
// --- Runtime tests for ParseTimeInput (static, no DB) ---
|
// --- Runtime tests for ParseTimeInput (static, no DB) ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseTimeInput_ShouldTreatInputAsMoscowTime()
|
||||||
|
{
|
||||||
|
var result = DiscordNewSessionHandler.ParseTimeInput("2026-06-01 15:00");
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
// 15:00 MSK = 12:00 UTC
|
||||||
|
Assert.Equal(12, result.Value.Hour);
|
||||||
|
Assert.Equal(0, result.Value.Minute);
|
||||||
|
Assert.Equal(TimeSpan.Zero, result.Value.Offset);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseTimeInput_ShouldParseDiscordDateFormat()
|
public void ParseTimeInput_ShouldParseDiscordDateFormat()
|
||||||
{
|
{
|
||||||
@@ -28,7 +39,8 @@ public sealed class DiscordNewSessionHandlerTests
|
|||||||
Assert.Equal(expected.Year, result.Value.Year);
|
Assert.Equal(expected.Year, result.Value.Year);
|
||||||
Assert.Equal(expected.Month, result.Value.Month);
|
Assert.Equal(expected.Month, result.Value.Month);
|
||||||
Assert.Equal(expected.Day, result.Value.Day);
|
Assert.Equal(expected.Day, result.Value.Day);
|
||||||
Assert.Equal(19, result.Value.Hour);
|
// Input is treated as Moscow time; 19:30 MSK = 16:30 UTC
|
||||||
|
Assert.Equal(16, result.Value.Hour);
|
||||||
Assert.Equal(30, result.Value.Minute);
|
Assert.Equal(30, result.Value.Minute);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +115,18 @@ public sealed class DiscordNewSessionHandlerTests
|
|||||||
Assert.Contains("UnauthorizedAccessException", source, StringComparison.Ordinal);
|
Assert.Contains("UnauthorizedAccessException", source, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Handler_ShouldLoadCoGmPermissionsFromDiscordPlayers()
|
||||||
|
{
|
||||||
|
var repoRoot = GetRepoRoot();
|
||||||
|
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||||
|
var source = File.ReadAllText(handlerPath);
|
||||||
|
|
||||||
|
Assert.Matches(
|
||||||
|
@"QueryAsync<ulong>[\s\S]*JOIN players p ON p\.id = gm\.player_id[\s\S]*p\.platform = 'Discord'[\s\S]*g\.external_group_id = @GuildId",
|
||||||
|
source);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Handler_ShouldBePlatformNeutral()
|
public void Handler_ShouldBePlatformNeutral()
|
||||||
{
|
{
|
||||||
@@ -127,6 +151,18 @@ public sealed class DiscordNewSessionHandlerTests
|
|||||||
Assert.Contains("RollbackAsync", source, StringComparison.Ordinal);
|
Assert.Contains("RollbackAsync", source, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Handler_ShouldNotRollbackCommittedTransactionAfterPostCommitFailure()
|
||||||
|
{
|
||||||
|
var repoRoot = GetRepoRoot();
|
||||||
|
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||||
|
var source = File.ReadAllText(handlerPath);
|
||||||
|
|
||||||
|
Assert.Contains("transactionCommitted = false", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("transactionCommitted = true", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("if (!transactionCommitted)", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Handler_ShouldRespectCancellationToken()
|
public void Handler_ShouldRespectCancellationToken()
|
||||||
{
|
{
|
||||||
@@ -145,7 +181,30 @@ public sealed class DiscordNewSessionHandlerTests
|
|||||||
var source = File.ReadAllText(commandPath);
|
var source = File.ReadAllText(commandPath);
|
||||||
|
|
||||||
Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal);
|
Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal);
|
||||||
Assert.Contains("WithEmbeds", source, StringComparison.Ordinal);
|
Assert.Contains("message.Embeds = embeds", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Handler_ShouldLeaveScheduleMessageCreationToInteractionResponse()
|
||||||
|
{
|
||||||
|
var repoRoot = GetRepoRoot();
|
||||||
|
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||||
|
var source = File.ReadAllText(handlerPath);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("SendScheduleAsync", source, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("PlatformScheduleMessage", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Handler_ShouldStoreReadableDiscordGroupNameForWebCards()
|
||||||
|
{
|
||||||
|
var repoRoot = GetRepoRoot();
|
||||||
|
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||||
|
var source = File.ReadAllText(handlerPath);
|
||||||
|
|
||||||
|
Assert.Contains("groupName", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("displayGroupName", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("VALUES (@GroupName, 'Discord'", source, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DateTimeOffset FutureDateAt1930()
|
private static DateTimeOffset FutureDateAt1930()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Discord;
|
namespace GmRelay.Bot.Tests.Discord;
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ public sealed class DiscordProjectStructureTests
|
|||||||
Assert.Contains("GmRelay.Shared.csproj", project);
|
Assert.Contains("GmRelay.Shared.csproj", project);
|
||||||
Assert.DoesNotContain("Telegram.Bot", project);
|
Assert.DoesNotContain("Telegram.Bot", project);
|
||||||
Assert.DoesNotContain("GmRelay.Bot.csproj", project);
|
Assert.DoesNotContain("GmRelay.Bot.csproj", project);
|
||||||
|
Assert.Contains("Dapper.AOT", project);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -60,8 +62,9 @@ public sealed class DiscordProjectStructureTests
|
|||||||
var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs"));
|
var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs"));
|
||||||
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
||||||
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
||||||
|
var version = GetProjectVersion(repoRoot);
|
||||||
|
|
||||||
Assert.Contains("gmrelay-discord-bot:2.8.0", compose);
|
Assert.Contains($"gmrelay-discord-bot:{version}", compose);
|
||||||
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
||||||
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
||||||
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
||||||
@@ -74,14 +77,15 @@ public sealed class DiscordProjectStructureTests
|
|||||||
public void Version_ShouldBeSynchronizedForDiscordFeatureRelease()
|
public void Version_ShouldBeSynchronizedForDiscordFeatureRelease()
|
||||||
{
|
{
|
||||||
var repoRoot = GetRepoRoot();
|
var repoRoot = GetRepoRoot();
|
||||||
|
var version = GetProjectVersion(repoRoot);
|
||||||
|
|
||||||
Assert.Contains("<Version>2.8.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
Assert.Contains($"<Version>{version}</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||||
Assert.Contains("VERSION: 2.8.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
Assert.Contains($"VERSION: {version}", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||||
Assert.Contains("gmrelay-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
Assert.Contains($"gmrelay-bot:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||||
Assert.Contains("gmrelay-web:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
Assert.Contains($"gmrelay-web:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||||
Assert.Contains("gmrelay-discord-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
Assert.Contains($"gmrelay-discord-bot:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
"v2.8.0",
|
$"v{version}",
|
||||||
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +98,16 @@ public sealed class DiscordProjectStructureTests
|
|||||||
Assert.Contains("DISCORD_BOT_TOKEN", envExample);
|
Assert.Contains("DISCORD_BOT_TOKEN", envExample);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Readme_ShouldNotAskForUnusedDiscordBotClientId()
|
||||||
|
{
|
||||||
|
var repoRoot = GetRepoRoot();
|
||||||
|
var readme = File.ReadAllText(Path.Combine(repoRoot, "README.md"));
|
||||||
|
|
||||||
|
Assert.DoesNotContain("DISCORD_BOT_CLIENT_ID", readme);
|
||||||
|
Assert.Contains("DISCORD_CLIENT_ID", readme);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Compose_ShouldIncludeDiscordHealthcheck()
|
public void Compose_ShouldIncludeDiscordHealthcheck()
|
||||||
{
|
{
|
||||||
@@ -110,4 +124,13 @@ public sealed class DiscordProjectStructureTests
|
|||||||
Assert.Contains("test:", discordBlock);
|
Assert.Contains("test:", discordBlock);
|
||||||
Assert.Contains("localhost:8082/health", discordBlock);
|
Assert.Contains("localhost:8082/health", discordBlock);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetProjectVersion(string repoRoot)
|
||||||
|
{
|
||||||
|
var props = XDocument.Load(Path.Combine(repoRoot, "Directory.Build.props"));
|
||||||
|
return props.Root?
|
||||||
|
.Element("PropertyGroup")?
|
||||||
|
.Element("Version")?
|
||||||
|
.Value ?? throw new InvalidOperationException("Version not found.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,26 @@ public sealed class DiscordSessionInteractionModuleSourceTests
|
|||||||
Assert.Contains("MessageFlags.Ephemeral", source, StringComparison.Ordinal);
|
Assert.Contains("MessageFlags.Ephemeral", source, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Module_ShouldUpdateSourceScheduleMessageThroughComponentInteraction()
|
||||||
|
{
|
||||||
|
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
|
||||||
|
|
||||||
|
Assert.Contains("InteractionCallback.DeferredModifyMessage", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("FollowupAsync", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("CompleteScheduleUpdateResponseAsync", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Module_ShouldRouteDeleteSessionButtons()
|
||||||
|
{
|
||||||
|
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
|
||||||
|
|
||||||
|
Assert.Contains("[ComponentInteraction(\"delete_session\")]", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("DiscordDeleteSessionHandler", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Module_ShouldRouteRsvpButtonsToNeutralHandler()
|
public async Task Module_ShouldRouteRsvpButtonsToNeutralHandler()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
using GmRelay.DiscordBot.Features.Sessions;
|
||||||
|
using NetCord.Services.ApplicationCommands;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Discord;
|
namespace GmRelay.Bot.Tests.Discord;
|
||||||
|
|
||||||
@@ -47,6 +50,41 @@ public sealed class DiscordStartupTests
|
|||||||
Assert.Contains(".AddComponentInteractions", program);
|
Assert.Contains(".AddComponentInteractions", program);
|
||||||
Assert.Contains(".AddGatewayHandlers", program);
|
Assert.Contains(".AddGatewayHandlers", program);
|
||||||
Assert.Contains("AddSlashCommand", program);
|
Assert.Contains("AddSlashCommand", program);
|
||||||
|
Assert.Contains("AddModules(typeof(Program).Assembly)", program);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(typeof(DiscordNewSessionCommand), "newsession")]
|
||||||
|
[InlineData(typeof(DiscordListSessionsCommand), "listsessions")]
|
||||||
|
[InlineData(typeof(DiscordRescheduleCommand), "reschedule")]
|
||||||
|
public void DiscordSessionSlashCommands_ShouldBeDeclaredOnModuleMethods(Type moduleType, string commandName)
|
||||||
|
{
|
||||||
|
var executeMethod = moduleType.GetMethod("ExecuteAsync", BindingFlags.Instance | BindingFlags.Public);
|
||||||
|
|
||||||
|
Assert.NotNull(executeMethod);
|
||||||
|
|
||||||
|
var methodAttribute = Assert.Single(executeMethod.GetCustomAttributes<SlashCommandAttribute>(inherit: false));
|
||||||
|
var nameProperty = typeof(SlashCommandAttribute).GetProperty("Name")
|
||||||
|
?? throw new InvalidOperationException("SlashCommandAttribute should expose command name.");
|
||||||
|
|
||||||
|
Assert.Equal(commandName, nameProperty.GetValue(methodAttribute));
|
||||||
|
Assert.Empty(moduleType.GetCustomAttributes<SlashCommandAttribute>(inherit: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DiscordSessionSlashCommands_ShouldBeDiscoverableByNetCordService()
|
||||||
|
{
|
||||||
|
var service = new ApplicationCommandService<SlashCommandContext>();
|
||||||
|
|
||||||
|
service.AddModules(typeof(DiscordNewSessionCommand).Assembly);
|
||||||
|
|
||||||
|
var commandNames = service.GetCommands()
|
||||||
|
.Select(command => command.Name)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.Contains("newsession", commandNames);
|
||||||
|
Assert.Contains("listsessions", commandNames);
|
||||||
|
Assert.Contains("reschedule", commandNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Domain;
|
||||||
|
|
||||||
|
public sealed class GameSystemTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Dnd5e", GameSystem.Dnd5e)]
|
||||||
|
[InlineData("D&D", GameSystem.Dnd5e)]
|
||||||
|
[InlineData("dnd5e", GameSystem.Dnd5e)]
|
||||||
|
[InlineData(" dnd5e ", GameSystem.Dnd5e)]
|
||||||
|
[InlineData("D&D 5e", GameSystem.Dnd5e)]
|
||||||
|
[InlineData("pathfinder", GameSystem.Pathfinder2e)]
|
||||||
|
[InlineData("call of cthulhu", GameSystem.CallOfCthulhu7e)]
|
||||||
|
[InlineData("shadow", GameSystem.Shadowdark)]
|
||||||
|
[InlineData("dark", GameSystem.Shadowdark)]
|
||||||
|
[InlineData("unknown xyz", GameSystem.Other)]
|
||||||
|
public void TryParseFuzzy_ShouldMapInputToExpectedSystem(string input, GameSystem expected)
|
||||||
|
{
|
||||||
|
var result = GameSystemExtensions.TryParseFuzzy(input);
|
||||||
|
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("днд")]
|
||||||
|
[InlineData("колова")]
|
||||||
|
public void TryParseFuzzy_ShouldReturnOtherForUnmatchedCyrillicInput(string input)
|
||||||
|
{
|
||||||
|
var result = GameSystemExtensions.TryParseFuzzy(input);
|
||||||
|
|
||||||
|
Assert.Equal(GameSystem.Other, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParseFuzzy_ShouldReturnNullForNullInput()
|
||||||
|
{
|
||||||
|
var result = GameSystemExtensions.TryParseFuzzy(null!);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void TryParseFuzzy_ShouldReturnNullForEmptyOrWhitespaceInput(string input)
|
||||||
|
{
|
||||||
|
var result = GameSystemExtensions.TryParseFuzzy(input);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(GameSystem.Dnd5e, "D&D 5e")]
|
||||||
|
[InlineData(GameSystem.Other, "Другое")]
|
||||||
|
[InlineData(GameSystem.Pathfinder2e, "Pathfinder 2e")]
|
||||||
|
[InlineData(GameSystem.Shadowdark, "Shadowdark")]
|
||||||
|
[InlineData((GameSystem)999, "Другое")]
|
||||||
|
public void ToDisplayName_ShouldReturnExpectedName(GameSystem system, string expected)
|
||||||
|
{
|
||||||
|
var result = system.ToDisplayName();
|
||||||
|
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
|
||||||
using GmRelay.DiscordBot.Rendering;
|
using GmRelay.DiscordBot.Rendering;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
@@ -69,14 +69,14 @@ public sealed class DiscordLandingPromisesSmokeTests
|
|||||||
};
|
};
|
||||||
var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero);
|
var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero);
|
||||||
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
|
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
|
||||||
var voteMessage = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
var voteMessage = BotRescheduleHandler.BuildVotingMessage(
|
||||||
scenario.Title,
|
scenario.Title,
|
||||||
scenario.Sessions[0].ScheduledAt,
|
scenario.Sessions[0].ScheduledAt,
|
||||||
deadline,
|
deadline,
|
||||||
options,
|
options,
|
||||||
voteParticipants,
|
voteParticipants,
|
||||||
[]);
|
[]);
|
||||||
var voteKeyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
|
var voteKeyboard = BotRescheduleHandler.BuildVotingKeyboard(options);
|
||||||
|
|
||||||
Assert.Contains("Landing Promise Smoke", voteMessage);
|
Assert.Contains("Landing Promise Smoke", voteMessage);
|
||||||
Assert.Contains("0/2", voteMessage);
|
Assert.Contains("0/2", voteMessage);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
@@ -65,14 +65,14 @@ public sealed class TelegramLandingPromisesSmokeTests
|
|||||||
};
|
};
|
||||||
var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero);
|
var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero);
|
||||||
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
|
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
|
||||||
var voteMessage = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
var voteMessage = BotRescheduleHandler.BuildVotingMessage(
|
||||||
scenario.Title,
|
scenario.Title,
|
||||||
scenario.Sessions[0].ScheduledAt,
|
scenario.Sessions[0].ScheduledAt,
|
||||||
deadline,
|
deadline,
|
||||||
options,
|
options,
|
||||||
voteParticipants,
|
voteParticipants,
|
||||||
[]);
|
[]);
|
||||||
var voteKeyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
|
var voteKeyboard = BotRescheduleHandler.BuildVotingKeyboard(options);
|
||||||
|
|
||||||
Assert.Contains("Landing Promise Smoke", voteMessage);
|
Assert.Contains("Landing Promise Smoke", voteMessage);
|
||||||
Assert.Contains("0/2", voteMessage);
|
Assert.Contains("0/2", voteMessage);
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed class CreateSessionCommandContractTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CreateSessionCommand_ShouldExposePlatformNeutralContext()
|
||||||
|
{
|
||||||
|
AssertProperty<CreateSessionCommand>("User", typeof(PlatformUser));
|
||||||
|
AssertProperty<CreateSessionCommand>("Group", typeof(PlatformGroup));
|
||||||
|
AssertProperty<CreateSessionCommand>("Title", typeof(string));
|
||||||
|
AssertProperty<CreateSessionCommand>("Link", typeof(string));
|
||||||
|
AssertProperty<CreateSessionCommand>("ScheduledTimes", typeof(IReadOnlyList<DateTimeOffset>));
|
||||||
|
AssertProperty<CreateSessionCommand>("MaxPlayers", typeof(int?));
|
||||||
|
AssertProperty<CreateSessionCommand>("ImageReference", typeof(string));
|
||||||
|
AssertNoTelegramSpecificProperties<CreateSessionCommand>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertProperty<T>(string name, Type expectedType)
|
||||||
|
{
|
||||||
|
var property = Assert.Single(typeof(T).GetProperties(), p => p.Name == name);
|
||||||
|
Assert.Equal(expectedType, property.PropertyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertNoTelegramSpecificProperties<T>()
|
||||||
|
{
|
||||||
|
var names = typeof(T).GetProperties().Select(p => p.Name).ToArray();
|
||||||
|
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
|
||||||
|
Assert.DoesNotContain("ChatId", names);
|
||||||
|
Assert.DoesNotContain("MessageId", names);
|
||||||
|
Assert.DoesNotContain("TelegramUserId", names);
|
||||||
|
Assert.DoesNotContain("TelegramUsername", names);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed class CreateSessionHandlerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task SharedHandler_ShouldExist_AndBePlatformNeutral()
|
||||||
|
{
|
||||||
|
var handler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs");
|
||||||
|
|
||||||
|
Assert.Contains("CreateSessionCommand", handler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("CreateSessionResult", handler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("command.User", handler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("command.Group", handler, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("ITelegramBotClient", handler, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("Telegram.Bot", handler, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("InlineKeyboardMarkup", handler, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("MessageThreadId", handler, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
{
|
||||||
|
return await File.ReadAllTextAsync(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-1
@@ -13,6 +13,7 @@ public sealed class PlatformNeutralSessionInteractionCommandTests
|
|||||||
AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
|
AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
|
||||||
AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
|
AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
|
||||||
AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
||||||
|
AssertProperty<JoinSessionCommand>("DeferScheduleUpdate", typeof(bool));
|
||||||
AssertNoTelegramSpecificProperties<JoinSessionCommand>();
|
AssertNoTelegramSpecificProperties<JoinSessionCommand>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,12 +25,29 @@ public sealed class PlatformNeutralSessionInteractionCommandTests
|
|||||||
AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
|
AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
|
||||||
AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
|
AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
|
||||||
AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
||||||
|
AssertProperty<LeaveSessionCommand>("DeferScheduleUpdate", typeof(bool));
|
||||||
AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
|
AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SessionInteractionResult_ShouldExposeReplyTextAndUpdatedView()
|
||||||
|
{
|
||||||
|
var resultType = typeof(JoinSessionCommand).Assembly.GetType(
|
||||||
|
"GmRelay.Shared.Features.Sessions.CreateSession.SessionInteractionResult");
|
||||||
|
|
||||||
|
Assert.NotNull(resultType);
|
||||||
|
AssertProperty(resultType, "ReplyText", typeof(string));
|
||||||
|
AssertProperty(resultType, "UpdatedView", typeof(GmRelay.Shared.Rendering.SessionBatchViewModel));
|
||||||
|
}
|
||||||
|
|
||||||
private static void AssertProperty<T>(string name, Type expectedType)
|
private static void AssertProperty<T>(string name, Type expectedType)
|
||||||
{
|
{
|
||||||
var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name);
|
AssertProperty(typeof(T), name, expectedType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertProperty(Type type, string name, Type expectedType)
|
||||||
|
{
|
||||||
|
var property = Assert.Single(type.GetProperties(), property => property.Name == name);
|
||||||
|
|
||||||
Assert.Equal(expectedType, property.PropertyType);
|
Assert.Equal(expectedType, property.PropertyType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,30 @@ public sealed class SessionCapacityRulesTests
|
|||||||
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status);
|
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DecideJoinStatus_ShouldReturnActive_WhenUnlimitedSeats()
|
||||||
|
{
|
||||||
|
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: null, activeParticipants: 5);
|
||||||
|
|
||||||
|
Assert.Equal(ParticipantRegistrationStatus.Active, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DecideJoinStatus_ShouldReturnWaitlisted_WhenOverCapacity()
|
||||||
|
{
|
||||||
|
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 3, activeParticipants: 5);
|
||||||
|
|
||||||
|
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DecideJoinStatus_ShouldReturnActive_WhenZeroActiveAndPositiveMax()
|
||||||
|
{
|
||||||
|
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 1, activeParticipants: 0);
|
||||||
|
|
||||||
|
Assert.Equal(ParticipantRegistrationStatus.Active, status);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CanPromoteWaitlistedPlayer_ShouldRequireWaitlistAndFreeSeat()
|
public void CanPromoteWaitlistedPlayer_ShouldRequireWaitlistAndFreeSeat()
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user