feat: add telegram mini app dashboard
Deploy Telegram Bot / build-and-push (push) Successful in 23s
Deploy Telegram Bot / deploy (push) Successful in 10s

This commit is contained in:
2026-04-28 14:56:55 +03:00
parent 5082dd4fcf
commit 41f2ea6e90
21 changed files with 698 additions and 26 deletions
@@ -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":
@@ -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);
}
}
+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 {
@@ -56,7 +56,7 @@
</button>
</form>
<div class="nav-version">v1.8.2</div>
<div class="nav-version">v1.9.0</div>
</div>
</Authorized>
<NotAuthorized>
@@ -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);
@@ -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;
}
}
}
+49 -1
View File
@@ -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;
}