Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93e7c1ac66 | |||
| 4d6651827b | |||
| 9e7a202f42 | |||
| 1c4cfb71c0 | |||
| ecc2236937 | |||
| 3002db6534 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 1.1.0
|
VERSION: 1.1.2
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.1.0</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
@@ -18,7 +18,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
bot:
|
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
|
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.0
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -22,6 +22,21 @@
|
|||||||
<Routes @rendermode="InteractiveServer" />
|
<Routes @rendermode="InteractiveServer" />
|
||||||
<ReconnectModal />
|
<ReconnectModal />
|
||||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
<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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</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.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,20 +75,29 @@
|
|||||||
[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))
|
||||||
{
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
model.Title = session.Title;
|
model.Title = session.Title;
|
||||||
// Convert UTC to Moscow for the picker
|
|
||||||
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
|
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
|
||||||
model.JoinLink = session.JoinLink;
|
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
|
||||||
|
|||||||
@@ -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))
|
|
||||||
{
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
groups = await SessionService.GetGroupsForGmAsync(telegramId);
|
groups = await SessionService.GetGroupsForGmAsync(telegramId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<PageTitle>Вход — GM-Relay</PageTitle>
|
<PageTitle>Вход — GM-Relay</PageTitle>
|
||||||
|
|
||||||
@@ -18,13 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div id="telegram-login-container">
|
<div id="telegram-login-container"></div>
|
||||||
<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>
|
</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
|
// 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);
|
||||||
|
}
|
||||||
@@ -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(
|
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);
|
||||||
|
}
|
||||||
@@ -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