4 Commits

Author SHA1 Message Date
Toutsu 93e7c1ac66 chore: поднятие версии до 1.1.2 во всех файлах конфигурации
Deploy Telegram Bot / build-and-push (push) Successful in 42s
Deploy Telegram Bot / deploy (push) Successful in 12s
2026-04-23 20:49:01 +03:00
Toutsu 4d6651827b fix: skip stale pending updates on startup
Deploy Telegram Bot / build-and-push (push) Successful in 4m24s
Deploy Telegram Bot / deploy (push) Successful in 18s
2026-04-23 20:42:16 +03:00
Toutsu 9e7a202f42 fix: redact bot secrets in startup logs
Deploy Telegram Bot / build-and-push (push) Successful in 4m24s
Deploy Telegram Bot / deploy (push) Successful in 21s
2026-04-23 20:28:52 +03:00
Toutsu 1c4cfb71c0 fix: close web access to foreign groups and sessions
Deploy Telegram Bot / build-and-push (push) Successful in 7m25s
Deploy Telegram Bot / deploy (push) Successful in 18s
2026-04-23 20:09:22 +03:00
26 changed files with 624 additions and 77 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 1.1.1 VERSION: 1.1.2
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.1.1</Version> <Version>1.1.2</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+2 -2
View File
@@ -18,7 +18,7 @@ services:
retries: 10 retries: 10
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.1 image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.2
container_name: gmrelay_bot container_name: gmrelay_bot
restart: always restart: always
network_mode: host network_mode: host
@@ -30,7 +30,7 @@ services:
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}" - "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}"
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.1 image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.2
container_name: gmrelay_web container_name: gmrelay_web
restart: always restart: always
network_mode: host network_mode: host
+1 -1
View File
@@ -22,7 +22,7 @@
</ItemGroup> </ItemGroup>
<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" Version="2.1.72" />
<PackageReference Include="Dapper.AOT" Version="1.0.48" /> <PackageReference Include="Dapper.AOT" Version="1.0.48" />
<PackageReference Include="dbup-postgresql" Version="7.0.1" /> <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;
using Telegram.Bot.Types.Enums; 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. /// Stateless — all state is in PostgreSQL. Safe to restart at any time.
/// </summary> /// </summary>
public sealed class TelegramBotService( public sealed class TelegramBotService(
ITelegramBotClient bot, ITelegramUpdateSource updateSource,
UpdateRouter router, ITelegramUpdateHandler updateHandler,
ILogger<TelegramBotService> logger) : BackgroundService ILogger<TelegramBotService> logger) : BackgroundService
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
logger.LogInformation("Telegram bot polling started"); logger.LogInformation("Telegram bot polling started");
// Skip any pending updates from before this startup var offset = await GetStartupOffsetAsync(stoppingToken);
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;
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
try try
{ {
var updates = await bot.GetUpdates( var updates = await updateSource.GetUpdatesAsync(
offset: offset, offset: offset,
timeout: 30, timeout: 30,
allowedUpdates: [UpdateType.Message, UpdateType.CallbackQuery], allowedUpdates: [UpdateType.Message, UpdateType.CallbackQuery],
@@ -47,7 +32,7 @@ public sealed class TelegramBotService(
{ {
try try
{ {
await router.RouteAsync(update, stoppingToken); await updateHandler.RouteAsync(update, stoppingToken);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -70,4 +55,33 @@ public sealed class TelegramBotService(
logger.LogInformation("Telegram bot polling stopped"); 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, HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
HandleRescheduleVoteHandler rescheduleVoteHandler, HandleRescheduleVoteHandler rescheduleVoteHandler,
ITelegramBotClient bot, ITelegramBotClient bot,
ILogger<UpdateRouter> logger) ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
{ {
public async Task RouteAsync(Update update, CancellationToken ct) public async Task RouteAsync(Update update, CancellationToken ct)
{ {
+9 -1
View File
@@ -4,6 +4,7 @@ using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Database; using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Bot.Infrastructure.Logging;
using GmRelay.Bot.Infrastructure.Scheduling; using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Bot.Infrastructure.Telegram;
using Npgsql; using Npgsql;
@@ -20,11 +21,16 @@ builder.AddServiceDefaults();
builder.Services.AddSingleton<NpgsqlDataSource>(sp => builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
{ {
var config = sp.GetRequiredService<IConfiguration>(); var config = sp.GetRequiredService<IConfiguration>();
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var connectionString = config.GetConnectionString("gmrelaydb") var connectionString = config.GetConnectionString("gmrelaydb")
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb."); "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); 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."); "Telegram:BotToken is required. Set via environment variable Telegram__BotToken or appsettings.json.");
return new TelegramBotClient(token); return new TelegramBotClient(token);
}); });
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
// ── Feature handlers (explicit registration — AOT safe) ────────────── // ── Feature handlers (explicit registration — AOT safe) ──────────────
builder.Services.AddSingleton<SendConfirmationHandler>(); builder.Services.AddSingleton<SendConfirmationHandler>();
@@ -57,6 +64,7 @@ builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
// ── Telegram infrastructure ────────────────────────────────────────── // ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>(); builder.Services.AddSingleton<UpdateRouter>();
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
builder.Services.AddHostedService<TelegramBotService>(); builder.Services.AddHostedService<TelegramBotService>();
// ── Session scheduler ──────────────────────────────────────────────── // ── Session scheduler ────────────────────────────────────────────────
@@ -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.Web.Services
@using GmRelay.Shared.Domain @using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize] @attribute [Authorize]
@inject SessionService SessionService @inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation @inject NavigationManager Navigation
<PageTitle>Редактирование сессии — GM-Relay</PageTitle> <PageTitle>Редактирование сессии — GM-Relay</PageTitle>
@@ -73,19 +75,28 @@
[Parameter] public Guid SessionId { get; set; } [Parameter] public Guid SessionId { get; set; }
private WebSession? session; private WebSession? session;
private SessionEditModel model = new(); private SessionEditModel model = new();
private bool isSubmitting = false; private bool isSubmitting;
private string? errorMessage; private string? errorMessage;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
session = await SessionService.GetSessionAsync(SessionId); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (session != null) if (!authState.User.TryGetTelegramId(out var telegramId))
{ {
model.Title = session.Title; Navigation.NavigateTo("/access-denied");
// Convert UTC to Moscow for the picker return;
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
model.JoinLink = session.JoinLink;
} }
session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
if (session is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
model.Title = session.Title;
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
model.JoinLink = session.JoinLink;
} }
private async Task HandleSubmit() private async Task HandleSubmit()
@@ -95,13 +106,22 @@
try try
{ {
// The value from <input type="datetime-local"> is considered as "unspecified" or local to browser. var authState = await AuthStateProvider.GetAuthenticationStateAsync();
// We treat it as Moscow time (UTC+3) and convert to UTC. if (!authState.User.TryGetTelegramId(out var telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime; 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}"); Navigation.NavigateTo($"/group/{session!.GroupId}");
} }
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex) catch (Exception ex)
{ {
errorMessage = "Не удалось сохранить изменения: " + ex.Message; errorMessage = "Не удалось сохранить изменения: " + ex.Message;
@@ -2,8 +2,11 @@
@using GmRelay.Web.Services @using GmRelay.Web.Services
@using GmRelay.Shared.Domain @using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize] @attribute [Authorize]
@inject SessionService SessionService @inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Сессии группы — GM-Relay</PageTitle> <PageTitle>Сессии группы — GM-Relay</PageTitle>
@@ -112,7 +115,18 @@
protected override async Task OnInitializedAsync() 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 private string GetStatusClass(string status) => status switch
+7 -4
View File
@@ -3,8 +3,9 @@
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using GmRelay.Web.Services @using GmRelay.Web.Services
@attribute [Authorize] @attribute [Authorize]
@inject SessionService SessionService @inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Панель управления — GM-Relay</PageTitle> <PageTitle>Панель управления — GM-Relay</PageTitle>
@@ -88,10 +89,12 @@
var user = authState.User; var user = authState.User;
userName = user.Identity?.Name ?? "Мастер Игры"; userName = user.Identity?.Name ?? "Мастер Игры";
var telegramIdClaim = user.FindFirst("TelegramId")?.Value; if (!user.TryGetTelegramId(out var telegramId))
if (long.TryParse(telegramIdClaim, out var telegramId))
{ {
groups = await SessionService.GetGroupsForGmAsync(telegramId); Navigation.NavigateTo("/access-denied");
return;
} }
groups = await SessionService.GetGroupsForGmAsync(telegramId);
} }
} }
+2 -1
View File
@@ -21,7 +21,8 @@ builder.AddNpgsqlDataSource("gmrelaydb");
// Add Services // Add Services
builder.Services.AddSingleton<TelegramAuthService>(); builder.Services.AddSingleton<TelegramAuthService>();
builder.Services.AddSingleton<SessionService>(); builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>();
// Add Bot Client // Add Bot Client
builder.Services.AddSingleton<ITelegramBotClient>(sp => 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);
}
+10
View File
@@ -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}'.");
+33 -16
View File
@@ -12,7 +12,7 @@ public sealed record WebSession(Guid Id, Guid GroupId, string Title, DateTime Sc
public sealed class SessionService( public sealed class SessionService(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
ILogger<SessionService> logger) ILogger<SessionService> logger) : ISessionStore
{ {
public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId)
{ {
@@ -22,6 +22,14 @@ public sealed class SessionService(
new { GmId = gmId })).ToList(); 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) public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
@@ -49,29 +57,41 @@ public sealed class SessionService(
new { SessionId = sessionId }); 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 conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync(); await using var transaction = await conn.BeginTransactionAsync();
// 1. Fetch current session with all required columns for WebSession record var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>(
var oldSession = await conn.QuerySingleAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, @"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, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId g.telegram_chat_id AS TelegramChatId
FROM sessions s FROM sessions s
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @Id", WHERE s.id = @Id AND s.group_id = @GroupId",
new { Id = sessionId }, transaction); new { Id = sessionId, GroupId = groupId },
// 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 },
transaction); 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( await conn.ExecuteAsync(
"UPDATE sessions SET title = @Title WHERE batch_id = @BatchId", "UPDATE sessions SET title = @Title WHERE batch_id = @BatchId",
new { Title = title, BatchId = oldSession.BatchId }, new { Title = title, BatchId = oldSession.BatchId },
@@ -79,7 +99,6 @@ public sealed class SessionService(
await transaction.CommitAsync(); await transaction.CommitAsync();
// 4. Send Telegram Notification
var timeChanged = oldSession.ScheduledAt != scheduledAt; var timeChanged = oldSession.ScheduledAt != scheduledAt;
var notification = $"🔄 <b>Мастер обновил игру!</b>\n\n" + var notification = $"🔄 <b>Мастер обновил игру!</b>\n\n" +
$"📌 <b>{System.Net.WebUtility.HtmlEncode(title)}</b>\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); await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
// 5. Update Original Batch Message
if (oldSession.BatchMessageId.HasValue) if (oldSession.BatchMessageId.HasValue)
{ {
await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title); await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title);
@@ -124,7 +142,6 @@ public sealed class SessionService(
} }
catch (Exception ex) 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); logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId);
} }
} }
@@ -20,6 +20,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\GmRelay.Bot\GmRelay.Bot.csproj" /> <ProjectReference Include="..\..\src\GmRelay.Bot\GmRelay.Bot.csproj" />
<ProjectReference Include="..\..\src\GmRelay.Web\GmRelay.Web.csproj" />
</ItemGroup> </ItemGroup>
</Project> </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);
}
-10
View File
@@ -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;
}
}
}