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

This commit is contained in:
2026-04-23 21:08:41 +03:00
parent 93e7c1ac66
commit bb8cbb7a40
17 changed files with 716 additions and 234 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 1.1.2 VERSION: 1.1.3
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.1.2</Version> <Version>1.1.3</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.2 image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.3
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.2 image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.3
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);
}
}
@@ -1,11 +1,8 @@
using System.Text.RegularExpressions;
using Dapper; using Dapper;
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 +13,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;
string? link = null;
var scheduledTimes = new List<DateTimeOffset>();
foreach (var line in text.Split('\n')) foreach (var timeInput in parseResult.PastTimeInputs)
{ {
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,10 +40,12 @@ 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;
var chatTitle = message.Chat.Title ?? "Private Chat"; var chatTitle = message.Chat.Title ?? "Private Chat";
@@ -67,20 +54,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 +85,36 @@ 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, 'Planned', @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
},
transaction); transaction);
sessions.Add(new SessionBatchDto(sessionId, dt.UtcDateTime, "Planned")); sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, "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 +123,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,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,38 +152,15 @@ 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
@@ -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);
} }
@@ -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);
}
}
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
@@ -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);
}
}
@@ -0,0 +1,47 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
namespace GmRelay.Bot.Tests.Rendering;
public sealed class SessionBatchRendererTests
{
[Fact]
public void Render_ShouldOrderSessionsAndSkipButtonsForClosedStatuses()
{
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), "Planned"),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), "Cancelled"),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), "RecruitmentClosed")
};
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.Single(result.Markup.InlineKeyboard);
Assert.Collection(
buttons.Select(button => button.CallbackData),
callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"cancel_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData));
}
}
@@ -66,6 +66,27 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal(sessionId, session.Id); 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] [Fact]
public async Task UpdateSessionForGmAsync_Throws_WhenSessionBelongsToAnotherGm() public async Task UpdateSessionForGmAsync_Throws_WhenSessionBelongsToAnotherGm()
{ {
@@ -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();
}
}