From 41f2ea6e907c544c11c4846a378cee7994a3c8ee Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 28 Apr 2026 14:56:55 +0300 Subject: [PATCH] feat: add telegram mini app dashboard --- .env.example | 4 + .gitea/workflows/deploy.yml | 3 +- Directory.Build.props | 2 +- README.md | 14 ++- compose.yaml | 6 +- .../2026-04-28-telegram-mini-app-dashboard.md | 69 +++++++++++++ ...4-28-telegram-mini-app-dashboard-design.md | 44 +++++++++ .../TelegramMiniAppMenuButtonService.cs | 46 +++++++++ .../Infrastructure/Telegram/UpdateRouter.cs | 27 +++++- src/GmRelay.Bot/Program.cs | 1 + src/GmRelay.Bot/appsettings.json | 3 +- src/GmRelay.Web/Components/App.razor | 35 +++++++ .../Components/Layout/MainLayout.razor.css | 13 ++- .../Components/Layout/NavMenu.razor | 2 +- .../Components/Pages/MiniApp.razor | 70 ++++++++++++++ src/GmRelay.Web/Program.cs | 48 ++++++++-- .../Services/TelegramAuthService.cs | 96 +++++++++++++++++++ src/GmRelay.Web/wwwroot/app.css | 50 +++++++++- .../TelegramMiniAppEntryPointTests.cs | 50 ++++++++++ .../Web/MiniAppDashboardTests.cs | 60 ++++++++++++ .../Web/TelegramAuthServiceTests.cs | 81 ++++++++++++++++ 21 files changed, 698 insertions(+), 26 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-28-telegram-mini-app-dashboard.md create mode 100644 docs/superpowers/specs/2026-04-28-telegram-mini-app-dashboard-design.md create mode 100644 src/GmRelay.Bot/Infrastructure/Telegram/TelegramMiniAppMenuButtonService.cs create mode 100644 src/GmRelay.Web/Components/Pages/MiniApp.razor create mode 100644 tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramMiniAppEntryPointTests.cs create mode 100644 tests/GmRelay.Bot.Tests/Web/MiniAppDashboardTests.cs diff --git a/.env.example b/.env.example index 24244a0..4e3262a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 82ddaae..d08ff80 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.8.2 + 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: | diff --git a/Directory.Build.props b/Directory.Build.props index f16e202..b747f50 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.8.2 + 1.9.0 net10.0 preview enable diff --git a/README.md b/README.md index 82cbc7f..e10ccc7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v1.8.2`. +**Текущая версия:** `v1.9.0`. --- @@ -24,6 +24,7 @@ ### 🌐 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 из выбранного шаблона. @@ -74,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=ваш_надежный_пароль @@ -83,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 @@ -171,6 +178,11 @@ Owner или co-GM нажимает кнопку `⏰ Перенести` у н Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о 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 вариантам нового времени. diff --git a/compose.yaml b/compose.yaml index 54184b1..7f2b71d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.8.2 + 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.8.2 + 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: diff --git a/docs/superpowers/plans/2026-04-28-telegram-mini-app-dashboard.md b/docs/superpowers/plans/2026-04-28-telegram-mini-app-dashboard.md new file mode 100644 index 0000000..685d5e0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-telegram-mini-app-dashboard.md @@ -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`. diff --git a/docs/superpowers/specs/2026-04-28-telegram-mini-app-dashboard-design.md b/docs/superpowers/specs/2026-04-28-telegram-mini-app-dashboard-design.md new file mode 100644 index 0000000..d9d3ad8 --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-telegram-mini-app-dashboard-design.md @@ -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. diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/TelegramMiniAppMenuButtonService.cs b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramMiniAppMenuButtonService.cs new file mode 100644 index 0000000..852034e --- /dev/null +++ b/src/GmRelay.Bot/Infrastructure/Telegram/TelegramMiniAppMenuButtonService.cs @@ -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 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; +} diff --git a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs index 53eb8f2..93621d6 100644 --- a/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs +++ b/src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs @@ -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 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": @@ -236,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); + } } diff --git a/src/GmRelay.Bot/Program.cs b/src/GmRelay.Bot/Program.cs index ac61ce2..d28aea6 100644 --- a/src/GmRelay.Bot/Program.cs +++ b/src/GmRelay.Bot/Program.cs @@ -71,6 +71,7 @@ builder.Services.AddSingleton(); // ── Telegram infrastructure ────────────────────────────────────────── builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); // ── Session scheduler ──────────────────────────────────────────────── diff --git a/src/GmRelay.Bot/appsettings.json b/src/GmRelay.Bot/appsettings.json index 158aa8e..276403a 100644 --- a/src/GmRelay.Bot/appsettings.json +++ b/src/GmRelay.Bot/appsettings.json @@ -7,6 +7,7 @@ } }, "Telegram": { - "BotToken": "" + "BotToken": "", + "MiniAppUrl": "" } } diff --git a/src/GmRelay.Web/Components/App.razor b/src/GmRelay.Web/Components/App.razor index 853d483..cc6732c 100644 --- a/src/GmRelay.Web/Components/App.razor +++ b/src/GmRelay.Web/Components/App.razor @@ -13,6 +13,7 @@ + @@ -23,6 +24,14 @@ diff --git a/src/GmRelay.Web/Components/Layout/MainLayout.razor.css b/src/GmRelay.Web/Components/Layout/MainLayout.razor.css index 4d1d93a..47c29e4 100644 --- a/src/GmRelay.Web/Components/Layout/MainLayout.razor.css +++ b/src/GmRelay.Web/Components/Layout/MainLayout.razor.css @@ -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 { diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index babfc9e..3bdfd46 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -56,7 +56,7 @@ - + diff --git a/src/GmRelay.Web/Components/Pages/MiniApp.razor b/src/GmRelay.Web/Components/Pages/MiniApp.razor new file mode 100644 index 0000000..af033f1 --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/MiniApp.razor @@ -0,0 +1,70 @@ +@page "/miniapp" +@using Microsoft.AspNetCore.Components.Authorization +@inject IJSRuntime JS +@inject NavigationManager Navigation + +Mini App Dashboard — GM-Relay + +
+
+ +

GM-Relay

+

@statusMessage

+ + @if (showFallback) + { + Войти через Telegram + } +
+
+ +@code { + private string statusMessage = "Открываем dashboard внутри Telegram..."; + private bool showFallback; + + [CascadingParameter] + private Task? 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( + "authenticateTelegramMiniApp", + "/auth/telegram-webapp", + "/"); + + if (!authenticated) + { + statusMessage = "Mini App доступен из Telegram. Для браузера используйте обычный вход."; + showFallback = true; + StateHasChanged(); + } + } + catch (JSException) + { + statusMessage = "Не удалось получить данные Telegram Mini App. Попробуйте открыть dashboard из бота."; + showFallback = true; + StateHasChanged(); + } + } +} diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index 324474b..72f9f32 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -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 - { - 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 + { + 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); diff --git a/src/GmRelay.Web/Services/TelegramAuthService.cs b/src/GmRelay.Web/Services/TelegramAuthService.cs index 8d8c74f..a6dbb4a 100644 --- a/src/GmRelay.Web/Services/TelegramAuthService.cs +++ b/src/GmRelay.Web/Services/TelegramAuthService.cs @@ -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; + } + } } diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index 23f5215..ce9feee 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -1,5 +1,5 @@ /* ============================================ - GM-Relay Design System v1.8.2 + GM-Relay Design System v1.9.0 Dark RPG Dashboard Theme ============================================ */ @@ -842,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; @@ -928,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; } diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramMiniAppEntryPointTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramMiniAppEntryPointTests.cs new file mode 100644 index 0000000..5aad54d --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Telegram/TelegramMiniAppEntryPointTests.cs @@ -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", 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}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/MiniAppDashboardTests.cs b/tests/GmRelay.Bot.Tests/Web/MiniAppDashboardTests.cs new file mode 100644 index 0000000..b68d08d --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/MiniAppDashboardTests.cs @@ -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}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs index f5fba30..c1460b9 100644 --- a/tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs @@ -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 + { + ["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 + { + ["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 + { + ["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 @@ -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 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 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(); + } }