Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93e7c1ac66 | |||
| 4d6651827b | |||
| 9e7a202f42 | |||
| 1c4cfb71c0 | |||
| ecc2236937 | |||
| 3002db6534 |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 1.1.0
|
||||
VERSION: 1.1.2
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>1.1.0</Version>
|
||||
<Version>1.1.2</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
bot:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.2
|
||||
container_name: gmrelay_bot
|
||||
restart: always
|
||||
network_mode: host
|
||||
@@ -30,7 +30,7 @@ services:
|
||||
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}"
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.2
|
||||
container_name: gmrelay_web
|
||||
restart: always
|
||||
network_mode: host
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql" Version="13.2.1" />
|
||||
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
|
||||
<PackageReference Include="Dapper" Version="2.1.72" />
|
||||
<PackageReference Include="Dapper.AOT" Version="1.0.48" />
|
||||
<PackageReference Include="dbup-postgresql" Version="7.0.1" />
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Logging;
|
||||
|
||||
public static partial class SecretRedactor
|
||||
{
|
||||
public static string RedactConnectionString(string? connectionString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var builder = new NpgsqlConnectionStringBuilder(connectionString);
|
||||
if (!string.IsNullOrWhiteSpace(builder.Password))
|
||||
{
|
||||
builder.Password = "***";
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return RedactText(connectionString);
|
||||
}
|
||||
}
|
||||
|
||||
public static string RedactText(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return SecretKeyValueRegex().Replace(
|
||||
text,
|
||||
static match => $"{match.Groups["key"].Value}={GetRedactedValue()}");
|
||||
}
|
||||
|
||||
private static string GetRedactedValue() => "***";
|
||||
|
||||
[GeneratedRegex(@"(?<key>password|pwd|passwd|token|secret|api[-_]?key)\s*=\s*(?<value>[^;\s,]+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SecretKeyValueRegex();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Telegram.Bot.Types;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
public interface ITelegramUpdateHandler
|
||||
{
|
||||
Task RouteAsync(Update update, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
public interface ITelegramUpdateSource
|
||||
{
|
||||
Task<Update[]> GetUpdatesAsync(
|
||||
int offset,
|
||||
int? limit = null,
|
||||
int? timeout = null,
|
||||
IEnumerable<UpdateType>? allowedUpdates = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
|
||||
@@ -9,35 +8,21 @@ namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
/// Stateless — all state is in PostgreSQL. Safe to restart at any time.
|
||||
/// </summary>
|
||||
public sealed class TelegramBotService(
|
||||
ITelegramBotClient bot,
|
||||
UpdateRouter router,
|
||||
ITelegramUpdateSource updateSource,
|
||||
ITelegramUpdateHandler updateHandler,
|
||||
ILogger<TelegramBotService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation("Telegram bot polling started");
|
||||
|
||||
// Skip any pending updates from before this startup
|
||||
try
|
||||
{
|
||||
var pending = await bot.GetUpdates(offset: -1, limit: 1, cancellationToken: stoppingToken);
|
||||
if (pending.Length > 0)
|
||||
{
|
||||
logger.LogInformation("Skipped {Count} pending update(s)", pending[^1].Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to clear pending updates, continuing anyway");
|
||||
}
|
||||
|
||||
var offset = 0;
|
||||
var offset = await GetStartupOffsetAsync(stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var updates = await bot.GetUpdates(
|
||||
var updates = await updateSource.GetUpdatesAsync(
|
||||
offset: offset,
|
||||
timeout: 30,
|
||||
allowedUpdates: [UpdateType.Message, UpdateType.CallbackQuery],
|
||||
@@ -47,7 +32,7 @@ public sealed class TelegramBotService(
|
||||
{
|
||||
try
|
||||
{
|
||||
await router.RouteAsync(update, stoppingToken);
|
||||
await updateHandler.RouteAsync(update, stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -70,4 +55,33 @@ public sealed class TelegramBotService(
|
||||
|
||||
logger.LogInformation("Telegram bot polling stopped");
|
||||
}
|
||||
|
||||
private async Task<int> GetStartupOffsetAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pending = await updateSource.GetUpdatesAsync(
|
||||
offset: -1,
|
||||
limit: 1,
|
||||
cancellationToken: stoppingToken);
|
||||
|
||||
if (pending.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var startupOffset = pending[^1].Id + 1;
|
||||
logger.LogInformation(
|
||||
"Skipping pending updates through {LastPendingUpdateId}; starting polling from offset {StartupOffset}",
|
||||
pending[^1].Id,
|
||||
startupOffset);
|
||||
|
||||
return startupOffset;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to determine startup offset, continuing from offset 0");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
public sealed class TelegramUpdateSource(ITelegramBotClient bot) : ITelegramUpdateSource
|
||||
{
|
||||
public Task<Update[]> GetUpdatesAsync(
|
||||
int offset,
|
||||
int? limit = null,
|
||||
int? timeout = null,
|
||||
IEnumerable<UpdateType>? allowedUpdates = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
bot.GetUpdates(
|
||||
offset: offset,
|
||||
limit: limit,
|
||||
timeout: timeout,
|
||||
allowedUpdates: allowedUpdates,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ public sealed class UpdateRouter(
|
||||
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||||
HandleRescheduleVoteHandler rescheduleVoteHandler,
|
||||
ITelegramBotClient bot,
|
||||
ILogger<UpdateRouter> logger)
|
||||
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
|
||||
{
|
||||
public async Task RouteAsync(Update update, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Bot.Infrastructure.Database;
|
||||
using GmRelay.Bot.Infrastructure.Logging;
|
||||
using GmRelay.Bot.Infrastructure.Scheduling;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using Npgsql;
|
||||
@@ -20,11 +21,16 @@ builder.AddServiceDefaults();
|
||||
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var connectionString = config.GetConnectionString("gmrelaydb")
|
||||
?? throw new InvalidOperationException(
|
||||
"ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb.");
|
||||
|
||||
Console.WriteLine($"[DBG] Master ConnectionString => {connectionString}");
|
||||
var logger = loggerFactory.CreateLogger("GmRelay.Bot.Startup");
|
||||
logger.LogInformation(
|
||||
"Configured PostgreSQL data source with connection string {ConnectionString}",
|
||||
SecretRedactor.RedactConnectionString(connectionString));
|
||||
|
||||
return NpgsqlDataSource.Create(connectionString);
|
||||
});
|
||||
|
||||
@@ -40,6 +46,7 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||
"Telegram:BotToken is required. Set via environment variable Telegram__BotToken or appsettings.json.");
|
||||
return new TelegramBotClient(token);
|
||||
});
|
||||
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
|
||||
|
||||
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
||||
builder.Services.AddSingleton<SendConfirmationHandler>();
|
||||
@@ -57,6 +64,7 @@ builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
|
||||
|
||||
// ── Telegram infrastructure ──────────────────────────────────────────
|
||||
builder.Services.AddSingleton<UpdateRouter>();
|
||||
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
|
||||
builder.Services.AddHostedService<TelegramBotService>();
|
||||
|
||||
// ── Session scheduler ────────────────────────────────────────────────
|
||||
|
||||
@@ -22,6 +22,21 @@
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<ReconnectModal />
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
<script>
|
||||
window.loadTelegramWidget = function (botUsername, authUrl) {
|
||||
var container = document.getElementById('telegram-login-container');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
var script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = 'https://telegram.org/js/telegram-widget.js?22';
|
||||
script.setAttribute('data-telegram-login', botUsername);
|
||||
script.setAttribute('data-size', 'large');
|
||||
script.setAttribute('data-auth-url', authUrl);
|
||||
script.setAttribute('data-request-access', 'write');
|
||||
container.appendChild(script);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
@page "/access-denied"
|
||||
|
||||
<PageTitle>Доступ запрещен — GM-Relay</PageTitle>
|
||||
|
||||
<div class="page-container">
|
||||
<div class="glass-card" style="max-width: 640px;">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">⛔</div>
|
||||
<div class="empty-state-title">Доступ запрещен</div>
|
||||
<p class="empty-state-text">Эта группа или сессия недоступна для вашей учётной записи.</p>
|
||||
<a href="/" class="btn-gm btn-gm-primary">← На главную</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (HttpContext is not null && !HttpContext.Response.HasStarted)
|
||||
{
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
@using GmRelay.Web.Services
|
||||
@using GmRelay.Shared.Domain
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject SessionService SessionService
|
||||
@inject AuthorizedSessionService SessionService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Редактирование сессии — GM-Relay</PageTitle>
|
||||
@@ -73,20 +75,29 @@
|
||||
[Parameter] public Guid SessionId { get; set; }
|
||||
private WebSession? session;
|
||||
private SessionEditModel model = new();
|
||||
private bool isSubmitting = false;
|
||||
private bool isSubmitting;
|
||||
private string? errorMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
session = await SessionService.GetSessionAsync(SessionId);
|
||||
if (session != null)
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
if (!authState.User.TryGetTelegramId(out var telegramId))
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
return;
|
||||
}
|
||||
|
||||
session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
|
||||
if (session is null)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
return;
|
||||
}
|
||||
|
||||
model.Title = session.Title;
|
||||
// Convert UTC to Moscow for the picker
|
||||
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
|
||||
model.JoinLink = session.JoinLink;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSubmit()
|
||||
{
|
||||
@@ -95,13 +106,22 @@
|
||||
|
||||
try
|
||||
{
|
||||
// The value from <input type="datetime-local"> is considered as "unspecified" or local to browser.
|
||||
// We treat it as Moscow time (UTC+3) and convert to UTC.
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
if (!authState.User.TryGetTelegramId(out var telegramId))
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
return;
|
||||
}
|
||||
|
||||
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
||||
|
||||
await SessionService.UpdateSessionAsync(SessionId, model.Title, utcTime, model.JoinLink);
|
||||
await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink);
|
||||
Navigation.NavigateTo($"/group/{session!.GroupId}");
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось сохранить изменения: " + ex.Message;
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
@using GmRelay.Web.Services
|
||||
@using GmRelay.Shared.Domain
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject SessionService SessionService
|
||||
@inject AuthorizedSessionService SessionService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Сессии группы — GM-Relay</PageTitle>
|
||||
|
||||
@@ -112,7 +115,18 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
sessions = await SessionService.GetUpcomingSessionsAsync(GroupId);
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
if (!authState.User.TryGetTelegramId(out var telegramId))
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
return;
|
||||
}
|
||||
|
||||
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
|
||||
if (sessions is null)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
}
|
||||
|
||||
private string GetStatusClass(string status) => status switch
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using GmRelay.Web.Services
|
||||
@attribute [Authorize]
|
||||
@inject SessionService SessionService
|
||||
@inject AuthorizedSessionService SessionService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Панель управления — GM-Relay</PageTitle>
|
||||
|
||||
@@ -88,10 +89,12 @@
|
||||
var user = authState.User;
|
||||
userName = user.Identity?.Name ?? "Мастер Игры";
|
||||
|
||||
var telegramIdClaim = user.FindFirst("TelegramId")?.Value;
|
||||
if (long.TryParse(telegramIdClaim, out var telegramId))
|
||||
if (!user.TryGetTelegramId(out var telegramId))
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
return;
|
||||
}
|
||||
|
||||
groups = await SessionService.GetGroupsForGmAsync(telegramId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@inject NavigationManager Navigation
|
||||
@inject IConfiguration Configuration
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<PageTitle>Вход — GM-Relay</PageTitle>
|
||||
|
||||
@@ -18,13 +19,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div id="telegram-login-container">
|
||||
<script async src="https://telegram.org/js/telegram-widget.js?22"
|
||||
data-telegram-login="@BotUsername"
|
||||
data-size="large"
|
||||
data-auth-url="@AuthUrl"
|
||||
data-request-access="write"></script>
|
||||
</div>
|
||||
<div id="telegram-login-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,4 +41,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await JS.InvokeVoidAsync("loadTelegramWidget", BotUsername, AuthUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ builder.AddNpgsqlDataSource("gmrelaydb");
|
||||
|
||||
// Add Services
|
||||
builder.Services.AddSingleton<TelegramAuthService>();
|
||||
builder.Services.AddSingleton<SessionService>();
|
||||
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
||||
builder.Services.AddScoped<AuthorizedSessionService>();
|
||||
|
||||
// Add Bot Client
|
||||
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
||||
{
|
||||
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
|
||||
sessionStore.GetGroupsForGmAsync(gmId);
|
||||
|
||||
public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId)
|
||||
{
|
||||
if (!await GroupBelongsToGmAsync(groupId, gmId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await sessionStore.GetUpcomingSessionsAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task<WebSession?> GetSessionForGmAsync(Guid sessionId, long gmId)
|
||||
{
|
||||
var session = await sessionStore.GetSessionAsync(sessionId);
|
||||
if (session is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null;
|
||||
}
|
||||
|
||||
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink)
|
||||
{
|
||||
var session = await GetSessionForGmAsync(sessionId, gmId);
|
||||
if (session is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(sessionId, gmId);
|
||||
}
|
||||
|
||||
await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink);
|
||||
}
|
||||
|
||||
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
|
||||
{
|
||||
var group = await sessionStore.GetGroupAsync(groupId);
|
||||
return group?.GmTelegramId == gmId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
public static bool TryGetTelegramId(this ClaimsPrincipal user, out long telegramId) =>
|
||||
long.TryParse(user.FindFirst("TelegramId")?.Value, out telegramId);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
public interface ISessionStore
|
||||
{
|
||||
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
|
||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
||||
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
||||
Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
public sealed class SessionAccessDeniedException(Guid sessionId, long gmId)
|
||||
: InvalidOperationException($"Session '{sessionId}' is not accessible for GM '{gmId}'.");
|
||||
@@ -12,7 +12,7 @@ public sealed record WebSession(Guid Id, Guid GroupId, string Title, DateTime Sc
|
||||
public sealed class SessionService(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
ILogger<SessionService> logger)
|
||||
ILogger<SessionService> logger) : ISessionStore
|
||||
{
|
||||
public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId)
|
||||
{
|
||||
@@ -22,6 +22,14 @@ public sealed class SessionService(
|
||||
new { GmId = gmId })).ToList();
|
||||
}
|
||||
|
||||
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
|
||||
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE id = @GroupId",
|
||||
new { GroupId = groupId });
|
||||
}
|
||||
|
||||
public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
@@ -49,29 +57,41 @@ public sealed class SessionService(
|
||||
new { SessionId = sessionId });
|
||||
}
|
||||
|
||||
public async Task UpdateSessionAsync(Guid sessionId, string title, DateTime scheduledAt, string joinLink)
|
||||
public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
await using var transaction = await conn.BeginTransactionAsync();
|
||||
|
||||
// 1. Fetch current session with all required columns for WebSession record
|
||||
var oldSession = await conn.QuerySingleAsync<WebSession>(
|
||||
var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @Id",
|
||||
new { Id = sessionId }, transaction);
|
||||
|
||||
// 2. Update Session
|
||||
await conn.ExecuteAsync(
|
||||
@"UPDATE sessions SET title = @Title, scheduled_at = @ScheduledAt, join_link = @JoinLink, updated_at = now()
|
||||
WHERE id = @Id",
|
||||
new { Id = sessionId, Title = title, ScheduledAt = scheduledAt, JoinLink = joinLink },
|
||||
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||||
new { Id = sessionId, GroupId = groupId },
|
||||
transaction);
|
||||
|
||||
// 3. Update all sessions in the same batch with new title (optional, usually batch shares title)
|
||||
if (oldSession is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(sessionId, 0);
|
||||
}
|
||||
|
||||
var updatedRows = await conn.ExecuteAsync(
|
||||
@"UPDATE sessions
|
||||
SET title = @Title,
|
||||
scheduled_at = @ScheduledAt,
|
||||
join_link = @JoinLink,
|
||||
updated_at = now()
|
||||
WHERE id = @Id AND group_id = @GroupId",
|
||||
new { Id = sessionId, GroupId = groupId, Title = title, ScheduledAt = scheduledAt, JoinLink = joinLink },
|
||||
transaction);
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
throw new SessionAccessDeniedException(sessionId, 0);
|
||||
}
|
||||
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE sessions SET title = @Title WHERE batch_id = @BatchId",
|
||||
new { Title = title, BatchId = oldSession.BatchId },
|
||||
@@ -79,7 +99,6 @@ public sealed class SessionService(
|
||||
|
||||
await transaction.CommitAsync();
|
||||
|
||||
// 4. Send Telegram Notification
|
||||
var timeChanged = oldSession.ScheduledAt != scheduledAt;
|
||||
var notification = $"🔄 <b>Мастер обновил игру!</b>\n\n" +
|
||||
$"📌 <b>{System.Net.WebUtility.HtmlEncode(title)}</b>\n" +
|
||||
@@ -87,7 +106,6 @@ public sealed class SessionService(
|
||||
|
||||
await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||
|
||||
// 5. Update Original Batch Message
|
||||
if (oldSession.BatchMessageId.HasValue)
|
||||
{
|
||||
await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title);
|
||||
@@ -124,7 +142,6 @@ public sealed class SessionService(
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log but don't throw — message may be too old or have same content
|
||||
logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\GmRelay.Bot\GmRelay.Bot.csproj" />
|
||||
<ProjectReference Include="..\..\src\GmRelay.Web\GmRelay.Web.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,31 @@
|
||||
using GmRelay.Bot.Infrastructure.Logging;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Infrastructure.Logging;
|
||||
|
||||
public sealed class SecretRedactorTests
|
||||
{
|
||||
[Fact]
|
||||
public void RedactConnectionString_ShouldMaskDatabasePassword()
|
||||
{
|
||||
var result = SecretRedactor.RedactConnectionString(
|
||||
"Host=localhost;Port=5432;Database=gmrelay;Username=gmrelay;Password=super-secret");
|
||||
|
||||
Assert.Contains("Password=***", result);
|
||||
Assert.DoesNotContain("super-secret", result);
|
||||
Assert.Contains("Host=localhost", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactText_ShouldMaskKnownSecretKeys()
|
||||
{
|
||||
var result = SecretRedactor.RedactText(
|
||||
"Password=super-secret Token=telegram-token apiKey=service-key");
|
||||
|
||||
Assert.DoesNotContain("super-secret", result);
|
||||
Assert.DoesNotContain("telegram-token", result);
|
||||
Assert.DoesNotContain("service-key", result);
|
||||
Assert.Contains("Password=***", result);
|
||||
Assert.Contains("Token=***", result);
|
||||
Assert.Contains("apiKey=***", result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Reflection;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
|
||||
|
||||
public sealed class TelegramBotServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ShouldStartPollingAfterLastPendingUpdate()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var updateSource = new FakeTelegramUpdateSource(cts);
|
||||
var updateHandler = new FakeTelegramUpdateHandler();
|
||||
var service = new TelegramBotService(
|
||||
updateSource,
|
||||
updateHandler,
|
||||
NullLogger<TelegramBotService>.Instance);
|
||||
|
||||
await InvokeExecuteAsync(service, cts.Token);
|
||||
|
||||
Assert.Empty(updateHandler.HandledUpdates);
|
||||
Assert.Collection(
|
||||
updateSource.Calls,
|
||||
call =>
|
||||
{
|
||||
Assert.Equal(-1, call.Offset);
|
||||
Assert.Equal(1, call.Limit);
|
||||
Assert.Null(call.Timeout);
|
||||
Assert.Null(call.AllowedUpdates);
|
||||
},
|
||||
call =>
|
||||
{
|
||||
Assert.Equal(43, call.Offset);
|
||||
Assert.Null(call.Limit);
|
||||
Assert.Equal(30, call.Timeout);
|
||||
Assert.Equal([UpdateType.Message, UpdateType.CallbackQuery], call.AllowedUpdates);
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task InvokeExecuteAsync(TelegramBotService service, CancellationToken cancellationToken)
|
||||
{
|
||||
var executeAsync = typeof(TelegramBotService).GetMethod(
|
||||
"ExecuteAsync",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(executeAsync);
|
||||
|
||||
var task = executeAsync.Invoke(service, [cancellationToken]) as Task;
|
||||
Assert.NotNull(task);
|
||||
|
||||
await task;
|
||||
}
|
||||
|
||||
private sealed class FakeTelegramUpdateHandler : ITelegramUpdateHandler
|
||||
{
|
||||
public List<Update> HandledUpdates { get; } = [];
|
||||
|
||||
public Task RouteAsync(Update update, CancellationToken ct)
|
||||
{
|
||||
HandledUpdates.Add(update);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeTelegramUpdateSource(CancellationTokenSource cts) : ITelegramUpdateSource
|
||||
{
|
||||
public List<PollCall> Calls { get; } = [];
|
||||
|
||||
public Task<Update[]> GetUpdatesAsync(
|
||||
int offset,
|
||||
int? limit = null,
|
||||
int? timeout = null,
|
||||
IEnumerable<UpdateType>? allowedUpdates = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Calls.Add(new PollCall(offset, limit, timeout, allowedUpdates?.ToArray()));
|
||||
|
||||
return Calls.Count switch
|
||||
{
|
||||
1 => Task.FromResult(new[] { new Update { Id = 42 } }),
|
||||
2 => ReturnAndCancelAsync(),
|
||||
_ => throw new InvalidOperationException("Unexpected polling call.")
|
||||
};
|
||||
}
|
||||
|
||||
private Task<Update[]> ReturnAndCancelAsync()
|
||||
{
|
||||
cts.Cancel();
|
||||
return Task.FromResult(Array.Empty<Update>());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PollCall(
|
||||
int Offset,
|
||||
int? Limit,
|
||||
int? Timeout,
|
||||
UpdateType[]? AllowedUpdates);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace GmRelay.Bot.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using GmRelay.Web.Services;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class AuthorizedSessionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenGroupBelongsToGm()
|
||||
{
|
||||
var gmId = 1001L;
|
||||
var groupId = Guid.NewGuid();
|
||||
var store = new FakeSessionStore(
|
||||
groups:
|
||||
[
|
||||
new(groupId, 42, "Alpha", gmId)
|
||||
],
|
||||
sessions:
|
||||
[
|
||||
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
|
||||
]);
|
||||
var service = new AuthorizedSessionService(store);
|
||||
|
||||
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, gmId);
|
||||
|
||||
Assert.NotNull(sessions);
|
||||
Assert.Single(sessions);
|
||||
Assert.Equal("Session A", sessions[0].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUpcomingSessionsForGmAsync_ReturnsNull_WhenGroupBelongsToAnotherGm()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var store = new FakeSessionStore(
|
||||
groups:
|
||||
[
|
||||
new(groupId, 42, "Alpha", 2002L)
|
||||
]);
|
||||
var service = new AuthorizedSessionService(store);
|
||||
|
||||
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, 1001L);
|
||||
|
||||
Assert.Null(sessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSessionForGmAsync_ReturnsSession_WhenSessionBelongsToOwnedGroup()
|
||||
{
|
||||
var gmId = 1001L;
|
||||
var groupId = Guid.NewGuid();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var store = new FakeSessionStore(
|
||||
groups:
|
||||
[
|
||||
new(groupId, 42, "Alpha", gmId)
|
||||
],
|
||||
sessions:
|
||||
[
|
||||
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
|
||||
]);
|
||||
var service = new AuthorizedSessionService(store);
|
||||
|
||||
var session = await service.GetSessionForGmAsync(sessionId, gmId);
|
||||
|
||||
Assert.NotNull(session);
|
||||
Assert.Equal(sessionId, session.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionForGmAsync_Throws_WhenSessionBelongsToAnotherGm()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var store = new FakeSessionStore(
|
||||
groups:
|
||||
[
|
||||
new(groupId, 42, "Alpha", 2002L)
|
||||
],
|
||||
sessions:
|
||||
[
|
||||
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
|
||||
]);
|
||||
var service = new AuthorizedSessionService(store);
|
||||
|
||||
var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b");
|
||||
|
||||
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
||||
Assert.False(store.UpdateCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionForGmAsync_UpdatesOwnedSession()
|
||||
{
|
||||
var gmId = 1001L;
|
||||
var groupId = Guid.NewGuid();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var scheduledAt = DateTime.UtcNow.AddDays(1);
|
||||
var store = new FakeSessionStore(
|
||||
groups:
|
||||
[
|
||||
new(groupId, 42, "Alpha", gmId)
|
||||
],
|
||||
sessions:
|
||||
[
|
||||
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
|
||||
]);
|
||||
var service = new AuthorizedSessionService(store);
|
||||
|
||||
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b");
|
||||
|
||||
Assert.True(store.UpdateCalled);
|
||||
Assert.Equal(groupId, store.LastUpdatedGroupId);
|
||||
Assert.Equal(sessionId, store.LastUpdatedSessionId);
|
||||
Assert.Equal("Updated", store.LastUpdatedTitle);
|
||||
Assert.Equal(scheduledAt, store.LastUpdatedScheduledAt);
|
||||
Assert.Equal("https://example.test/b", store.LastUpdatedJoinLink);
|
||||
}
|
||||
|
||||
private sealed class FakeSessionStore(
|
||||
IEnumerable<WebGameGroup>? groups = null,
|
||||
IEnumerable<WebSession>? sessions = null) : ISessionStore
|
||||
{
|
||||
private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? [];
|
||||
private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.Id) ?? [];
|
||||
|
||||
public bool UpdateCalled { get; private set; }
|
||||
public Guid? LastUpdatedSessionId { get; private set; }
|
||||
public Guid? LastUpdatedGroupId { get; private set; }
|
||||
public string? LastUpdatedTitle { get; private set; }
|
||||
public DateTime? LastUpdatedScheduledAt { get; private set; }
|
||||
public string? LastUpdatedJoinLink { get; private set; }
|
||||
|
||||
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
|
||||
Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList());
|
||||
|
||||
public Task<WebGameGroup?> GetGroupAsync(Guid groupId)
|
||||
{
|
||||
groupsById.TryGetValue(groupId, out var group);
|
||||
return Task.FromResult(group);
|
||||
}
|
||||
|
||||
public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) =>
|
||||
Task.FromResult(sessionsById.Values.Where(session => session.GroupId == groupId).ToList());
|
||||
|
||||
public Task<WebSession?> GetSessionAsync(Guid sessionId)
|
||||
{
|
||||
sessionsById.TryGetValue(sessionId, out var session);
|
||||
return Task.FromResult(session);
|
||||
}
|
||||
|
||||
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink)
|
||||
{
|
||||
UpdateCalled = true;
|
||||
LastUpdatedSessionId = sessionId;
|
||||
LastUpdatedGroupId = groupId;
|
||||
LastUpdatedTitle = title;
|
||||
LastUpdatedScheduledAt = scheduledAt;
|
||||
LastUpdatedJoinLink = joinLink;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user