4 Commits

Author SHA1 Message Date
Toutsu 41f2ea6e90 feat: add telegram mini app dashboard
Deploy Telegram Bot / build-and-push (push) Successful in 23s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-04-28 14:56:55 +03:00
Toutsu 5082dd4fcf fix: stack sidebar template nav item
Deploy Telegram Bot / build-and-push (push) Successful in 3m45s
Deploy Telegram Bot / deploy (push) Successful in 9s
2026-04-28 10:36:52 +03:00
Toutsu cfbda4ca05 fix: move campaign templates to dedicated tab
Deploy Telegram Bot / build-and-push (push) Successful in 3m28s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-04-28 10:22:12 +03:00
Toutsu 0218890a7a feat: add campaign templates and recurring schedules
Deploy Telegram Bot / build-and-push (push) Successful in 3m49s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-04-28 10:01:18 +03:00
35 changed files with 2138 additions and 41 deletions
+4
View File
@@ -6,6 +6,10 @@ TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN_HERE
# Найти его можно в информации о боте у @BotFather.
TELEGRAM_BOT_USERNAME=YOUR_BOT_USERNAME_HERE
# HTTPS URL Mini App dashboard, например: https://your-domain.example/miniapp
# Используется ботом для кнопки меню Telegram и кнопки /start.
TELEGRAM_MINI_APP_URL=
# Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=StrongPasswordForDatabase
+2 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 1.7.0
VERSION: 1.9.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -64,6 +64,7 @@ jobs:
echo "TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}" > .env
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env
echo "TELEGRAM_MINI_APP_URL=${{ secrets.TELEGRAM_MINI_APP_URL }}" >> .env
- name: Deploy Containers
run: |
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>1.7.0</Version>
<Version>1.9.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+35 -4
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v1.7.0`.
**Текущая версия:** `v1.9.0`.
---
@@ -12,6 +12,7 @@
### 🤖 Telegram Бот
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
@@ -23,8 +24,10 @@
### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
- **📱 Telegram Mini App Dashboard**: Мобильная версия dashboard открывается прямо из Telegram, проверяет WebApp `initData` на сервере и использует те же права owner/co-GM, что и обычный Web Dashboard.
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
- **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard.
- **📋 Шаблоны кампаний**: Owner и co-GM управляют типовыми параметрами кампаний в отдельной вкладке `Шаблоны`, а на странице группы запускают новый повторяющийся batch из выбранного шаблона.
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
@@ -72,6 +75,10 @@ TELEGRAM_BOT_TOKEN=ваш_токен_здесь
# Используется для работы виджета авторизации (Telegram Login Widget).
TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
# HTTPS URL Mini App dashboard, например: https://your-domain.example/miniapp.
# Используется кнопкой меню Telegram и кнопкой /start.
TELEGRAM_MINI_APP_URL=https://your-domain.example/miniapp
# Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=ваш_надежный_пароль
@@ -81,6 +88,8 @@ GMRELAY_WEB_PORT=8080
*(Опционально)* Настройте домен Telegram бота в @BotFather командой `/setdomain` для работы виджета авторизации на вашем сайте.
Для Telegram Mini App настройте в @BotFather домен Web Dashboard и menu button на URL из `TELEGRAM_MINI_APP_URL`. Бот также показывает кнопку `Открыть dashboard` в ответе на `/start`, если переменная задана.
### 3. Запуск
Выполните команду:
```bash
@@ -125,6 +134,20 @@ docker compose up -d
Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard.
Для регулярной кампании можно не перечислять все даты вручную. Укажите одну строку `Время:`, количество игр и интервал в днях:
```text
/newsession
Название: Kingmaker
Время: 30.04.2026 19:30
Игр: 6
Интервал: 7
Мест: 5
Ссылка: https://discord.gg/invite-link
```
Бот создаст 6 игр с недельным шагом. Вместо `Игр:` также принимается `Сессий:` или `Повторов:`, вместо `Интервал:``Шаг:`.
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
### Делегирование управления
@@ -141,17 +164,25 @@ Owner или co-GM нажимает кнопку `⏰ Перенести` у н
Дедлайн должен быть в будущем и раньше первого предложенного времени. Участники выбирают один вариант кнопкой в Telegram, могут изменить голос до дедлайна и видят текущие результаты в сообщении голосования. По дедлайну бот выбирает вариант с наибольшим числом голосов, переносит сессию, сбрасывает RSVP и обновляет batch-сообщение. Если голосов нет или есть ничья, перенос отклоняется, а время сессии остаётся прежним.
### Bulk-операции в Web Dashboard
На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. Owner и co-GM могут:
### Шаблоны и bulk-операции в Web Dashboard
Вкладка `Шаблоны` в левом меню вынесена отдельно от страницы группы. Owner и co-GM выбирают группу, сохраняют шаблон кампании с названием, ссылкой, количеством игр, интервалом, лимитом мест и режимом уведомлений, а также удаляют устаревшие шаблоны.
На странице группы Web Dashboard показывает только применение сохранённых шаблонов и отдельный блок для каждой пачки игр. Owner и co-GM могут:
- создать новый batch из шаблона, выбрав только первую дату расписания;
- обновить общий `title` и `link` сразу у всех сессий batch;
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
- клонировать batch на следующую неделю или следующий календарный месяц.
После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается. При клонировании создаётся новая пачка с новым Telegram-сообщением и пустым составом игроков.
После создания из шаблона или клонирования появляется новая пачка с новым Telegram-сообщением и пустым составом игроков. После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается.
Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам.
### Telegram Mini App Dashboard
Owner и co-GM могут открыть мобильный dashboard прямо из Telegram через кнопку меню бота или кнопку `Открыть dashboard` после `/start`. Страница `/miniapp` получает `Telegram.WebApp.initData`, отправляет его на серверный endpoint `/auth/telegram-webapp`, проходит HMAC-проверку токеном бота и выдаёт обычную cookie-сессию dashboard.
После входа Mini App использует те же страницы, что и Web Dashboard: список групп, карточки сессий, редактирование игры, повышение игрока из листа ожидания, применение шаблонов и bulk-операции batch. Доступ к чужим группам не появляется: все данные по-прежнему фильтруются через `AuthorizedSessionService` по роли owner/co-GM.
### Другие команды
- `/listsessions` — Показать список всех актуальных игр в этой группе.
- `⏰ Перенести` в сообщении расписания — Запустить голосование по 2-3 вариантам нового времени.
+4 -2
View File
@@ -17,7 +17,7 @@ services:
retries: 10
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.7.0
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.0
restart: always
depends_on:
db:
@@ -25,11 +25,12 @@ services:
environment:
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
networks:
- gmrelay
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.7.0
image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.0
restart: always
depends_on:
db:
@@ -38,6 +39,7 @@ services:
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}"
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
ports:
- "${GMRELAY_WEB_PORT:-8080}:8080"
volumes:
@@ -0,0 +1,69 @@
# Telegram Mini App Dashboard Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a Telegram Mini App mobile dashboard that reuses the existing Web Dashboard and validates Telegram WebApp `initData` on the server.
**Architecture:** Extend `TelegramAuthService` for WebApp init data, add a `/miniapp` Blazor entry page plus `/auth/telegram-webapp` endpoint, and add bot entry points through an inline WebApp button and optional menu button setup. Existing application/domain services remain the only write path.
**Tech Stack:** .NET 10, Blazor Server, Telegram.Bot, xUnit, Dapper/Npgsql-backed existing services.
---
### Task 1: Telegram WebApp Authentication
**Files:**
- Modify: `src/GmRelay.Web/Services/TelegramAuthService.cs`
- Modify: `src/GmRelay.Web/Program.cs`
- Test: `tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs`
- [ ] Write failing tests for valid WebApp `initData`, tampered hash, and expired auth date.
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramAuthServiceTests`.
- [ ] Implement WebApp HMAC verification using the Telegram `WebAppData` secret derivation.
- [ ] Add `/auth/telegram-webapp` endpoint that signs in using the same claims as `/auth/telegram`.
- [ ] Re-run the filtered tests.
### Task 2: Mini App Entry Page
**Files:**
- Create: `src/GmRelay.Web/Components/Pages/MiniApp.razor`
- Modify: `src/GmRelay.Web/Components/App.razor`
- Modify: `src/GmRelay.Web/wwwroot/app.css`
- Test: `tests/GmRelay.Bot.Tests/Web/MiniAppDashboardTests.cs`
- [ ] Write failing tests that assert `/miniapp`, `telegram-web-app.js`, `authenticateTelegramMiniApp`, and Mini App CSS hooks exist.
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter MiniAppDashboardTests`.
- [ ] Implement `/miniapp` to post `Telegram.WebApp.initData` to `/auth/telegram-webapp`, expand/ready the Mini App, and show fallback login when opened outside Telegram.
- [ ] Add CSS for a mobile-first Mini App shell and compact dashboard spacing.
- [ ] Re-run the filtered tests.
### Task 3: Bot Entry Points
**Files:**
- Create: `src/GmRelay.Bot/Infrastructure/Telegram/TelegramMiniAppMenuButtonService.cs`
- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs`
- Modify: `src/GmRelay.Bot/Program.cs`
- Test: `tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramMiniAppEntryPointTests.cs`
- [ ] Write failing tests that assert `/start` exposes a WebApp button and startup registers the menu button service.
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter TelegramMiniAppEntryPointTests`.
- [ ] Add a configurable `Telegram:MiniAppUrl` entry point; when missing, keep existing command behavior.
- [ ] Add hosted service that calls `SetChatMenuButton` with `MenuButtonWebApp` only when the URL is configured.
- [ ] Re-run the filtered tests.
### Task 4: Docs, Versions, and Release Prep
**Files:**
- Modify: `Directory.Build.props`
- Modify: `compose.yaml`
- Modify: `.gitea/workflows/deploy.yml`
- Modify: `src/GmRelay.Web/wwwroot/app.css`
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
- Modify: `README.md`
- Wiki: `Home`, `Быстрый старт`, `Руководство ГМа`, `Развёртывание`, `Архитектура`, `Разработка`
- [ ] Update project/container/workflow/UI versions to `1.9.0`.
- [ ] Document `TELEGRAM_MINI_APP_URL`, BotFather `/setmenubutton`, `/miniapp`, and WebApp auth.
- [ ] Run `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --collect:"XPlat Code Coverage"`.
- [ ] Run `dotnet build GM-Relay.slnx -c Release`.
- [ ] Commit, push, close issue #17, update wiki, create tag/release `v1.9.0`.
@@ -0,0 +1,44 @@
# Telegram Mini App Dashboard Design
## Goal
Issue #17 adds a Telegram Mini App dashboard as the mobile entry point for the existing Web Dashboard. Owner and co-GM users must be able to open the dashboard from Telegram, pass server-side Telegram WebApp `initData` validation, and manage only their own groups.
## Scope
- Add Mini App authentication using Telegram WebApp `initData`.
- Add a `/miniapp` entry page that signs the user into the existing cookie auth flow, then opens the regular dashboard UI in mobile-first mode.
- Reuse `AuthorizedSessionService`, `SessionService`, and existing Blazor pages for groups, sessions, templates, waitlist promotion, edit forms, and bulk batch operations.
- Add bot entry points: a Mini App button in `/start` and a configurable default menu button when `Telegram:MiniAppUrl` is set.
- Update README, wiki, deployment config, and visible version strings to `1.9.0`.
## Architecture
The Mini App is not a second dashboard implementation. It is a Telegram-authenticated entrance into the existing Blazor dashboard. This keeps authorization, domain operations, Telegram message synchronization, and Web Dashboard behavior in one place.
`TelegramAuthService` gains a second verification method for WebApp `initData`. The server accepts the raw URL-encoded init payload at `/auth/telegram-webapp`, verifies the Telegram HMAC with the bot token, extracts the user id/name from the embedded `user` JSON, and issues the same auth cookie as the login widget endpoint.
`/miniapp` loads `telegram-web-app.js`, posts `window.Telegram.WebApp.initData` to the server endpoint, expands the WebApp viewport, and redirects to `/`. If a user opens `/miniapp` outside Telegram, the page shows the regular login fallback.
## Data Flow
1. User opens the Mini App from the bot menu button or `/start` inline button.
2. Telegram injects `initData` into the WebApp JavaScript API.
3. `/miniapp` posts `{ initData }` to `/auth/telegram-webapp`.
4. The server verifies the WebApp signature and expiry.
5. The server creates the same claims used by Telegram Login Widget.
6. Existing Blazor pages load groups through `AuthorizedSessionService`.
7. Any edit, waitlist, template, or batch action still goes through existing services and keeps Telegram messages synchronized.
## Error Handling
- Missing or invalid init data returns `401` and leaves the user on the Mini App page.
- Expired auth data is rejected with the same 24-hour window used by the Login Widget.
- A verified Telegram user with no owner/co-GM groups sees the existing empty dashboard state.
- Direct navigation to a foreign group/session still redirects to `/access-denied` through existing authorization checks.
## Testing
- Unit tests cover valid and invalid WebApp `initData`.
- File-level regression tests ensure `/miniapp`, `/auth/telegram-webapp`, Telegram WebApp script loading, bot Mini App button, menu button setup, and mobile Mini App CSS hooks remain present.
- Existing `AuthorizedSessionServiceTests` continue covering owner/co-GM access behavior.
@@ -42,11 +42,19 @@ public sealed class CreateSessionHandler(
cancellationToken: cancellationToken);
}
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
{
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
cancellationToken: cancellationToken);
}
if (!parseResult.IsValid)
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link",
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link\n\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7",
cancellationToken: cancellationToken);
return;
}
@@ -9,17 +9,21 @@ internal sealed record NewSessionParseResult(
IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs,
IReadOnlyList<string> InvalidTimeInputs,
IReadOnlyList<string> InvalidSeatLimitInputs)
IReadOnlyList<string> InvalidSeatLimitInputs,
IReadOnlyList<string> InvalidRecurringInputs)
{
public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.Count > 0 &&
InvalidSeatLimitInputs.Count == 0;
InvalidSeatLimitInputs.Count == 0 &&
InvalidRecurringInputs.Count == 0;
}
internal static class NewSessionCommandParser
{
private const int MaxRecurringSessionCount = 52;
private const int MaxRecurringIntervalDays = 365;
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
@@ -29,16 +33,30 @@ internal static class NewSessionCommandParser
"\u041b\u0438\u043c\u0438\u0442:",
"\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:"
];
private static readonly string[] RecurringCountPrefixes =
[
"\u0418\u0433\u0440:",
"\u0421\u0435\u0441\u0441\u0438\u0439:",
"\u041f\u043e\u0432\u0442\u043e\u0440\u043e\u0432:"
];
private static readonly string[] RecurringIntervalPrefixes =
[
"\u0428\u0430\u0433:",
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b:"
];
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
{
string? title = null;
string? link = null;
int? maxPlayers = null;
int? recurringCount = null;
var recurringIntervalDays = 7;
var scheduledTimes = new List<DateTimeOffset>();
var pastTimeInputs = new List<string>();
var invalidTimeInputs = new List<string>();
var invalidSeatLimitInputs = new List<string>();
var invalidRecurringInputs = new List<string>();
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
{
@@ -71,6 +89,42 @@ internal static class NewSessionCommandParser
continue;
}
var recurringCountPrefix = RecurringCountPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (recurringCountPrefix is not null)
{
var recurringInput = line[recurringCountPrefix.Length..].Trim();
if (int.TryParse(recurringInput, out var parsedCount) &&
parsedCount is >= 1 and <= MaxRecurringSessionCount)
{
recurringCount = parsedCount;
}
else
{
invalidRecurringInputs.Add(recurringInput);
}
continue;
}
var recurringIntervalPrefix = RecurringIntervalPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (recurringIntervalPrefix is not null)
{
var recurringInput = line[recurringIntervalPrefix.Length..].Trim();
if (int.TryParse(recurringInput, out var parsedInterval) &&
parsedInterval is >= 1 and <= MaxRecurringIntervalDays)
{
recurringIntervalDays = parsedInterval;
}
else
{
invalidRecurringInputs.Add(recurringInput);
}
continue;
}
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
{
continue;
@@ -92,6 +146,14 @@ internal static class NewSessionCommandParser
scheduledTimes.Add(scheduledAt);
}
if (recurringCount.HasValue && scheduledTimes.Count == 1)
{
var firstScheduledTime = scheduledTimes[0];
scheduledTimes = Enumerable.Range(0, recurringCount.Value)
.Select(index => firstScheduledTime.AddDays(recurringIntervalDays * index))
.ToList();
}
return new NewSessionParseResult(
title,
link,
@@ -99,6 +161,7 @@ internal static class NewSessionCommandParser
scheduledTimes,
pastTimeInputs,
invalidTimeInputs,
invalidSeatLimitInputs);
invalidSeatLimitInputs,
invalidRecurringInputs);
}
}
@@ -0,0 +1,46 @@
using Telegram.Bot;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Infrastructure.Telegram;
public sealed class TelegramMiniAppMenuButtonService(
ITelegramBotClient bot,
IConfiguration configuration,
ILogger<TelegramMiniAppMenuButtonService> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
if (string.IsNullOrWhiteSpace(miniAppUrl))
{
logger.LogInformation("Telegram Mini App URL is not configured; menu button setup skipped.");
return;
}
if (!Uri.TryCreate(miniAppUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttps && !uri.IsLoopback))
{
logger.LogWarning("Telegram Mini App URL {MiniAppUrl} is not a valid HTTPS URL.", miniAppUrl);
return;
}
try
{
await bot.SetChatMenuButton(
menuButton: new MenuButtonWebApp
{
Text = "Dashboard",
WebApp = new WebAppInfo(miniAppUrl)
},
cancellationToken: cancellationToken);
logger.LogInformation("Telegram Mini App menu button configured for {MiniAppUrl}.", miniAppUrl);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to configure Telegram Mini App menu button.");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -9,6 +9,7 @@ using GmRelay.Bot.Features.Sessions.RescheduleSession;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Infrastructure.Telegram;
@@ -30,6 +31,7 @@ public sealed class UpdateRouter(
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
HandleRescheduleVoteHandler rescheduleVoteHandler,
ITelegramBotClient bot,
IConfiguration configuration,
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
{
public async Task RouteAsync(Update update, CancellationToken ct)
@@ -188,10 +190,7 @@ public sealed class UpdateRouter(
switch (command)
{
case "/start":
await bot.SendMessage(
chatId: message.Chat.Id,
text: "GM-Relay Bot ready. Use /help for commands.",
cancellationToken: ct);
await SendStartMessageAsync(message, ct);
break;
case "/newsession":
@@ -218,6 +217,10 @@ public sealed class UpdateRouter(
Мест: 4
Ссылка: https://link
Для регулярного расписания можно указать одну дату:
Игр: 4
Интервал: 7
/listsessions список предстоящих сессий
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
Owner и co-GM могут переносить сессии кнопкой «Перенести»: бот попросит 2-3 варианта времени и дедлайн голосования.
@@ -232,4 +235,24 @@ public sealed class UpdateRouter(
break;
}
}
private async Task SendStartMessageAsync(Message message, CancellationToken ct)
{
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
if (string.IsNullOrWhiteSpace(miniAppUrl))
{
await bot.SendMessage(
chatId: message.Chat.Id,
text: "GM-Relay Bot ready. Use /help for commands.",
cancellationToken: ct);
return;
}
await bot.SendMessage(
chatId: message.Chat.Id,
text: "GM-Relay Bot ready. Откройте dashboard внутри Telegram или используйте /help для команд.",
replyMarkup: new InlineKeyboardMarkup(
InlineKeyboardButton.WithWebApp("Открыть dashboard", new WebAppInfo(miniAppUrl))),
cancellationToken: ct);
}
}
@@ -0,0 +1,17 @@
CREATE TABLE campaign_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
title VARCHAR(500) NOT NULL,
join_link TEXT NOT NULL,
session_count INTEGER NOT NULL CHECK (session_count BETWEEN 1 AND 52),
interval_days INTEGER NOT NULL CHECK (interval_days BETWEEN 1 AND 365),
max_players INTEGER CHECK (max_players IS NULL OR max_players > 0),
notification_mode VARCHAR(32) NOT NULL DEFAULT 'GroupAndDirect'
CHECK (notification_mode IN ('GroupAndDirect', 'GroupOnly')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (group_id, name)
);
CREATE INDEX ix_campaign_templates_group ON campaign_templates (group_id, created_at DESC);
+1
View File
@@ -71,6 +71,7 @@ builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
// ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>();
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
builder.Services.AddHostedService<TelegramBotService>();
// ── Session scheduler ────────────────────────────────────────────────
+2 -1
View File
@@ -7,6 +7,7 @@
}
},
"Telegram": {
"BotToken": ""
"BotToken": "",
"MiniAppUrl": ""
}
}
+35
View File
@@ -13,6 +13,7 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["GmRelay.Web.styles.css"]" />
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveServer" />
@@ -23,6 +24,14 @@
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script>
(function () {
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData) {
var webApp = window.Telegram.WebApp;
document.body.classList.add('telegram-mini-app');
webApp.ready();
}
})();
window.loadTelegramWidget = function (botUsername, authUrl) {
var container = document.getElementById('telegram-login-container');
if (!container) return;
@@ -36,6 +45,32 @@
script.setAttribute('data-request-access', 'write');
container.appendChild(script);
};
window.authenticateTelegramMiniApp = async function (authUrl, redirectUrl) {
if (!window.Telegram || !window.Telegram.WebApp || !window.Telegram.WebApp.initData) {
return false;
}
var webApp = window.Telegram.WebApp;
document.body.classList.add('telegram-mini-app');
webApp.ready();
webApp.expand();
var response = await fetch(authUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ initData: webApp.initData })
});
if (!response.ok) {
return false;
}
var payload = await response.json();
window.location.href = payload.redirectUrl || redirectUrl || '/';
return true;
};
</script>
</body>
@@ -60,12 +60,17 @@
/* === Mobile Responsive === */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
width: 280px;
transform: none;
width: 100%;
height: auto;
min-height: 0;
position: sticky;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.sidebar.open {
transform: translateX(0);
.page {
display: block;
}
.main-area {
@@ -25,6 +25,15 @@
</svg>
Панель управления
</NavLink>
<NavLink class="nav-item" href="templates" @onclick="CloseMenu">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="16" rx="2"/>
<path d="M7 8h10"/>
<path d="M7 12h6"/>
<path d="M7 16h8"/>
</svg>
Шаблоны
</NavLink>
</div>
<div class="nav-footer">
@@ -47,7 +56,7 @@
</button>
</form>
<div class="nav-version">v1.3.0</div>
<div class="nav-version">v1.9.0</div>
</div>
</Authorized>
<NotAuthorized>
@@ -56,13 +56,17 @@
.nav-section {
padding: 0 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* === Nav Items === */
.nav-item {
.nav-section ::deep .nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.875rem;
border-radius: var(--radius-sm);
color: var(--text-secondary);
@@ -70,16 +74,16 @@
font-size: 0.875rem;
font-weight: 500;
transition: all var(--transition-normal);
margin-bottom: 0.125rem;
white-space: nowrap;
box-sizing: border-box;
}
.nav-item:hover {
.nav-section ::deep .nav-item:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
}
.nav-item.active,
.nav-item ::deep a.active {
.nav-section ::deep .nav-item.active {
background: rgba(124, 58, 237, 0.15);
color: var(--accent-primary);
border: 1px solid rgba(124, 58, 237, 0.2);
@@ -0,0 +1,402 @@
@page "/templates"
@using GmRelay.Web.Services
@using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Шаблоны кампаний — GM-Relay</PageTitle>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li class="active">Шаблоны кампаний</li>
</ul>
<div class="page-header animate-fade-in">
<h2>📋 Шаблоны кампаний</h2>
<p>Сохраняйте типовые кампании один раз и применяйте их на странице нужной группы.</p>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
⚠️ @errorMessage
</div>
}
@if (!string.IsNullOrEmpty(successMessage))
{
<div class="gm-alert gm-alert-success" style="margin-bottom: 1rem;">
✅ @successMessage
</div>
}
@if (groups is null)
{
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 80%; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 60%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 70%;"></div>
</div>
}
else if (groups.Count == 0)
{
<div class="glass-card">
<div class="empty-state">
<div class="empty-state-icon">🤖</div>
<div class="empty-state-title">Нет доступных групп</div>
<p class="empty-state-text">Добавьте бота GM-Relay в группу Telegram, чтобы создать первый шаблон кампании.</p>
</div>
</div>
}
else
{
<div class="glass-card campaign-template-panel animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Группа для шаблонов</h3>
<p>@(SelectedGroup?.Name ?? "Выберите группу")</p>
</div>
@if (SelectedGroup is not null)
{
<span class="status-badge @(SelectedGroup.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
@FormatRole(SelectedGroup.ManagerRole)
</span>
}
</div>
<div class="template-group-selector">
<select value="@selectedGroupId" @onchange="OnSelectedGroupChanged" class="gm-form-control">
@foreach (var group in groups)
{
<option value="@group.Id">@group.Name</option>
}
</select>
@if (SelectedGroup is not null)
{
<a href="/group/@SelectedGroup.Id" class="btn-gm btn-gm-outline">Открыть группу →</a>
}
</div>
</div>
<div class="glass-card campaign-template-panel animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Новый шаблон</h3>
<p>Эти параметры будут использоваться при запуске batch из группы.</p>
</div>
<span class="status-badge status-info">Template</span>
</div>
<EditForm Model="@templateModel" OnValidSubmit="CreateCampaignTemplate">
<div class="campaign-template-fields">
<div class="gm-form-group">
<label class="gm-form-label">Название шаблона</label>
<InputText @bind-Value="templateModel.Name" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Название кампании</label>
<InputText @bind-Value="templateModel.Title" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Ссылка</label>
<InputText @bind-Value="templateModel.JoinLink" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Игр</label>
<InputNumber @bind-Value="templateModel.SessionCount" class="gm-form-control" min="1" max="52" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Интервал, дней</label>
<InputNumber @bind-Value="templateModel.IntervalDays" class="gm-form-control" min="1" max="365" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Мест</label>
<InputNumber @bind-Value="templateModel.MaxPlayers" class="gm-form-control" min="1" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Уведомления</label>
<select @bind="templateModel.NotificationMode" class="gm-form-control">
<option value="@SessionNotificationModeExtensions.GroupAndDirectValue">В группе и в личку</option>
<option value="@SessionNotificationModeExtensions.GroupOnlyValue">Только в группе</option>
</select>
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isCreatingTemplate">
@(isCreatingTemplate ? "⏳ Сохраняем..." : "💾 Сохранить шаблон")
</button>
</EditForm>
</div>
<div class="glass-card campaign-template-panel animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Сохранённые шаблоны</h3>
<p>@campaignTemplateModels.Count для выбранной группы</p>
</div>
<span class="status-badge status-info">@campaignTemplateModels.Count</span>
</div>
@if (campaignTemplates is null)
{
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 55%;"></div>
}
else if (campaignTemplateModels.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Шаблонов пока нет</div>
<p class="empty-state-text">Создайте первый шаблон для выбранной группы.</p>
</div>
}
else
{
<div class="campaign-template-list">
@foreach (var template in campaignTemplateModels)
{
<div class="campaign-template-row template-management-row">
<div class="campaign-template-info">
<h3>@template.Name</h3>
<p>@FormatTemplateSummary(template)</p>
</div>
<div class="template-management-actions">
<span class="status-badge status-neutral">@FormatLocalMoscow(template.UpdatedAt.ToMoscow())</span>
<button type="button" class="btn-gm btn-gm-danger" disabled="@(deletingTemplateId == template.Id)" @onclick="() => DeleteCampaignTemplate(template)">
@(deletingTemplateId == template.Id ? "⏳ Удаляем..." : "Удалить")
</button>
</div>
</div>
}
</div>
}
</div>
}
</div>
@code {
private List<WebGameGroup>? groups;
private List<WebCampaignTemplate>? campaignTemplates;
private List<CampaignTemplateManagementModel> campaignTemplateModels = [];
private Guid selectedGroupId;
private Guid? deletingTemplateId;
private bool isCreatingTemplate;
private long telegramId;
private string? errorMessage;
private string? successMessage;
private CampaignTemplateEditModel templateModel = new();
private WebGameGroup? SelectedGroup => groups?.FirstOrDefault(group => group.Id == selectedGroupId);
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
groups = await SessionService.GetGroupsForGmAsync(telegramId);
selectedGroupId = groups.FirstOrDefault()?.Id ?? Guid.Empty;
if (selectedGroupId != Guid.Empty)
{
await LoadTemplates();
}
}
private async Task OnSelectedGroupChanged(ChangeEventArgs args)
{
if (!Guid.TryParse(args.Value?.ToString(), out var groupId))
{
return;
}
selectedGroupId = groupId;
errorMessage = null;
successMessage = null;
await LoadTemplates();
}
private async Task LoadTemplates()
{
campaignTemplates = null;
campaignTemplateModels = [];
var templates = await SessionService.GetCampaignTemplatesForGmAsync(selectedGroupId, telegramId);
if (templates is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
campaignTemplates = templates;
RebuildCampaignTemplateModels();
}
private async Task CreateCampaignTemplate()
{
errorMessage = null;
successMessage = null;
if (selectedGroupId == Guid.Empty)
{
errorMessage = "Выберите группу для шаблона.";
return;
}
if (!ValidateCampaignTemplate(templateModel))
{
errorMessage = "Шаблон должен иметь название, ссылку, 1-52 игр, шаг 1-365 дней и положительный лимит мест, если он указан.";
return;
}
isCreatingTemplate = true;
try
{
await SessionService.CreateCampaignTemplateForGmAsync(
selectedGroupId,
telegramId,
new CreateCampaignTemplateRequest(
templateModel.Name,
templateModel.Title,
templateModel.JoinLink,
templateModel.SessionCount,
templateModel.IntervalDays,
templateModel.MaxPlayers,
SessionNotificationModeExtensions.FromDatabaseValue(templateModel.NotificationMode)));
templateModel = new();
successMessage = "Шаблон кампании сохранён.";
await LoadTemplates();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось сохранить шаблон: " + ex.Message;
}
finally
{
isCreatingTemplate = false;
}
}
private async Task DeleteCampaignTemplate(CampaignTemplateManagementModel template)
{
errorMessage = null;
successMessage = null;
deletingTemplateId = template.Id;
try
{
await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId);
successMessage = "Шаблон кампании удалён.";
await LoadTemplates();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось удалить шаблон: " + ex.Message;
}
finally
{
deletingTemplateId = null;
}
}
private void RebuildCampaignTemplateModels()
{
campaignTemplateModels = campaignTemplates?
.OrderByDescending(template => template.UpdatedAt)
.ThenBy(template => template.Name)
.Select(template => new CampaignTemplateManagementModel
{
Id = template.Id,
Name = template.Name,
Title = template.Title,
JoinLink = template.JoinLink,
SessionCount = template.SessionCount,
IntervalDays = template.IntervalDays,
MaxPlayers = template.MaxPlayers,
NotificationMode = template.NotificationMode,
UpdatedAt = template.UpdatedAt
})
.ToList() ?? [];
}
private static bool ValidateCampaignTemplate(CampaignTemplateEditModel template)
{
template.Name = template.Name.Trim();
template.Title = template.Title.Trim();
template.JoinLink = template.JoinLink.Trim();
if (template.MaxPlayers.HasValue && template.MaxPlayers.Value <= 0)
{
return false;
}
return template.Name.Length > 0 &&
template.Title.Length > 0 &&
template.JoinLink.Length > 0 &&
template.SessionCount is >= 1 and <= 52 &&
template.IntervalDays is >= 1 and <= 365;
}
private static string FormatTemplateSummary(CampaignTemplateManagementModel template)
{
var seats = template.MaxPlayers.HasValue
? $"{template.MaxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)} мест"
: "без лимита";
return $"{template.Title} · {template.SessionCount} игр · каждые {template.IntervalDays} дн. · {seats} · {FormatNotificationMode(template.NotificationMode)}";
}
private static string FormatNotificationMode(string notificationMode) =>
SessionNotificationModeExtensions.FromDatabaseValue(notificationMode) switch
{
SessionNotificationMode.GroupOnly => "только группа",
_ => "группа и личка"
};
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
private static string FormatLocalMoscow(DateTime localMoscow) =>
localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
private sealed class CampaignTemplateEditModel
{
public string Name { get; set; } = "";
public string Title { get; set; } = "";
public string JoinLink { get; set; } = "";
public int SessionCount { get; set; } = 6;
public int IntervalDays { get; set; } = 7;
public int? MaxPlayers { get; set; }
public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue;
}
private sealed class CampaignTemplateManagementModel
{
public Guid Id { get; init; }
public string Name { get; init; } = "";
public string Title { get; init; } = "";
public string JoinLink { get; init; } = "";
public int SessionCount { get; init; }
public int IntervalDays { get; init; }
public int? MaxPlayers { get; init; }
public string NotificationMode { get; init; } = SessionNotificationModeExtensions.GroupAndDirectValue;
public DateTime UpdatedAt { get; init; }
}
}
@@ -85,6 +85,47 @@
</div>
}
@if (campaignTemplates is not null)
{
<div class="glass-card campaign-template-panel animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Применить шаблон</h3>
<p>@campaignTemplateModels.Count доступных для этой группы</p>
</div>
<a href="/templates" class="btn-gm btn-gm-outline">⚙️ Управлять шаблонами</a>
</div>
@if (campaignTemplateModels.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Шаблонов пока нет</div>
<p class="empty-state-text">Создайте шаблоны в отдельной вкладке, а здесь запускайте из них новые batch-расписания.</p>
</div>
}
else
{
<div class="campaign-template-list">
@foreach (var template in campaignTemplateModels)
{
<div class="campaign-template-row">
<div class="campaign-template-info">
<h3>@template.Name</h3>
<p>@FormatTemplateSummary(template)</p>
</div>
<div class="campaign-template-actions">
<input type="datetime-local" @bind="template.FirstScheduledAtLocal" class="gm-form-control" />
<button type="button" class="btn-gm btn-gm-success" disabled="@IsTemplateBusy(template)" @onclick="() => CreateBatchFromTemplate(template)">
@(processingTemplateId == template.Id ? "⏳ Создаём..." : "📅 Создать batch")
</button>
</div>
</div>
}
</div>
}
</div>
}
@if (sessions == null)
{
<div class="glass-card" style="padding: 2rem;">
@@ -263,10 +304,13 @@
@code {
[Parameter] public Guid GroupId { get; set; }
private List<WebSession>? sessions;
private List<WebCampaignTemplate>? campaignTemplates;
private WebGroupManagement? groupManagement;
private List<BatchBulkEditModel> batchModels = [];
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
private Guid? promotingSessionId;
private Guid? processingBatchId;
private Guid? processingTemplateId;
private long? removingCoGmId;
private bool isAddingCoGm;
private long telegramId;
@@ -302,7 +346,15 @@
return;
}
campaignTemplates = await SessionService.GetCampaignTemplatesForGmAsync(GroupId, telegramId);
if (campaignTemplates is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
RebuildBatchModels();
RebuildCampaignTemplateModels();
}
private async Task AddCoGm()
@@ -497,6 +549,39 @@
}
}
private async Task CreateBatchFromTemplate(CampaignTemplateUsageModel template)
{
errorMessage = null;
successMessage = null;
processingTemplateId = template.Id;
try
{
var utcTime = new DateTimeOffset(template.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
if (utcTime <= DateTime.UtcNow)
{
errorMessage = "Первая дата batch должна быть в будущем.";
return;
}
var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForGmAsync(template.Id, telegramId, utcTime);
successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось создать batch из шаблона: " + ex.Message;
}
finally
{
processingTemplateId = null;
}
}
private void RebuildBatchModels()
{
batchModels = sessions?
@@ -523,6 +608,27 @@
.ToList() ?? [];
}
private void RebuildCampaignTemplateModels()
{
var defaultStart = DateTime.UtcNow.AddDays(7).ToMoscow();
campaignTemplateModels = campaignTemplates?
.OrderByDescending(template => template.UpdatedAt)
.ThenBy(template => template.Name)
.Select(template => new CampaignTemplateUsageModel
{
Id = template.Id,
Name = template.Name,
Title = template.Title,
JoinLink = template.JoinLink,
SessionCount = template.SessionCount,
IntervalDays = template.IntervalDays,
MaxPlayers = template.MaxPlayers,
NotificationMode = template.NotificationMode,
FirstScheduledAtLocal = defaultStart
})
.ToList() ?? [];
}
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
{
batch.Title = batch.Title.Trim();
@@ -532,6 +638,8 @@
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
private string CurrentUserRole =>
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
?? GroupManagerRoleExtensions.CoGmValue;
@@ -577,6 +685,22 @@
private static string FormatBatchSummary(BatchBulkEditModel batch) =>
$"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}";
private static string FormatTemplateSummary(CampaignTemplateUsageModel template)
{
var seats = template.MaxPlayers.HasValue
? $"{template.MaxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)} мест"
: "без лимита";
return $"{template.Title} · {template.SessionCount} игр · каждые {template.IntervalDays} дн. · {seats} · {FormatNotificationMode(template.NotificationMode)}";
}
private static string FormatNotificationMode(string notificationMode) =>
SessionNotificationModeExtensions.FromDatabaseValue(notificationMode) switch
{
SessionNotificationMode.GroupOnly => "только группа",
_ => "группа и личка"
};
private static string FormatLocalMoscow(DateTime localMoscow) =>
localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
@@ -611,6 +735,19 @@
public string CloneInterval { get; set; } = "week";
}
private sealed class CampaignTemplateUsageModel
{
public Guid Id { get; init; }
public string Name { get; init; } = "";
public string Title { get; init; } = "";
public string JoinLink { get; init; } = "";
public int SessionCount { get; init; }
public int IntervalDays { get; init; }
public int? MaxPlayers { get; init; }
public string NotificationMode { get; init; } = SessionNotificationModeExtensions.GroupAndDirectValue;
public DateTime FirstScheduledAtLocal { get; set; } = DateTime.Now;
}
private sealed class CoGmEditModel
{
public long? TelegramId { get; set; }
@@ -0,0 +1,70 @@
@page "/miniapp"
@using Microsoft.AspNetCore.Components.Authorization
@inject IJSRuntime JS
@inject NavigationManager Navigation
<PageTitle>Mini App Dashboard — GM-Relay</PageTitle>
<div class="mini-app-page">
<div class="mini-app-auth-card">
<div class="mini-app-logo">🎲</div>
<h1>GM-Relay</h1>
<p>@statusMessage</p>
@if (showFallback)
{
<a href="/login" class="btn-gm btn-gm-primary">Войти через Telegram</a>
}
</div>
</div>
@code {
private string statusMessage = "Открываем dashboard внутри Telegram...";
private bool showFallback;
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
protected override async Task OnInitializedAsync()
{
if (AuthStateTask is null)
{
return;
}
var user = (await AuthStateTask).User;
if (user.Identity?.IsAuthenticated == true)
{
Navigation.NavigateTo("/");
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
try
{
var authenticated = await JS.InvokeAsync<bool>(
"authenticateTelegramMiniApp",
"/auth/telegram-webapp",
"/");
if (!authenticated)
{
statusMessage = "Mini App доступен из Telegram. Для браузера используйте обычный вход.";
showFallback = true;
StateHasChanged();
}
}
catch (JSException)
{
statusMessage = "Не удалось получить данные Telegram Mini App. Попробуйте открыть dashboard из бота.";
showFallback = true;
StateHasChanged();
}
}
}
+38 -10
View File
@@ -87,23 +87,36 @@ app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService aut
{
if (authService.Verify(context.Request.Query, out var telegramId, out var name))
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, telegramId.ToString()),
new Claim(ClaimTypes.Name, name),
new Claim("TelegramId", telegramId.ToString())
};
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity), authProperties);
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
CreateTelegramPrincipal(telegramId, name),
authProperties);
return Results.Redirect("/");
}
return Results.Redirect("/login?error=auth_failed");
});
app.MapPost("/auth/telegram-webapp", async (
HttpContext context,
TelegramAuthService authService,
TelegramWebAppAuthRequest request) =>
{
if (!authService.VerifyWebAppInitData(request.InitData, out var telegramId, out var name))
{
return Results.Unauthorized();
}
var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
CreateTelegramPrincipal(telegramId, name),
authProperties);
return Results.Ok(new { redirectUrl = "/" });
}).DisableAntiforgery();
app.MapPost("/auth/logout", async (HttpContext context) =>
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
@@ -111,3 +124,18 @@ app.MapPost("/auth/logout", async (HttpContext context) =>
});
app.Run();
static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new(ClaimTypes.Name, name),
new("TelegramId", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture))
};
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
return new ClaimsPrincipal(claimsIdentity);
}
public sealed record TelegramWebAppAuthRequest(string InitData);
@@ -128,6 +128,60 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval);
}
public async Task<List<WebCampaignTemplate>?> GetCampaignTemplatesForGmAsync(Guid groupId, long gmId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
return null;
}
return await sessionStore.GetCampaignTemplatesAsync(groupId);
}
public async Task<WebCampaignTemplate> CreateCampaignTemplateForGmAsync(
Guid groupId,
long gmId,
CreateCampaignTemplateRequest request)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
throw new SessionAccessDeniedException(groupId, gmId);
}
var normalizedRequest = NormalizeCampaignTemplateRequest(request);
return await sessionStore.CreateCampaignTemplateAsync(groupId, normalizedRequest);
}
public async Task DeleteCampaignTemplateForGmAsync(Guid templateId, long gmId)
{
var template = await sessionStore.GetCampaignTemplateAsync(templateId);
if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId))
{
throw new SessionAccessDeniedException(templateId, gmId);
}
await sessionStore.DeleteCampaignTemplateAsync(templateId, template.GroupId);
}
public async Task<WebSessionBatch> CreateBatchFromCampaignTemplateForGmAsync(
Guid templateId,
long gmId,
DateTime firstScheduledAt)
{
if (firstScheduledAt <= DateTime.UtcNow)
{
throw new ArgumentOutOfRangeException(nameof(firstScheduledAt), firstScheduledAt, "First scheduled time must be in the future.");
}
var template = await sessionStore.GetCampaignTemplateAsync(templateId);
if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId))
{
throw new SessionAccessDeniedException(templateId, gmId);
}
return await sessionStore.CreateBatchFromTemplateAsync(templateId, template.GroupId, firstScheduledAt);
}
public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
{
if (coGmTelegramId <= 0)
@@ -169,4 +223,48 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
{
return await sessionStore.IsGroupManagerAsync(groupId, gmId);
}
private static CreateCampaignTemplateRequest NormalizeCampaignTemplateRequest(CreateCampaignTemplateRequest request)
{
var name = request.Name.Trim();
var title = request.Title.Trim();
var joinLink = request.JoinLink.Trim();
if (name.Length == 0)
{
throw new ArgumentException("Template name must not be empty.", nameof(request));
}
if (title.Length == 0)
{
throw new ArgumentException("Session title must not be empty.", nameof(request));
}
if (joinLink.Length == 0)
{
throw new ArgumentException("Join link must not be empty.", nameof(request));
}
if (request.SessionCount is < 1 or > 52)
{
throw new ArgumentOutOfRangeException(nameof(request), request.SessionCount, "Session count must be between 1 and 52.");
}
if (request.IntervalDays is < 1 or > 365)
{
throw new ArgumentOutOfRangeException(nameof(request), request.IntervalDays, "Interval must be between 1 and 365 days.");
}
if (request.MaxPlayers is <= 0)
{
throw new ArgumentOutOfRangeException(nameof(request), request.MaxPlayers, "Seat limit must be greater than zero.");
}
return request with
{
Name = name,
Title = title,
JoinLink = joinLink
};
}
}
@@ -18,8 +18,33 @@ public sealed record WebSessionBatch(
int SessionCount,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
public sealed record WebCampaignTemplate(
Guid Id,
Guid GroupId,
string Name,
string Title,
string JoinLink,
int SessionCount,
int IntervalDays,
int? MaxPlayers,
string NotificationMode,
DateTime CreatedAt,
DateTime UpdatedAt);
public sealed record CreateCampaignTemplateRequest(
string Name,
string Title,
string JoinLink,
int SessionCount,
int IntervalDays,
int? MaxPlayers,
SessionNotificationMode NotificationMode);
public static class BatchSchedulePlanner
{
private const int MaxTemplateSessionCount = 52;
private const int MaxTemplateIntervalDays = 365;
public static IReadOnlyList<DateTime> BuildFixedIntervalSchedule(
IEnumerable<DateTime> currentSchedule,
DateTime firstScheduledAt,
@@ -36,6 +61,26 @@ public static class BatchSchedulePlanner
.ToList();
}
public static IReadOnlyList<DateTime> BuildRecurringSchedule(
DateTime firstScheduledAt,
int sessionCount,
int intervalDays)
{
if (sessionCount is < 1 or > MaxTemplateSessionCount)
{
throw new ArgumentOutOfRangeException(nameof(sessionCount), sessionCount, "Session count must be between 1 and 52.");
}
if (intervalDays is < 1 or > MaxTemplateIntervalDays)
{
throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be between 1 and 365 days.");
}
return Enumerable.Range(0, sessionCount)
.Select(index => firstScheduledAt.AddDays(intervalDays * index))
.ToList();
}
public static DateTime ShiftForClone(DateTime scheduledAt, BatchCloneInterval interval) =>
interval switch
{
@@ -18,6 +18,11 @@ public interface ISessionStore
Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode);
Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays);
Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval);
Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId);
Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId);
Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request);
Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId);
Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt);
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername);
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId);
}
+208
View File
@@ -63,6 +63,7 @@ internal sealed record WebBatchSessionRow(
long TelegramChatId,
int? ThreadId,
string NotificationMode);
internal sealed record WebTemplateGroupDto(long TelegramChatId);
public sealed class SessionService(
NpgsqlDataSource dataSource,
@@ -753,6 +754,213 @@ public sealed class SessionService(
sourceSessions[0].NotificationMode);
}
public async Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebCampaignTemplate>(
"""
SELECT id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
FROM campaign_templates
WHERE group_id = @GroupId
ORDER BY created_at DESC, name
""",
new { GroupId = groupId })).ToList();
}
public async Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebCampaignTemplate>(
"""
SELECT id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
FROM campaign_templates
WHERE id = @TemplateId
""",
new { TemplateId = templateId });
}
public async Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleAsync<WebCampaignTemplate>(
"""
INSERT INTO campaign_templates (
group_id,
name,
title,
join_link,
session_count,
interval_days,
max_players,
notification_mode
)
VALUES (
@GroupId,
@Name,
@Title,
@JoinLink,
@SessionCount,
@IntervalDays,
@MaxPlayers,
@NotificationMode
)
ON CONFLICT (group_id, name) DO UPDATE
SET title = EXCLUDED.title,
join_link = EXCLUDED.join_link,
session_count = EXCLUDED.session_count,
interval_days = EXCLUDED.interval_days,
max_players = EXCLUDED.max_players,
notification_mode = EXCLUDED.notification_mode,
updated_at = now()
RETURNING id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
""",
new
{
GroupId = groupId,
request.Name,
request.Title,
request.JoinLink,
request.SessionCount,
request.IntervalDays,
request.MaxPlayers,
NotificationMode = request.NotificationMode.ToDatabaseValue()
});
}
public async Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"DELETE FROM campaign_templates WHERE id = @TemplateId AND group_id = @GroupId",
new { TemplateId = templateId, GroupId = groupId });
}
public async Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var template = await conn.QuerySingleOrDefaultAsync<WebCampaignTemplate>(
"""
SELECT id AS Id,
group_id AS GroupId,
name AS Name,
title AS Title,
join_link AS JoinLink,
session_count AS SessionCount,
interval_days AS IntervalDays,
max_players AS MaxPlayers,
notification_mode AS NotificationMode,
created_at AS CreatedAt,
updated_at AS UpdatedAt
FROM campaign_templates
WHERE id = @TemplateId
AND group_id = @GroupId
FOR UPDATE
""",
new { TemplateId = templateId, GroupId = groupId },
transaction);
if (template is null)
{
throw new SessionAccessDeniedException(templateId, 0);
}
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
"SELECT telegram_chat_id AS TelegramChatId FROM game_groups WHERE id = @GroupId",
new { GroupId = groupId },
transaction);
if (group is null)
{
throw new SessionAccessDeniedException(groupId, 0);
}
var schedule = BatchSchedulePlanner.BuildRecurringSchedule(
firstScheduledAt,
template.SessionCount,
template.IntervalDays);
var batchId = Guid.NewGuid();
var renderedSessions = new List<SessionBatchDto>();
foreach (var scheduledAt in schedule)
{
var sessionId = await conn.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, notification_mode)
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @MaxPlayers, @NotificationMode)
RETURNING id
""",
new
{
BatchId = batchId,
GroupId = groupId,
template.Title,
template.JoinLink,
ScheduledAt = scheduledAt,
Status = SessionStatus.Planned,
template.MaxPlayers,
template.NotificationMode
},
transaction);
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers));
}
await transaction.CommitAsync();
var renderResult = SessionBatchRenderer.Render(template.Title, renderedSessions, Array.Empty<ParticipantBatchDto>());
var batchMessage = await bot.SendMessage(
chatId: group.TelegramChatId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup);
await conn.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MessageId WHERE batch_id = @BatchId",
new { MessageId = batchMessage.MessageId, BatchId = batchId });
return new WebSessionBatch(
batchId,
groupId,
template.Title,
template.JoinLink,
renderedSessions.Min(session => session.ScheduledAt),
renderedSessions.Max(session => session.ScheduledAt),
renderedSessions.Count,
template.NotificationMode);
}
private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync(
Npgsql.NpgsqlConnection conn,
Guid sessionId)
@@ -1,5 +1,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.WebUtilities;
namespace GmRelay.Web.Services;
@@ -55,4 +57,98 @@ public sealed class TelegramAuthService(IConfiguration configuration)
return false;
}
public bool VerifyWebAppInitData(string initData, out long telegramId, out string name)
{
telegramId = 0;
name = string.Empty;
if (string.IsNullOrWhiteSpace(initData))
return false;
var token = configuration["Telegram__BotToken"] ?? configuration["Telegram:BotToken"];
if (string.IsNullOrEmpty(token))
return false;
var values = QueryHelpers.ParseQuery(initData);
if (!values.TryGetValue("hash", out var hash) || string.IsNullOrWhiteSpace(hash))
return false;
var dataCheckString = string.Join(
"\n",
values
.Where(pair => pair.Key != "hash")
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = HMACSHA256.HashData(Encoding.UTF8.GetBytes("WebAppData"), Encoding.UTF8.GetBytes(token));
var computedHashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
byte[] hashBytes;
try
{
hashBytes = Convert.FromHexString(hash.ToString());
}
catch (FormatException)
{
return false;
}
if (!CryptographicOperations.FixedTimeEquals(computedHashBytes, hashBytes))
return false;
if (!values.TryGetValue("auth_date", out var authDateStr) ||
!long.TryParse(authDateStr, out var authDate))
{
return false;
}
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (now - authDate > 86400)
return false;
if (!values.TryGetValue("user", out var userJson) || string.IsNullOrWhiteSpace(userJson))
return false;
return TryReadWebAppUser(userJson.ToString(), out telegramId, out name);
}
private static bool TryReadWebAppUser(string userJson, out long telegramId, out string name)
{
telegramId = 0;
name = string.Empty;
try
{
using var document = JsonDocument.Parse(userJson);
var root = document.RootElement;
if (!root.TryGetProperty("id", out var idElement) || !idElement.TryGetInt64(out telegramId))
return false;
var firstName = root.TryGetProperty("first_name", out var firstNameElement)
? firstNameElement.GetString() ?? string.Empty
: string.Empty;
var lastName = root.TryGetProperty("last_name", out var lastNameElement)
? lastNameElement.GetString() ?? string.Empty
: string.Empty;
var username = root.TryGetProperty("username", out var usernameElement)
? usernameElement.GetString()
: null;
name = (firstName, lastName) switch
{
({ Length: > 0 }, { Length: > 0 }) => $"{firstName} {lastName}",
({ Length: > 0 }, _) => firstName,
_ when !string.IsNullOrWhiteSpace(username) => "@" + username,
_ => $"Telegram {telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
};
return true;
}
catch (JsonException)
{
return false;
}
}
}
+128 -3
View File
@@ -1,5 +1,5 @@
/* ============================================
GM-Relay Design System v1.7.0
GM-Relay Design System v1.9.0
Dark RPG Dashboard Theme
============================================ */
@@ -618,6 +618,78 @@ select option {
white-space: nowrap;
}
/* === Campaign templates === */
.campaign-template-panel {
margin-bottom: 1.5rem;
}
.campaign-template-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.campaign-template-list {
margin-top: 1rem;
border-top: 1px solid var(--border-color);
}
.campaign-template-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, auto);
gap: 1rem;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid var(--border-color);
}
.campaign-template-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.campaign-template-info h3 {
font-size: 0.9375rem;
margin-bottom: 0.25rem;
overflow-wrap: anywhere;
}
.campaign-template-info p {
margin: 0;
color: var(--text-muted);
font-size: 0.8125rem;
}
.campaign-template-actions {
display: grid;
grid-template-columns: minmax(190px, 1fr) auto;
gap: 0.75rem;
align-items: center;
}
.template-group-selector {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
}
.template-management-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.template-management-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.empty-state-compact {
padding: 1.5rem 1rem;
}
/* === Animations === */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
@@ -770,6 +842,46 @@ select option {
margin-bottom: 2rem;
}
/* === Telegram Mini App entry === */
.mini-app-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--bg-primary);
}
.mini-app-auth-card {
width: 100%;
max-width: 360px;
padding: 1.5rem;
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
background: var(--glass-bg);
text-align: center;
}
.mini-app-logo {
font-size: 2.25rem;
margin-bottom: 0.75rem;
}
.mini-app-auth-card h1 {
font-size: 1.375rem;
margin-bottom: 0.5rem;
}
.mini-app-auth-card p {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 1.25rem;
}
.telegram-mini-app .page-container {
max-width: 720px;
}
/* === Mobile Sessions Cards (instead of table) === */
.session-card-mobile {
display: none;
@@ -838,11 +950,16 @@ select option {
}
.batch-bulk-fields,
.batch-clone-row {
.batch-clone-row,
.campaign-template-fields,
.campaign-template-row,
.campaign-template-actions,
.template-group-selector {
grid-template-columns: 1fr;
}
.batch-clone-row .btn-gm {
.batch-clone-row .btn-gm,
.campaign-template-actions .btn-gm {
justify-content: center;
width: 100%;
}
@@ -851,6 +968,14 @@ select option {
padding: 1rem;
}
.telegram-mini-app .content {
padding: 0.75rem;
}
.telegram-mini-app .page-container {
padding: 0.75rem;
}
h2 {
font-size: 1.25rem;
}
@@ -33,6 +33,32 @@ public sealed class NewSessionCommandParserTests
Assert.Empty(result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldExpandRecurringSchedule_WhenRepeatCountAndIntervalProvided()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Kingmaker
Время: 30.04.2026 19:30
Игр: 4
Интервал: 14
Ссылка: https://example.test/kingmaker
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal(
[
new DateTimeOffset(2026, 4, 30, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 14, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 28, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 6, 11, 16, 30, 0, TimeSpan.Zero)
],
result.ScheduledTimes);
}
[Fact]
public void Parse_ShouldCollectPastAndInvalidTimes()
{
@@ -0,0 +1,50 @@
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
public sealed class TelegramMiniAppEntryPointTests
{
[Fact]
public async Task UpdateRouter_ShouldExposeMiniAppButtonInStartCommand()
{
var updateRouter = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs"));
Assert.Contains("Telegram:MiniAppUrl", updateRouter, StringComparison.Ordinal);
Assert.Contains("InlineKeyboardButton.WithWebApp", updateRouter, StringComparison.Ordinal);
Assert.Contains("Открыть dashboard", updateRouter, StringComparison.Ordinal);
}
[Fact]
public async Task BotStartup_ShouldRegisterMiniAppMenuButtonService()
{
var program = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Bot/Program.cs"));
Assert.Contains("TelegramMiniAppMenuButtonService", program, StringComparison.Ordinal);
Assert.Contains("AddHostedService<TelegramMiniAppMenuButtonService>", program, StringComparison.Ordinal);
}
[Fact]
public async Task MiniAppMenuButtonService_ShouldSetTelegramWebAppMenuButtonWhenConfigured()
{
var service = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Bot/Infrastructure/Telegram/TelegramMiniAppMenuButtonService.cs"));
Assert.Contains("SetChatMenuButton", service, StringComparison.Ordinal);
Assert.Contains("MenuButtonWebApp", service, StringComparison.Ordinal);
Assert.Contains("Telegram:MiniAppUrl", service, StringComparison.Ordinal);
}
private static string FindRepositoryFile(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -460,13 +460,153 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal(BatchCloneInterval.NextWeek, store.LastCloneInterval);
}
[Fact]
public async Task CreateCampaignTemplateForGmAsync_CreatesTemplate_WhenGroupBelongsToGm()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
]);
var service = new AuthorizedSessionService(store);
await service.CreateCampaignTemplateForGmAsync(
groupId,
gmId,
new CreateCampaignTemplateRequest(
" Weekly arc ",
" Kingmaker ",
" https://example.test/kingmaker ",
SessionCount: 6,
IntervalDays: 7,
MaxPlayers: 5,
SessionNotificationMode.GroupOnly));
Assert.True(store.CreateCampaignTemplateCalled);
Assert.Equal(groupId, store.LastCreatedCampaignTemplateGroupId);
Assert.Equal("Weekly arc", store.LastCreatedCampaignTemplateRequest?.Name);
Assert.Equal("Kingmaker", store.LastCreatedCampaignTemplateRequest?.Title);
Assert.Equal("https://example.test/kingmaker", store.LastCreatedCampaignTemplateRequest?.JoinLink);
Assert.Equal(6, store.LastCreatedCampaignTemplateRequest?.SessionCount);
Assert.Equal(7, store.LastCreatedCampaignTemplateRequest?.IntervalDays);
Assert.Equal(5, store.LastCreatedCampaignTemplateRequest?.MaxPlayers);
Assert.Equal(SessionNotificationMode.GroupOnly, store.LastCreatedCampaignTemplateRequest?.NotificationMode);
}
[Fact]
public async Task CreateCampaignTemplateForGmAsync_AllowsNoSeatLimit()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
]);
var service = new AuthorizedSessionService(store);
await service.CreateCampaignTemplateForGmAsync(
groupId,
gmId,
new CreateCampaignTemplateRequest(
"Open table",
"West Marches",
"https://example.test/west",
8,
7,
null,
SessionNotificationMode.GroupAndDirect));
Assert.True(store.CreateCampaignTemplateCalled);
Assert.Null(store.LastCreatedCampaignTemplateRequest?.MaxPlayers);
}
[Fact]
public async Task CreateCampaignTemplateForGmAsync_Throws_WhenGroupBelongsToAnotherGm()
{
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.CreateCampaignTemplateForGmAsync(
groupId,
1001L,
new CreateCampaignTemplateRequest(
"Weekly arc",
"Kingmaker",
"https://example.test/kingmaker",
SessionCount: 6,
IntervalDays: 7,
MaxPlayers: 5,
SessionNotificationMode.GroupOnly));
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.CreateCampaignTemplateCalled);
}
[Fact]
public async Task CreateBatchFromCampaignTemplateForGmAsync_CreatesBatch_WhenTemplateGroupBelongsToGm()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var templateId = Guid.NewGuid();
var firstScheduledAt = DateTime.UtcNow.AddDays(3);
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
templates:
[
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
]);
var service = new AuthorizedSessionService(store);
await service.CreateBatchFromCampaignTemplateForGmAsync(templateId, gmId, firstScheduledAt);
Assert.True(store.CreateBatchFromTemplateCalled);
Assert.Equal(templateId, store.LastCreatedBatchTemplateId);
Assert.Equal(groupId, store.LastCreatedBatchTemplateGroupId);
Assert.Equal(firstScheduledAt, store.LastCreatedBatchFirstScheduledAt);
}
[Fact]
public async Task CreateBatchFromCampaignTemplateForGmAsync_Throws_WhenTemplateGroupBelongsToAnotherGm()
{
var groupId = Guid.NewGuid();
var templateId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
],
templates:
[
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.CreateBatchFromCampaignTemplateForGmAsync(templateId, 1001L, DateTime.UtcNow.AddDays(3));
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.CreateBatchFromTemplateCalled);
}
private sealed class FakeSessionStore(
IEnumerable<WebGameGroup>? groups = null,
IEnumerable<WebSession>? sessions = null,
IEnumerable<FakeGroupManager>? managers = null) : ISessionStore
IEnumerable<FakeGroupManager>? managers = null,
IEnumerable<WebCampaignTemplate>? templates = null) : ISessionStore
{
private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? [];
private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.Id) ?? [];
private readonly Dictionary<Guid, WebCampaignTemplate> templatesById = templates?.ToDictionary(template => template.Id) ?? [];
private readonly List<FakeGroupManager> managers = managers?.ToList() ?? [];
public bool UpdateCalled { get; private set; }
@@ -475,6 +615,9 @@ public sealed class AuthorizedSessionServiceTests
public bool UpdateBatchNotificationModeCalled { get; private set; }
public bool RescheduleBatchCalled { get; private set; }
public bool CloneBatchCalled { get; private set; }
public bool CreateCampaignTemplateCalled { get; private set; }
public bool DeleteCampaignTemplateCalled { get; private set; }
public bool CreateBatchFromTemplateCalled { get; private set; }
public bool AddCoGmCalled { get; private set; }
public bool RemoveCoGmCalled { get; private set; }
public Guid? LastUpdatedSessionId { get; private set; }
@@ -499,6 +642,13 @@ public sealed class AuthorizedSessionServiceTests
public Guid? LastClonedBatchId { get; private set; }
public Guid? LastClonedBatchGroupId { get; private set; }
public BatchCloneInterval? LastCloneInterval { get; private set; }
public Guid? LastCreatedCampaignTemplateGroupId { get; private set; }
public CreateCampaignTemplateRequest? LastCreatedCampaignTemplateRequest { get; private set; }
public Guid? LastDeletedCampaignTemplateId { get; private set; }
public Guid? LastDeletedCampaignTemplateGroupId { get; private set; }
public Guid? LastCreatedBatchTemplateId { get; private set; }
public Guid? LastCreatedBatchTemplateGroupId { get; private set; }
public DateTime? LastCreatedBatchFirstScheduledAt { get; private set; }
public Guid? LastAddedCoGmGroupId { get; private set; }
public long? LastAddedCoGmTelegramId { get; private set; }
public string? LastAddedCoGmDisplayName { get; private set; }
@@ -642,6 +792,66 @@ public sealed class AuthorizedSessionServiceTests
1));
}
public Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId) =>
Task.FromResult(templatesById.Values.Where(template => template.GroupId == groupId).ToList());
public Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId)
{
templatesById.TryGetValue(templateId, out var template);
return Task.FromResult(template);
}
public Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request)
{
CreateCampaignTemplateCalled = true;
LastCreatedCampaignTemplateGroupId = groupId;
LastCreatedCampaignTemplateRequest = request;
var template = new WebCampaignTemplate(
Guid.NewGuid(),
groupId,
request.Name,
request.Title,
request.JoinLink,
request.SessionCount,
request.IntervalDays,
request.MaxPlayers,
request.NotificationMode.ToDatabaseValue(),
DateTime.UtcNow,
DateTime.UtcNow);
templatesById[template.Id] = template;
return Task.FromResult(template);
}
public Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId)
{
DeleteCampaignTemplateCalled = true;
LastDeletedCampaignTemplateId = templateId;
LastDeletedCampaignTemplateGroupId = groupId;
templatesById.Remove(templateId);
return Task.CompletedTask;
}
public Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt)
{
CreateBatchFromTemplateCalled = true;
LastCreatedBatchTemplateId = templateId;
LastCreatedBatchTemplateGroupId = groupId;
LastCreatedBatchFirstScheduledAt = firstScheduledAt;
var template = templatesById[templateId];
return Task.FromResult(new WebSessionBatch(
Guid.NewGuid(),
groupId,
template.Title,
template.JoinLink,
firstScheduledAt,
firstScheduledAt.AddDays(template.IntervalDays * (template.SessionCount - 1)),
template.SessionCount,
template.NotificationMode));
}
public Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
{
AddCoGmCalled = true;
@@ -37,6 +37,35 @@ public sealed class BatchSchedulePlannerTests
Assert.Throws<ArgumentOutOfRangeException>(action);
}
[Fact]
public void BuildRecurringSchedule_CreatesFixedIntervalScheduleFromFirstDate()
{
var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc);
var result = BatchSchedulePlanner.BuildRecurringSchedule(firstScheduledAt, sessionCount: 3, intervalDays: 14);
Assert.Equal(
[
firstScheduledAt,
firstScheduledAt.AddDays(14),
firstScheduledAt.AddDays(28)
],
result);
}
[Theory]
[InlineData(0, 7)]
[InlineData(53, 7)]
[InlineData(3, 0)]
public void BuildRecurringSchedule_RejectsInvalidTemplateShape(int sessionCount, int intervalDays)
{
var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc);
var action = () => BatchSchedulePlanner.BuildRecurringSchedule(firstScheduledAt, sessionCount, intervalDays);
Assert.Throws<ArgumentOutOfRangeException>(action);
}
[Theory]
[InlineData(BatchCloneInterval.NextWeek, 2026, 5, 8)]
[InlineData(BatchCloneInterval.NextMonth, 2026, 6, 1)]
@@ -0,0 +1,64 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class CampaignTemplatesNavigationTests
{
[Fact]
public async Task NavMenu_ShouldExposeTemplatesTab()
{
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
Assert.Contains("href=\"templates\"", navMenu, StringComparison.Ordinal);
Assert.Contains("Шаблоны", navMenu, StringComparison.Ordinal);
}
[Fact]
public async Task NavMenuStyles_ShouldStyleNavLinkAnchorsAsStackedRows()
{
var navCss = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor.css"));
Assert.Contains("::deep .nav-item", navCss, StringComparison.Ordinal);
Assert.Matches(
@"\.nav-section\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*gap:\s*0\.25rem;",
navCss);
Assert.Matches(
@"::deep\s+\.nav-item\s*\{[^}]*display:\s*flex;[^}]*width:\s*100%;",
navCss);
}
[Fact]
public async Task GroupDetails_ShouldApplyTemplatesWithoutManagingThem()
{
var groupDetails = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Pages/GroupDetails.razor"));
Assert.Contains("CreateBatchFromTemplate", groupDetails, StringComparison.Ordinal);
Assert.DoesNotContain("OnValidSubmit=\"CreateCampaignTemplate\"", groupDetails, StringComparison.Ordinal);
Assert.DoesNotContain("DeleteCampaignTemplate", groupDetails, StringComparison.Ordinal);
}
[Fact]
public async Task CampaignTemplatesPage_ShouldOwnTemplateManagement()
{
var templatesPage = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Pages/CampaignTemplates.razor"));
Assert.Contains("@page \"/templates\"", templatesPage, StringComparison.Ordinal);
Assert.Contains("OnValidSubmit=\"CreateCampaignTemplate\"", templatesPage, StringComparison.Ordinal);
Assert.Contains("DeleteCampaignTemplate", templatesPage, StringComparison.Ordinal);
}
private static string FindRepositoryFile(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -0,0 +1,60 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class MiniAppDashboardTests
{
[Fact]
public async Task MiniAppPage_ShouldExposeTelegramWebAppDashboardEntryPoint()
{
var miniAppPage = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Pages/MiniApp.razor"));
Assert.Contains("@page \"/miniapp\"", miniAppPage, StringComparison.Ordinal);
Assert.Contains("authenticateTelegramMiniApp", miniAppPage, StringComparison.Ordinal);
Assert.Contains("/auth/telegram-webapp", miniAppPage, StringComparison.Ordinal);
}
[Fact]
public async Task AppShell_ShouldLoadTelegramWebAppScriptAndAuthenticator()
{
var appShell = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/App.razor"));
Assert.Contains("telegram-web-app.js", appShell, StringComparison.Ordinal);
Assert.Contains("window.authenticateTelegramMiniApp", appShell, StringComparison.Ordinal);
Assert.Contains("Telegram.WebApp.initData", appShell, StringComparison.Ordinal);
}
[Fact]
public async Task Program_ShouldMapTelegramWebAppAuthEndpoint()
{
var program = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Program.cs"));
Assert.Contains("MapPost(\"/auth/telegram-webapp\"", program, StringComparison.Ordinal);
Assert.Contains("VerifyWebAppInitData", program, StringComparison.Ordinal);
}
[Fact]
public async Task Styles_ShouldIncludeMiniAppMobileDashboardRules()
{
var css = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/wwwroot/app.css"));
Assert.Contains("mini-app-page", css, StringComparison.Ordinal);
Assert.Contains("mini-app-auth-card", css, StringComparison.Ordinal);
Assert.Contains("@media (max-width: 768px)", css, StringComparison.Ordinal);
}
private static string FindRepositoryFile(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -77,6 +77,64 @@ public sealed class TelegramAuthServiceTests
Assert.False(verified);
}
[Fact]
public void VerifyWebAppInitData_ShouldAcceptValidTelegramWebAppPayload()
{
const string botToken = "test-bot-token";
var initData = CreateWebAppInitData(
botToken,
new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
["query_id"] = "AAHdF6IQAAAAAN0XohDhrOrc",
["user"] = """{"id":424242,"first_name":"Ada","last_name":"Lovelace","username":"ada"}"""
});
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(initData, out var telegramId, out var name);
Assert.True(verified);
Assert.Equal(424242L, telegramId);
Assert.Equal("Ada Lovelace", name);
}
[Fact]
public void VerifyWebAppInitData_ShouldRejectTamperedHash()
{
const string botToken = "test-bot-token";
var initData = CreateWebAppInitData(
botToken,
new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
["user"] = """{"id":424242,"first_name":"Ada"}"""
});
var tamperedInitData = initData.Replace("hash=", "hash=00", StringComparison.Ordinal);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(tamperedInitData, out _, out _);
Assert.False(verified);
}
[Fact]
public void VerifyWebAppInitData_ShouldRejectExpiredPayload()
{
const string botToken = "test-bot-token";
var initData = CreateWebAppInitData(
botToken,
new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds().ToString(),
["user"] = """{"id":424242,"first_name":"Ada"}"""
});
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(initData, out _, out _);
Assert.False(verified);
}
private static IConfiguration CreateConfiguration(string botToken) =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
@@ -106,4 +164,27 @@ public sealed class TelegramAuthServiceTests
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
private static string CreateWebAppInitData(string botToken, IReadOnlyDictionary<string, string> values)
{
var hash = ComputeTelegramWebAppHash(botToken, values);
var encodedPairs = values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")
.Append($"hash={hash}");
return string.Join("&", encodedPairs);
}
private static string ComputeTelegramWebAppHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = HMACSHA256.HashData(Encoding.UTF8.GetBytes("WebAppData"), Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
}