8 Commits

Author SHA1 Message Date
Toutsu b80002aa36 refactor: unify session status model
Deploy Telegram Bot / build-and-push (push) Successful in 4m47s
Deploy Telegram Bot / deploy (push) Successful in 19s
Fixes #5
2026-04-24 10:26:45 +03:00
Toutsu bb8cbb7a40 test: cover core bot and web scenarios
Deploy Telegram Bot / build-and-push (push) Successful in 4m18s
Deploy Telegram Bot / deploy (push) Successful in 20s
2026-04-23 21:08:41 +03:00
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
Toutsu ecc2236937 chore: поднятие версии до 1.1.1 во всех файлах конфигурации
Deploy Telegram Bot / build-and-push (push) Successful in 25s
Deploy Telegram Bot / deploy (push) Successful in 8s
2026-04-21 15:47:43 +03:00
Toutsu 3002db6534 fix: загрузка Telegram Login Widget через JS interop для корректной работы с Blazor SPA-навигацией
Deploy Telegram Bot / build-and-push (push) Successful in 3m17s
Deploy Telegram Bot / deploy (push) Successful in 9s
2026-04-21 15:31:12 +03:00
50 changed files with 1471 additions and 335 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 1.1.0 VERSION: 1.1.4
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.1.0</Version> <Version>1.1.4</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.0 image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.4
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.4
container_name: gmrelay_web container_name: gmrelay_web
restart: always restart: always
network_mode: host network_mode: host
@@ -1,14 +1,11 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups; using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp; namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
// ── Command ──────────────────────────────────────────────────────────
public sealed record HandleRsvpCommand( public sealed record HandleRsvpCommand(
Guid SessionId, Guid SessionId,
long TelegramUserId, long TelegramUserId,
@@ -17,13 +14,12 @@ public sealed record HandleRsvpCommand(
long ChatId, long ChatId,
int MessageId); int MessageId);
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined); internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
internal sealed record SessionContext( internal sealed record SessionContext(
string Title, string Title,
DateTime ScheduledAt, DateTime ScheduledAt,
string Status,
long GmTelegramId, long GmTelegramId,
long TelegramChatId); long TelegramChatId);
@@ -33,21 +29,6 @@ internal sealed record ParticipantRsvp(
string? TelegramUsername, string? TelegramUsername,
string RsvpStatus); string RsvpStatus);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Handles the "Буду" / "Не смогу" callback query.
///
/// Flow:
/// 1. Validate that the user is a participant in this session
/// 2. Record or update their RSVP (idempotent)
/// 3. If declined → alert GM privately, revert session if was Confirmed
/// 4. If all non-GM players confirmed → mark session Confirmed, notify group + GM
/// 5. Update the inline keyboard to show current RSVP status
///
/// Concurrency: two simultaneous clicks on different rows don't conflict (MVCC).
/// The last EditMessage wins, which is fine — both reflect up-to-date state.
/// </summary>
public sealed class HandleRsvpHandler( public sealed class HandleRsvpHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
@@ -58,12 +39,11 @@ public sealed class HandleRsvpHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
// ── 1. Validate participant ──────────────────────────────────
var participantExists = await connection.ExecuteScalarAsync<bool>( var participantExists = await connection.ExecuteScalarAsync<bool>(
""" """
SELECT EXISTS ( SELECT EXISTS (
SELECT 1 FROM session_participants sp SELECT 1
FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId AND p.telegram_id = @TelegramUserId
@@ -82,8 +62,6 @@ public sealed class HandleRsvpHandler(
return; return;
} }
// ── 2. Record RSVP (idempotent) ─────────────────────────────
var updated = await connection.ExecuteAsync( var updated = await connection.ExecuteAsync(
""" """
UPDATE session_participants UPDATE session_participants
@@ -98,7 +76,6 @@ public sealed class HandleRsvpHandler(
if (updated == 0) if (updated == 0)
{ {
// Already in this state — just dismiss the loading spinner
var alreadyText = command.Status == RsvpStatus.Confirmed var alreadyText = command.Status == RsvpStatus.Confirmed
? "Вы уже подтвердили участие." ? "Вы уже подтвердили участие."
: "Вы уже отказались от участия."; : "Вы уже отказались от участия.";
@@ -110,11 +87,11 @@ public sealed class HandleRsvpHandler(
return; return;
} }
// ── 3. Load session context ─────────────────────────────────
var session = await connection.QuerySingleAsync<SessionContext>( var session = await connection.QuerySingleAsync<SessionContext>(
""" """
SELECT s.title, s.scheduled_at AS ScheduledAt, SELECT s.title,
s.scheduled_at AS ScheduledAt,
s.status AS Status,
g.gm_telegram_id AS GmTelegramId, g.gm_telegram_id AS GmTelegramId,
g.telegram_chat_id AS TelegramChatId g.telegram_chat_id AS TelegramChatId
FROM sessions s FROM sessions s
@@ -124,26 +101,27 @@ public sealed class HandleRsvpHandler(
new { command.SessionId }, new { command.SessionId },
transaction); transaction);
// ── 4. Handle decline ───────────────────────────────────────
if (command.Status == RsvpStatus.Declined) if (command.Status == RsvpStatus.Declined)
{ {
// Revert session to ConfirmationSent if it was Confirmed var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0);
await connection.ExecuteAsync(
""" if (decision.ShouldRevertSessionToConfirmationSent)
UPDATE sessions {
SET status = @ConfirmationSent, updated_at = now() await connection.ExecuteAsync(
WHERE id = @SessionId AND status = @Confirmed """
""", UPDATE sessions
new SET status = @ConfirmationSent, updated_at = now()
{ WHERE id = @SessionId AND status = @Confirmed
command.SessionId, """,
ConfirmationSent = SessionStatus.ConfirmationSent, new
Confirmed = SessionStatus.Confirmed {
}, command.SessionId,
transaction); ConfirmationSent = SessionStatus.ConfirmationSent,
Confirmed = SessionStatus.Confirmed
},
transaction);
}
// Alert GM immediately via private message
var declinedPlayer = await connection.QuerySingleAsync<string>( var declinedPlayer = await connection.QuerySingleAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId", "SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
new { command.TelegramUserId }, new { command.TelegramUserId },
@@ -151,7 +129,6 @@ public sealed class HandleRsvpHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
// Send alert outside transaction (network call)
try try
{ {
await bot.SendMessage( await bot.SendMessage(
@@ -161,24 +138,22 @@ public sealed class HandleRsvpHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", command.SessionId);
command.SessionId);
} }
await bot.AnswerCallbackQuery( await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId, callbackQueryId: command.CallbackQueryId,
text: "Вы отказались от участия.", text: decision.CallbackText,
cancellationToken: ct); cancellationToken: ct);
} }
// ── 5. Handle confirm — check if ALL confirmed ──────────────
else else
{ {
var counts = await connection.QuerySingleAsync<RsvpCounts>( var counts = await connection.QuerySingleAsync<RsvpCounts>(
""" """
SELECT SELECT
count(*) AS Total, count(*) AS Total,
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed, count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
FROM session_participants FROM session_participants
WHERE session_id = @SessionId AND is_gm = false WHERE session_id = @SessionId AND is_gm = false
""", """,
@@ -190,9 +165,9 @@ public sealed class HandleRsvpHandler(
}, },
transaction); transaction);
var allConfirmed = counts.Confirmed == counts.Total; var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed);
if (allConfirmed) if (decision.ShouldMarkSessionConfirmed)
{ {
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
@@ -206,9 +181,8 @@ public sealed class HandleRsvpHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
if (allConfirmed) if (decision.ShouldNotifyGroup)
{ {
// Notify group
try try
{ {
await bot.SendMessage( await bot.SendMessage(
@@ -218,11 +192,12 @@ public sealed class HandleRsvpHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", command.SessionId);
command.SessionId);
} }
}
// Notify GM privately if (decision.ShouldNotifyGm)
{
try try
{ {
await bot.SendMessage( await bot.SendMessage(
@@ -232,27 +207,20 @@ public sealed class HandleRsvpHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", command.SessionId);
command.SessionId);
} }
} }
await bot.AnswerCallbackQuery( await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId, callbackQueryId: command.CallbackQueryId,
text: "Вы подтвердили участие!", text: decision.CallbackText,
cancellationToken: ct); cancellationToken: ct);
} }
// ── 6. Update inline keyboard message ───────────────────────
await UpdateConfirmationMessage(command, session, ct); await UpdateConfirmationMessage(command, session, ct);
} }
/// <summary> private async Task UpdateConfirmationMessage(HandleRsvpCommand command, SessionContext session, CancellationToken ct)
/// Re-renders the confirmation message with current RSVP statuses.
/// </summary>
private async Task UpdateConfirmationMessage(
HandleRsvpCommand command, SessionContext session, CancellationToken ct)
{ {
try try
{ {
@@ -260,10 +228,10 @@ public sealed class HandleRsvpHandler(
var participants = (await connection.QueryAsync<ParticipantRsvp>( var participants = (await connection.QueryAsync<ParticipantRsvp>(
""" """
SELECT p.telegram_id AS TelegramId, SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername, p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus sp.rsvp_status AS RsvpStatus
FROM session_participants sp FROM session_participants sp
JOIN players p ON p.id = sp.player_id JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false WHERE sp.session_id = @SessionId AND sp.is_gm = false
@@ -279,34 +247,47 @@ public sealed class HandleRsvpHandler(
{ {
$"🎲 Подтвердите участие в «{session.Title}»", $"🎲 Подтвердите участие в «{session.Title}»",
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)", $"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
"" string.Empty
}; };
foreach (var p in confirmed) foreach (var participant in confirmed)
lines.Add($" ✅ {FormatName(p)}"); {
foreach (var p in declined) lines.Add($" ✅ {FormatName(participant)}");
lines.Add($" ❌ ~~{FormatName(p)}~~"); }
foreach (var p in pending)
lines.Add($" ⏳ {FormatName(p)}");
lines.Add(""); foreach (var participant in declined)
{
lines.Add($" ❌ ~~{FormatName(participant)}~~");
}
foreach (var participant in pending)
{
lines.Add($" ⏳ {FormatName(participant)}");
}
lines.Add(string.Empty);
if (confirmed.Count == participants.Count) if (confirmed.Count == participants.Count)
{
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})"); lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})");
}
else if (declined.Count > 0) else if (declined.Count > 0)
{
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)"); lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)");
}
else else
{
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})"); lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})");
}
var text = string.Join("\n", lines); var text = string.Join("\n", lines);
// Keep buttons unless everyone confirmed
var replyMarkup = confirmed.Count == participants.Count var replyMarkup = confirmed.Count == participants.Count
? null ? null
: new InlineKeyboardMarkup([ : new InlineKeyboardMarkup([
[ [
InlineKeyboardButton.WithCallbackData("\u2705 Буду", $"rsvp:confirm:{command.SessionId}"), InlineKeyboardButton.WithCallbackData(" Буду", $"rsvp:confirm:{command.SessionId}"),
InlineKeyboardButton.WithCallbackData("\u274c Не смогу", $"rsvp:decline:{command.SessionId}") InlineKeyboardButton.WithCallbackData(" Не смогу", $"rsvp:decline:{command.SessionId}")
] ]
]); ]);
@@ -319,12 +300,10 @@ public sealed class HandleRsvpHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
// EditMessage can fail if message is too old or unchanged — non-critical logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId);
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}",
command.SessionId);
} }
} }
private static string FormatName(ParticipantRsvp p) => private static string FormatName(ParticipantRsvp participant) =>
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName; participant.TelegramUsername is not null ? $"@{participant.TelegramUsername}" : participant.DisplayName;
} }
@@ -0,0 +1,42 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
internal sealed record RsvpFlowDecision(
string CallbackText,
bool ShouldAlertGm,
bool ShouldRevertSessionToConfirmationSent,
bool ShouldMarkSessionConfirmed,
bool ShouldNotifyGroup,
bool ShouldNotifyGm);
internal static class RsvpFlowRules
{
public static RsvpFlowDecision Evaluate(
string requestedStatus,
string currentSessionStatus,
int totalParticipants,
int confirmedParticipants)
{
if (requestedStatus == RsvpStatus.Declined)
{
return new RsvpFlowDecision(
CallbackText: "\u0412\u044b \u043e\u0442\u043a\u0430\u0437\u0430\u043b\u0438\u0441\u044c \u043e\u0442 \u0443\u0447\u0430\u0441\u0442\u0438\u044f.",
ShouldAlertGm: true,
ShouldRevertSessionToConfirmationSent: currentSessionStatus == SessionStatus.Confirmed,
ShouldMarkSessionConfirmed: false,
ShouldNotifyGroup: false,
ShouldNotifyGm: false);
}
var everyoneConfirmed = confirmedParticipants == totalParticipants;
return new RsvpFlowDecision(
CallbackText: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u0443\u0447\u0430\u0441\u0442\u0438\u0435!",
ShouldAlertGm: false,
ShouldRevertSessionToConfirmationSent: false,
ShouldMarkSessionConfirmed: everyoneConfirmed,
ShouldNotifyGroup: everyoneConfirmed,
ShouldNotifyGm: everyoneConfirmed);
}
}
@@ -48,7 +48,10 @@ public sealed class CancelSessionHandler(
} }
// 2. Отменяем сессию // 2. Отменяем сессию
await connection.ExecuteAsync("UPDATE sessions SET status = 'Cancelled' WHERE id = @Id", new { Id = command.SessionId }, transaction); await connection.ExecuteAsync(
"UPDATE sessions SET status = @Status WHERE id = @Id",
new { Id = command.SessionId, Status = SessionStatus.Cancelled },
transaction);
// 3. Загружаем весь батч для перерисовки // 3. Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>( var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@@ -1,11 +1,9 @@
using System.Text.RegularExpressions;
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.CreateSession; namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -16,37 +14,25 @@ public sealed class CreateSessionHandler(
{ {
public async Task HandleAsync(Message message, CancellationToken cancellationToken) public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{ {
var text = message.Text ?? ""; var parseResult = NewSessionCommandParser.Parse(message.Text, DateTimeOffset.UtcNow);
string? title = null; foreach (var timeInput in parseResult.PastTimeInputs)
string? link = null;
var scheduledTimes = new List<DateTimeOffset>();
foreach (var line in text.Split('\n'))
{ {
var trimmed = line.Trim(); await botClient.SendMessage(
if (trimmed.StartsWith("Название:", StringComparison.OrdinalIgnoreCase)) message.Chat.Id,
title = trimmed["Название:".Length..].Trim(); $"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
else if (trimmed.StartsWith("Ссылка:", StringComparison.OrdinalIgnoreCase)) cancellationToken: cancellationToken);
link = trimmed["Ссылка:".Length..].Trim();
else if (trimmed.StartsWith("Время:", StringComparison.OrdinalIgnoreCase))
{
var timeStr = trimmed["Время:".Length..].Trim();
if (MoscowTime.TryParseMoscow(timeStr, out var scheduledAt))
{
if (scheduledAt > DateTimeOffset.UtcNow)
scheduledTimes.Add(scheduledAt);
else
await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Дата {timeStr} находится в прошлом и будет пропущена.", cancellationToken: cancellationToken);
}
else
{
await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Некорректный формат времени '{timeStr}'. Пропущено.", cancellationToken: cancellationToken);
}
}
} }
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(link) || scheduledTimes.Count == 0) foreach (var timeInput in parseResult.InvalidTimeInputs)
{
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
cancellationToken: cancellationToken);
}
if (!parseResult.IsValid)
{ {
await botClient.SendMessage( await botClient.SendMessage(
chatId: message.Chat.Id, chatId: message.Chat.Id,
@@ -55,8 +41,10 @@ public sealed class CreateSessionHandler(
return; return;
} }
var title = parseResult.Title!;
var link = parseResult.Link!;
var gmId = message.From!.Id; var gmId = message.From!.Id;
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"); var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
var gmUsername = message.From.Username; var gmUsername = message.From.Username;
var chatId = message.Chat.Id; var chatId = message.Chat.Id;
@@ -67,20 +55,24 @@ public sealed class CreateSessionHandler(
try try
{ {
// 1. Убеждаемся, что GM зарегистрирован
await connection.ExecuteAsync( await connection.ExecuteAsync(
@"INSERT INTO players (telegram_id, display_name, telegram_username) """
VALUES (@TgId, @Name, @Username) INSERT INTO players (telegram_id, display_name, telegram_username)
ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = EXCLUDED.telegram_username;", VALUES (@TgId, @Name, @Username)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username;
""",
new { TgId = gmId, Name = gmName, Username = gmUsername }, new { TgId = gmId, Name = gmName, Username = gmUsername },
transaction); transaction);
// 2. Убеждаемся, что Группа зарегистрирована
var groupId = await connection.ExecuteScalarAsync<Guid>( var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id) """
VALUES (@ChatId, @ChatName, @GmId) INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name VALUES (@ChatId, @ChatName, @GmId)
RETURNING id;", ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
RETURNING id;
""",
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId }, new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
transaction); transaction);
@@ -94,29 +86,37 @@ public sealed class CreateSessionHandler(
messageThreadId = topic.MessageThreadId; messageThreadId = topic.MessageThreadId;
} }
// 3. Создаем сессии в цикле с общим batch_id
var batchId = Guid.NewGuid(); var batchId = Guid.NewGuid();
var sessions = new List<SessionBatchDto>(); var sessions = new List<SessionBatchDto>();
foreach (var dt in scheduledTimes.OrderBy(d => d)) foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value))
{ {
var sessionId = await connection.ExecuteScalarAsync<Guid>( var sessionId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id) """
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, 'Planned', @ThreadId) INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id)
RETURNING id;", VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId)
new { BatchId = batchId, GroupId = groupId, Title = title, Link = link, ScheduledAt = dt, ThreadId = messageThreadId }, RETURNING id;
""",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = link,
ScheduledAt = scheduledAt,
ThreadId = messageThreadId,
Status = SessionStatus.Planned
},
transaction); transaction);
sessions.Add(new SessionBatchDto(sessionId, dt.UtcDateTime, "Planned")); sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned));
} }
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId); logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
// 4. Отправляем сообщение в чат
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>()); var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
var batchMessage = await botClient.SendMessage( var batchMessage = await botClient.SendMessage(
chatId: chatId, chatId: chatId,
messageThreadId: messageThreadId, messageThreadId: messageThreadId,
@@ -125,12 +125,10 @@ public sealed class CreateSessionHandler(
replyMarkup: renderResult.Markup, replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
// 4b. Сохраняем message_id батч-сообщения для дальнейшего редактирования
await connection.ExecuteAsync( await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId", "UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessage.MessageId, BatchId = batchId }); new { MsgId = batchMessage.MessageId, BatchId = batchId });
// 5. Удаляем исходное сообщение с командой /newsession, чтобы не спамить
try try
{ {
await botClient.DeleteMessage( await botClient.DeleteMessage(
@@ -0,0 +1,69 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record NewSessionParseResult(
string? Title,
string? Link,
IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs,
IReadOnlyList<string> InvalidTimeInputs)
{
public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.Count > 0;
}
internal static class NewSessionCommandParser
{
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
{
string? title = null;
string? link = null;
var scheduledTimes = new List<DateTimeOffset>();
var pastTimeInputs = new List<string>();
var invalidTimeInputs = new List<string>();
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
{
if (line.StartsWith(TitlePrefix, StringComparison.OrdinalIgnoreCase))
{
title = line[TitlePrefix.Length..].Trim();
continue;
}
if (line.StartsWith(LinkPrefix, StringComparison.OrdinalIgnoreCase))
{
link = line[LinkPrefix.Length..].Trim();
continue;
}
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var timeInput = line[TimePrefix.Length..].Trim();
if (!MoscowTime.TryParseMoscow(timeInput, out var scheduledAt))
{
invalidTimeInputs.Add(timeInput);
continue;
}
if (scheduledAt <= nowUtc)
{
pastTimeInputs.Add(timeInput);
continue;
}
scheduledTimes.Add(scheduledAt);
}
return new NewSessionParseResult(title, link, scheduledTimes, pastTimeInputs, invalidTimeInputs);
}
}
@@ -1,5 +1,6 @@
using System.Text; using System.Text;
using Dapper; using Dapper;
using GmRelay.Shared.Domain;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
@@ -21,10 +22,10 @@ public sealed class ExportCalendarHandler(
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
WHERE g.telegram_chat_id = @ChatId WHERE g.telegram_chat_id = @ChatId
AND s.status = 'Planned' AND s.status = @Planned
AND s.scheduled_at > NOW() AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id }); new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -80,10 +80,10 @@ public sealed class DeleteSessionHandler(
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != 'Cancelled' AND s.scheduled_at > NOW() WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new { ChatId = command.ChatId }); new { ChatId = command.ChatId, Cancelled = SessionStatus.Cancelled });
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -23,10 +23,10 @@ public sealed class ListSessionsHandler(
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != 'Cancelled' AND s.scheduled_at > NOW() WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id }); new { ChatId = message.Chat.Id, Cancelled = SessionStatus.Cancelled });
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -154,10 +154,10 @@ public sealed class HandleRescheduleTimeInputHandler(
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
UPDATE sessions SET scheduled_at = @NewTime, status = 'Planned', updated_at = now() UPDATE sessions SET scheduled_at = @NewTime, status = @Status, updated_at = now()
WHERE id = @SessionId WHERE id = @SessionId
""", """,
new { NewTime = newTime, proposal.SessionId }, new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction); transaction);
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -1,25 +1,20 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Sessions.CreateSession;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups; using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
// ── Command ──────────────────────────────────────────────────────────
public sealed record HandleRescheduleVoteCommand( public sealed record HandleRescheduleVoteCommand(
Guid ProposalId, Guid ProposalId,
string Vote, // "yes" or "no" string Vote,
long TelegramUserId, long TelegramUserId,
string CallbackQueryId, string CallbackQueryId,
long ChatId, long ChatId,
int MessageId); int MessageId);
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record VoteProposalDto( internal sealed record VoteProposalDto(
Guid Id, Guid Id,
Guid SessionId, Guid SessionId,
@@ -32,17 +27,6 @@ internal sealed record VoteProposalDto(
int? ConfirmationMessageId, int? ConfirmationMessageId,
int? BatchMessageId); int? BatchMessageId);
internal sealed record VoteCountDto(int Total, int Approved);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Handles "✅ Согласен" / "❌ Против" votes on a reschedule proposal.
///
/// If anyone votes no → proposal rejected, old time stays.
/// If all vote yes → session time updated, batch message re-rendered,
/// session status reset to Planned so confirmation triggers work correctly.
/// </summary>
public sealed class HandleRescheduleVoteHandler( public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
@@ -53,12 +37,15 @@ public sealed class HandleRescheduleVoteHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Load proposal + session info
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>( var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
""" """
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.proposed_at AS ProposedAt, SELECT rp.id AS Id,
s.title AS Title, s.scheduled_at AS CurrentScheduledAt, rp.session_id AS SessionId,
s.batch_id AS BatchId, s.status AS SessionStatus, rp.proposed_at AS ProposedAt,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId,
s.status AS SessionStatus,
s.confirmation_message_id AS ConfirmationMessageId, s.confirmation_message_id AS ConfirmationMessageId,
s.batch_message_id AS BatchMessageId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId g.telegram_chat_id AS TelegramChatId
@@ -72,12 +59,13 @@ public sealed class HandleRescheduleVoteHandler(
if (proposal is null) if (proposal is null)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, await bot.AnswerCallbackQuery(
"Голосование уже завершено или не найдено.", cancellationToken: ct); command.CallbackQueryId,
"Голосование уже завершено или не найдено.",
cancellationToken: ct);
return; return;
} }
// 2. Verify voter is a participant of this session
var playerId = await connection.ExecuteScalarAsync<Guid?>( var playerId = await connection.ExecuteScalarAsync<Guid?>(
""" """
SELECT p.id SELECT p.id
@@ -92,23 +80,52 @@ public sealed class HandleRescheduleVoteHandler(
if (playerId is null) if (playerId is null)
{ {
await bot.AnswerCallbackQuery(command.CallbackQueryId, await bot.AnswerCallbackQuery(
"Вы не являетесь участником этой сессии.", cancellationToken: ct); command.CallbackQueryId,
"Вы не являетесь участником этой сессии.",
cancellationToken: ct);
return; return;
} }
// 3. Record vote (upsert) await connection.ExecuteAsync(
var inserted = await connection.ExecuteAsync(
""" """
INSERT INTO reschedule_votes (proposal_id, player_id, vote) INSERT INTO reschedule_votes (proposal_id, player_id, vote)
VALUES (@ProposalId, @PlayerId, @Vote) VALUES (@ProposalId, @PlayerId, @Vote)
ON CONFLICT (proposal_id, player_id) DO UPDATE SET vote = EXCLUDED.vote, voted_at = now() ON CONFLICT (proposal_id, player_id) DO UPDATE
SET vote = EXCLUDED.vote,
voted_at = now()
""", """,
new { command.ProposalId, PlayerId = playerId.Value, command.Vote }, new { command.ProposalId, PlayerId = playerId.Value, command.Vote },
transaction); transaction);
// 4. Handle "no" vote — immediately reject var participants = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
if (command.Vote == "no") ? new List<VoteParticipantDto>()
: (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false
""",
new { proposal.SessionId },
transaction)).ToList();
var approvedPlayerIds = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
? new HashSet<Guid>()
: (await connection.QueryAsync<Guid>(
"""
SELECT player_id
FROM reschedule_votes
WHERE proposal_id = @ProposalId AND vote = 'yes'
""",
new { command.ProposalId },
transaction)).ToHashSet();
var decision = RescheduleVoteRules.Evaluate(command.Vote, participants.Count, approvedPlayerIds.Count);
if (decision.Outcome == RescheduleVoteOutcome.Rejected)
{ {
await connection.ExecuteAsync( await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id", "UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
@@ -117,12 +134,10 @@ public sealed class HandleRescheduleVoteHandler(
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
// Get voter's name
var voterName = await connection.QuerySingleOrDefaultAsync<string>( var voterName = await connection.QuerySingleOrDefaultAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TgId", "SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
new { TgId = command.TelegramUserId }); new { command.TelegramUserId });
// Update voting message — show rejection
try try
{ {
await bot.EditMessageText( await bot.EditMessageText(
@@ -137,49 +152,26 @@ public sealed class HandleRescheduleVoteHandler(
logger.LogWarning(ex, "Failed to update vote message after rejection"); logger.LogWarning(ex, "Failed to update vote message after rejection");
} }
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы проголосовали против переноса.", cancellationToken: ct); await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId); logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId);
return; return;
} }
// 5. Handle "yes" vote — check if all approved if (decision.ShouldRescheduleSession)
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false
""",
new { proposal.SessionId },
transaction)).ToList();
var approvedPlayerIds = (await connection.QueryAsync<Guid>(
"""
SELECT player_id FROM reschedule_votes
WHERE proposal_id = @ProposalId AND vote = 'yes'
""",
new { command.ProposalId },
transaction)).ToHashSet();
var allApproved = approvedPlayerIds.Count == participants.Count;
if (allApproved)
{ {
// 6. All approved — reschedule! var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero);
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero); // ProposedAt is stored in UTC
// Update session time and reset status to Planned for fresh notification cycle
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
UPDATE sessions UPDATE sessions
SET scheduled_at = @NewTime, SET scheduled_at = @NewTime,
status = 'Planned', status = @Status,
confirmation_message_id = NULL, confirmation_message_id = NULL,
link_message_id = NULL, link_message_id = NULL,
updated_at = now() updated_at = now()
WHERE id = @SessionId WHERE id = @SessionId
""", """,
new { NewTime = newTime, proposal.SessionId }, new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction); transaction);
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -187,19 +179,21 @@ public sealed class HandleRescheduleVoteHandler(
new { Id = command.ProposalId }, new { Id = command.ProposalId },
transaction); transaction);
// Reset all participant RSVP to Pending for the new confirmation cycle if (decision.ShouldResetParticipantRsvps)
await connection.ExecuteAsync( {
""" await connection.ExecuteAsync(
UPDATE session_participants """
SET rsvp_status = 'Pending', responded_at = NULL UPDATE session_participants
WHERE session_id = @SessionId AND is_gm = false SET rsvp_status = 'Pending',
""", responded_at = NULL
new { proposal.SessionId }, WHERE session_id = @SessionId AND is_gm = false
transaction); """,
new { proposal.SessionId },
transaction);
}
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
// Update voting message — show approval
try try
{ {
await bot.EditMessageText( await bot.EditMessageText(
@@ -214,21 +208,24 @@ public sealed class HandleRescheduleVoteHandler(
logger.LogWarning(ex, "Failed to update vote message after approval"); logger.LogWarning(ex, "Failed to update vote message after approval");
} }
// Re-render batch message
await TryUpdateBatchMessage(proposal, ct); await TryUpdateBatchMessage(proposal, ct);
logger.LogInformation("Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})", logger.LogInformation(
proposal.SessionId, newTime, command.ProposalId); "Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
proposal.SessionId,
newTime,
command.ProposalId);
} }
else else
{ {
// Not all voted yet — update the voting message to show progress
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage( var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title, proposal.CurrentScheduledAt, proposal.Title,
proposal.CurrentScheduledAt,
new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero), new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero),
participants, approvedPlayerIds); participants,
approvedPlayerIds);
var keyboard = new InlineKeyboardMarkup([ var keyboard = new InlineKeyboardMarkup([
[ [
@@ -253,15 +250,9 @@ public sealed class HandleRescheduleVoteHandler(
} }
} }
await bot.AnswerCallbackQuery(command.CallbackQueryId, await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
allApproved ? "Вы подтвердили перенос! Все согласны — время обновлено." : "Вы подтвердили перенос!",
cancellationToken: ct);
} }
/// <summary>
/// Re-renders the batch schedule message to reflect the updated session time.
/// If batch_message_id is stored, edits the original message. Otherwise sends a notification.
/// </summary>
private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct) private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct)
{ {
try try
@@ -274,7 +265,9 @@ public sealed class HandleRescheduleVoteHandler(
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>( var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
""" """
SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM session_participants sp FROM session_participants sp
JOIN players p ON sp.player_id = p.id JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id JOIN sessions s ON sp.session_id = s.id
@@ -285,7 +278,6 @@ public sealed class HandleRescheduleVoteHandler(
if (proposal.BatchMessageId.HasValue) if (proposal.BatchMessageId.HasValue)
{ {
// Edit the original batch schedule message in-place
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants); var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText( await bot.EditMessageText(
@@ -298,10 +290,9 @@ public sealed class HandleRescheduleVoteHandler(
} }
else else
{ {
// Fallback for sessions created before V005 migration (no batch_message_id)
await bot.SendMessage( await bot.SendMessage(
chatId: proposal.TelegramChatId, chatId: proposal.TelegramChatId,
text: $"📢 Расписание обновлено! Сессия «{proposal.Title}» перенесена на <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК).", text: $"📣 Расписание обновлено! Сессия «{proposal.Title}» перенесена на <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК).",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct); cancellationToken: ct);
} }
@@ -1,4 +1,5 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
@@ -39,9 +40,9 @@ public sealed class InitiateRescheduleHandler(
SELECT s.title AS Title, g.gm_telegram_id AS GmId SELECT s.title AS Title, g.gm_telegram_id AS GmId
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId AND s.status != 'Cancelled' WHERE s.id = @SessionId AND s.status != @Cancelled
""", """,
new { command.SessionId }); new { command.SessionId, Cancelled = SessionStatus.Cancelled });
if (session is null) if (session is null)
{ {
@@ -0,0 +1,39 @@
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal enum RescheduleVoteOutcome
{
Pending,
Rejected,
Approved
}
internal sealed record RescheduleVoteDecision(
RescheduleVoteOutcome Outcome,
string CallbackText,
bool ShouldRescheduleSession,
bool ShouldResetParticipantRsvps);
internal static class RescheduleVoteRules
{
public static RescheduleVoteDecision Evaluate(string vote, int totalParticipants, int approvedParticipants)
{
if (string.Equals(vote, "no", StringComparison.OrdinalIgnoreCase))
{
return new RescheduleVoteDecision(
Outcome: RescheduleVoteOutcome.Rejected,
CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.",
ShouldRescheduleSession: false,
ShouldResetParticipantRsvps: false);
}
var everyoneApproved = approvedParticipants == totalParticipants;
return new RescheduleVoteDecision(
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending,
CallbackText: everyoneApproved
? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e."
: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!",
ShouldRescheduleSession: everyoneApproved,
ShouldResetParticipantRsvps: everyoneApproved);
}
}
+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,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
@@ -1,3 +1,5 @@
using System.Collections.Frozen;
namespace GmRelay.Shared.Domain; namespace GmRelay.Shared.Domain;
public static class SessionStatus public static class SessionStatus
@@ -6,4 +8,13 @@ public static class SessionStatus
public const string ConfirmationSent = "ConfirmationSent"; public const string ConfirmationSent = "ConfirmationSent";
public const string Confirmed = "Confirmed"; public const string Confirmed = "Confirmed";
public const string Cancelled = "Cancelled"; public const string Cancelled = "Cancelled";
public static IReadOnlySet<string> All { get; } =
new[] { Planned, ConfirmationSent, Confirmed, Cancelled }
.ToFrozenSet(StringComparer.Ordinal);
public static bool IsKnown(string status) => All.Contains(status);
public static bool IsCancelled(string status) =>
string.Equals(status, Cancelled, StringComparison.Ordinal);
} }
@@ -36,14 +36,10 @@ public static class SessionBatchRenderer
messageText += " <i>Пока никто не записался</i>\n"; messageText += " <i>Пока никто не записался</i>\n";
} }
if (session.Status == "Cancelled") if (SessionStatus.IsCancelled(session.Status))
{ {
messageText += "❌ <i>Сессия отменена</i>\n\n"; messageText += "❌ <i>Сессия отменена</i>\n\n";
} }
else if (session.Status == "RecruitmentClosed")
{
messageText += "🔒 <i>Набор завершен</i>\n\n";
}
else else
{ {
messageText += "\n"; messageText += "\n";
+15
View File
@@ -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,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
@@ -120,15 +134,12 @@
SessionStatus.Confirmed => "status-success", SessionStatus.Confirmed => "status-success",
SessionStatus.Cancelled => "status-danger", SessionStatus.Cancelled => "status-danger",
SessionStatus.ConfirmationSent => "status-warning", SessionStatus.ConfirmationSent => "status-warning",
"Recruiting" => "status-info", SessionStatus.Planned => "status-info",
"RecruitmentClosed" => "status-info",
_ => "status-neutral" _ => "status-neutral"
}; };
private string TranslateStatus(string status) => status switch private string TranslateStatus(string status) => status switch
{ {
"Recruiting" => "Набор",
"RecruitmentClosed" => "Набор закрыт",
SessionStatus.Planned => "Запланировано", SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждём подтверждения", SessionStatus.ConfirmationSent => "Ждём подтверждения",
SessionStatus.Confirmed => "Подтверждено", SessionStatus.Confirmed => "Подтверждено",
+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);
} }
} }
+10 -7
View File
@@ -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);
}
}
} }
+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);
} }
} }
@@ -0,0 +1,75 @@
using System.Reflection;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Domain;
public sealed class SessionStatusTests
{
[Fact]
public void All_ShouldContainOnlyCanonicalSessionStatuses()
{
var allProperty = typeof(SessionStatus).GetProperty(
"All",
BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(allProperty);
var allStatusValues = Assert.IsAssignableFrom<IReadOnlySet<string>>(allProperty.GetValue(null));
var expectedStatusValues = new[]
{
SessionStatus.Planned,
SessionStatus.ConfirmationSent,
SessionStatus.Confirmed,
SessionStatus.Cancelled
}
.Order(StringComparer.Ordinal);
Assert.Equal(expectedStatusValues, allStatusValues.Order(StringComparer.Ordinal));
}
[Fact]
public void ProductionSources_ShouldNotReferenceLegacySessionStatuses()
{
var repositoryRoot = FindRepositoryRoot();
var productionFiles = Directory.EnumerateFiles(repositoryRoot, "*.*", SearchOption.AllDirectories)
.Where(path => IsProductionSource(path))
.ToList();
var legacyStatuses = new[] { "Recruit" + "ing", "Recruitment" + "Closed" };
var offenders = productionFiles
.SelectMany(path => legacyStatuses
.Where(status => File.ReadAllText(path).Contains(status, StringComparison.Ordinal))
.Select(status => $"{Path.GetRelativePath(repositoryRoot, path)} contains {status}"))
.ToList();
Assert.Empty(offenders);
}
private static bool IsProductionSource(string path)
{
var extension = Path.GetExtension(path);
var separator = Path.DirectorySeparatorChar;
return path.Contains($"{separator}src{separator}", StringComparison.Ordinal)
&& !path.Contains($"{separator}bin{separator}", StringComparison.Ordinal)
&& !path.Contains($"{separator}obj{separator}", StringComparison.Ordinal)
&& extension is ".cs" or ".razor" or ".sql";
}
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "GM-Relay.slnx")))
{
return current.FullName;
}
current = current.Parent;
}
throw new InvalidOperationException("Could not find repository root.");
}
}
@@ -0,0 +1,50 @@
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Features.Confirmation.HandleRsvp;
public sealed class RsvpFlowRulesTests
{
[Fact]
public void Evaluate_ShouldRevertAndAlert_WhenConfirmedSessionGetsDecline()
{
var decision = RsvpFlowRules.Evaluate(
RsvpStatus.Declined,
SessionStatus.Confirmed,
totalParticipants: 3,
confirmedParticipants: 2);
Assert.True(decision.ShouldAlertGm);
Assert.True(decision.ShouldRevertSessionToConfirmationSent);
Assert.False(decision.ShouldMarkSessionConfirmed);
Assert.Equal("Вы отказались от участия.", decision.CallbackText);
}
[Fact]
public void Evaluate_ShouldMarkConfirmed_WhenLastParticipantConfirms()
{
var decision = RsvpFlowRules.Evaluate(
RsvpStatus.Confirmed,
SessionStatus.ConfirmationSent,
totalParticipants: 3,
confirmedParticipants: 3);
Assert.True(decision.ShouldMarkSessionConfirmed);
Assert.True(decision.ShouldNotifyGroup);
Assert.True(decision.ShouldNotifyGm);
}
[Fact]
public void Evaluate_ShouldKeepWaiting_WhenNotEveryoneConfirmed()
{
var decision = RsvpFlowRules.Evaluate(
RsvpStatus.Confirmed,
SessionStatus.ConfirmationSent,
totalParticipants: 4,
confirmedParticipants: 2);
Assert.False(decision.ShouldMarkSessionConfirmed);
Assert.False(decision.ShouldNotifyGroup);
Assert.False(decision.ShouldNotifyGm);
}
}
@@ -0,0 +1,68 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class NewSessionCommandParserTests
{
[Fact]
public void Parse_ShouldExtractTitleLinkAndUpcomingTimes()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Curse of Strahd
Время: 24.04.2026 19:30
Время: 01.05.2026 20:00
Ссылка: https://example.test/room
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal("Curse of Strahd", result.Title);
Assert.Equal("https://example.test/room", result.Link);
Assert.Equal(
[
new DateTimeOffset(2026, 4, 24, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 1, 17, 0, 0, TimeSpan.Zero)
],
result.ScheduledTimes);
Assert.Empty(result.PastTimeInputs);
Assert.Empty(result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldCollectPastAndInvalidTimes()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
Название: Delta Green
Время: 20.04.2026 19:30
Время: 31.04.2026 19:30
Время: 25.04.2026 18:00
Ссылка: https://example.test/dg
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Single(result.ScheduledTimes);
Assert.Equal(["20.04.2026 19:30"], result.PastTimeInputs);
Assert.Equal(["31.04.2026 19:30"], result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldBeInvalid_WhenRequiredFieldsMissing()
{
var text = """
/newsession
Название: Blades in the Dark
Время: 25.04.2026 19:30
""";
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
Assert.False(result.IsValid);
Assert.Null(result.Link);
}
}
@@ -0,0 +1,32 @@
using GmRelay.Bot.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
public sealed class HandleRescheduleTimeInputHandlerTests
{
[Fact]
public void BuildVotingMessage_ShouldShowApprovedAndPendingParticipants()
{
var approvedId = Guid.NewGuid();
var pendingId = Guid.NewGuid();
var currentTime = new DateTime(2026, 4, 25, 16, 30, 0, DateTimeKind.Utc);
var newTime = new DateTimeOffset(2026, 4, 26, 17, 0, 0, TimeSpan.Zero);
var participants = new List<VoteParticipantDto>
{
new(approvedId, "Alice", "alice"),
new(pendingId, "Bob", null)
};
var text = HandleRescheduleTimeInputHandler.BuildVotingMessage(
"Shadowrun",
currentTime,
newTime,
participants,
[approvedId]);
Assert.Contains("Shadowrun", text);
Assert.Contains("✅ @alice", text);
Assert.Contains("⏳ Bob", text);
Assert.Contains("Голоса: 1/2 ✅", text);
}
}
@@ -0,0 +1,36 @@
using GmRelay.Bot.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
public sealed class RescheduleVoteRulesTests
{
[Fact]
public void Evaluate_ShouldReject_WhenParticipantVotesNo()
{
var decision = RescheduleVoteRules.Evaluate("no", totalParticipants: 4, approvedParticipants: 3);
Assert.Equal(RescheduleVoteOutcome.Rejected, decision.Outcome);
Assert.False(decision.ShouldRescheduleSession);
Assert.Equal("Вы проголосовали против переноса.", decision.CallbackText);
}
[Fact]
public void Evaluate_ShouldApprove_WhenEveryoneVotedYes()
{
var decision = RescheduleVoteRules.Evaluate("yes", totalParticipants: 3, approvedParticipants: 3);
Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome);
Assert.True(decision.ShouldRescheduleSession);
Assert.True(decision.ShouldResetParticipantRsvps);
}
[Fact]
public void Evaluate_ShouldStayPending_WhileVotesOutstanding()
{
var decision = RescheduleVoteRules.Evaluate("yes", totalParticipants: 5, approvedParticipants: 2);
Assert.Equal(RescheduleVoteOutcome.Pending, decision.Outcome);
Assert.False(decision.ShouldRescheduleSession);
Assert.False(decision.ShouldResetParticipantRsvps);
}
}
@@ -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);
}
@@ -0,0 +1,50 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Rendering;
public sealed class SessionBatchRendererTests
{
[Fact]
public void Render_ShouldOrderSessionsAndSkipButtonsForCancelledSessions()
{
var firstSessionId = Guid.NewGuid();
var secondSessionId = Guid.NewGuid();
var cancelledSessionId = Guid.NewGuid();
var sessions = new[]
{
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned)
};
var participants = new[]
{
new ParticipantBatchDto(secondSessionId, "Alice", "alice"),
new ParticipantBatchDto(cancelledSessionId, "Bob", null)
};
var result = SessionBatchRenderer.Render("Campaign", sessions, participants);
var text = result.Text;
var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList();
var firstIndex = text.IndexOf(sessions[2].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
var secondIndex = text.IndexOf(sessions[0].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
var thirdIndex = text.IndexOf(sessions[1].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
Assert.Contains("Campaign", text);
Assert.True(firstIndex < secondIndex);
Assert.True(secondIndex < thirdIndex);
Assert.Contains("@alice", text);
Assert.Contains("Bob", text);
Assert.Equal(2, result.Markup.InlineKeyboard.Count());
Assert.Collection(
buttons.Select(button => button.CallbackData),
callbackData => Assert.Equal($"join_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"cancel_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"reschedule_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"cancel_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData));
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace GmRelay.Bot.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
@@ -0,0 +1,183 @@
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 GetSessionForGmAsync_ReturnsNull_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 session = await service.GetSessionForGmAsync(sessionId, 1001L);
Assert.Null(session);
}
[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;
}
}
}
@@ -0,0 +1,109 @@
using System.Security.Cryptography;
using System.Text;
using GmRelay.Web.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
namespace GmRelay.Bot.Tests.Web;
public sealed class TelegramAuthServiceTests
{
[Fact]
public void Verify_ShouldAcceptValidTelegramPayload()
{
const string botToken = "test-bot-token";
var authDate = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var query = CreateQueryCollection(
botToken,
new Dictionary<string, string>
{
["auth_date"] = authDate,
["first_name"] = "Ada",
["id"] = "424242",
["last_name"] = "Lovelace",
["username"] = "ada"
});
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(query, out var telegramId, out var name);
Assert.True(verified);
Assert.Equal(424242L, telegramId);
Assert.Equal("Ada Lovelace", name);
}
[Fact]
public void Verify_ShouldRejectTamperedHash()
{
const string botToken = "test-bot-token";
var values = new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
["first_name"] = "Ada",
["id"] = "424242"
};
var query = CreateQueryCollection(botToken, values);
var invalidQuery = new QueryCollection(new Dictionary<string, StringValues>(query.ToDictionary(
pair => pair.Key,
pair => pair.Value))
{
["hash"] = "00"
});
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(invalidQuery, out _, out _);
Assert.False(verified);
}
[Fact]
public void Verify_ShouldRejectExpiredPayload()
{
const string botToken = "test-bot-token";
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds().ToString();
var query = CreateQueryCollection(
botToken,
new Dictionary<string, string>
{
["auth_date"] = expiredAuthDate,
["first_name"] = "Ada",
["id"] = "424242"
});
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(query, out _, out _);
Assert.False(verified);
}
private static IConfiguration CreateConfiguration(string botToken) =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Telegram:BotToken"] = botToken
})
.Build();
private static QueryCollection CreateQueryCollection(string botToken, Dictionary<string, string> values)
{
var hash = ComputeTelegramHash(botToken, values);
var queryValues = values.ToDictionary(
pair => pair.Key,
pair => new StringValues(pair.Value));
queryValues["hash"] = new StringValues(hash);
return new QueryCollection(queryValues);
}
private static string ComputeTelegramHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
}