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,14 +1,11 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
// ── Command ──────────────────────────────────────────────────────────
public sealed record HandleRsvpCommand(
Guid SessionId,
long TelegramUserId,
@@ -17,13 +14,12 @@ public sealed record HandleRsvpCommand(
long ChatId,
int MessageId);
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
internal sealed record SessionContext(
string Title,
DateTime ScheduledAt,
string Status,
long GmTelegramId,
long TelegramChatId);
@@ -33,21 +29,6 @@ internal sealed record ParticipantRsvp(
string? TelegramUsername,
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(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
@@ -58,12 +39,11 @@ public sealed class HandleRsvpHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// ── 1. Validate participant ──────────────────────────────────
var participantExists = await connection.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1 FROM session_participants sp
SELECT 1
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId
@@ -82,8 +62,6 @@ public sealed class HandleRsvpHandler(
return;
}
// ── 2. Record RSVP (idempotent) ─────────────────────────────
var updated = await connection.ExecuteAsync(
"""
UPDATE session_participants
@@ -98,7 +76,6 @@ public sealed class HandleRsvpHandler(
if (updated == 0)
{
// Already in this state — just dismiss the loading spinner
var alreadyText = command.Status == RsvpStatus.Confirmed
? "Вы уже подтвердили участие."
: "Вы уже отказались от участия.";
@@ -110,11 +87,11 @@ public sealed class HandleRsvpHandler(
return;
}
// ── 3. Load session context ─────────────────────────────────
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.telegram_chat_id AS TelegramChatId
FROM sessions s
@@ -124,26 +101,27 @@ public sealed class HandleRsvpHandler(
new { command.SessionId },
transaction);
// ── 4. Handle decline ───────────────────────────────────────
if (command.Status == RsvpStatus.Declined)
{
// Revert session to ConfirmationSent if it was Confirmed
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @ConfirmationSent, updated_at = now()
WHERE id = @SessionId AND status = @Confirmed
""",
new
{
command.SessionId,
ConfirmationSent = SessionStatus.ConfirmationSent,
Confirmed = SessionStatus.Confirmed
},
transaction);
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0);
if (decision.ShouldRevertSessionToConfirmationSent)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @ConfirmationSent, updated_at = now()
WHERE id = @SessionId AND status = @Confirmed
""",
new
{
command.SessionId,
ConfirmationSent = SessionStatus.ConfirmationSent,
Confirmed = SessionStatus.Confirmed
},
transaction);
}
// Alert GM immediately via private message
var declinedPlayer = await connection.QuerySingleAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
new { command.TelegramUserId },
@@ -151,7 +129,6 @@ public sealed class HandleRsvpHandler(
await transaction.CommitAsync(ct);
// Send alert outside transaction (network call)
try
{
await bot.SendMessage(
@@ -161,24 +138,22 @@ public sealed class HandleRsvpHandler(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}",
command.SessionId);
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", command.SessionId);
}
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: "Вы отказались от участия.",
text: decision.CallbackText,
cancellationToken: ct);
}
// ── 5. Handle confirm — check if ALL confirmed ──────────────
else
{
var counts = await connection.QuerySingleAsync<RsvpCounts>(
"""
SELECT
count(*) AS Total,
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
count(*) AS Total,
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
FROM session_participants
WHERE session_id = @SessionId AND is_gm = false
""",
@@ -190,9 +165,9 @@ public sealed class HandleRsvpHandler(
},
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(
"""
@@ -206,9 +181,8 @@ public sealed class HandleRsvpHandler(
await transaction.CommitAsync(ct);
if (allConfirmed)
if (decision.ShouldNotifyGroup)
{
// Notify group
try
{
await bot.SendMessage(
@@ -218,11 +192,12 @@ public sealed class HandleRsvpHandler(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}",
command.SessionId);
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", command.SessionId);
}
}
// Notify GM privately
if (decision.ShouldNotifyGm)
{
try
{
await bot.SendMessage(
@@ -232,27 +207,20 @@ public sealed class HandleRsvpHandler(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}",
command.SessionId);
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", command.SessionId);
}
}
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: "Вы подтвердили участие!",
text: decision.CallbackText,
cancellationToken: ct);
}
// ── 6. Update inline keyboard message ───────────────────────
await UpdateConfirmationMessage(command, session, ct);
}
/// <summary>
/// Re-renders the confirmation message with current RSVP statuses.
/// </summary>
private async Task UpdateConfirmationMessage(
HandleRsvpCommand command, SessionContext session, CancellationToken ct)
private async Task UpdateConfirmationMessage(HandleRsvpCommand command, SessionContext session, CancellationToken ct)
{
try
{
@@ -260,10 +228,10 @@ public sealed class HandleRsvpHandler(
var participants = (await connection.QueryAsync<ParticipantRsvp>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus
sp.rsvp_status AS RsvpStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false
@@ -279,34 +247,47 @@ public sealed class HandleRsvpHandler(
{
$"🎲 Подтвердите участие в «{session.Title}»",
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
""
string.Empty
};
foreach (var p in confirmed)
lines.Add($" ✅ {FormatName(p)}");
foreach (var p in declined)
lines.Add($" ❌ ~~{FormatName(p)}~~");
foreach (var p in pending)
lines.Add($" ⏳ {FormatName(p)}");
foreach (var participant in confirmed)
{
lines.Add($" ✅ {FormatName(participant)}");
}
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)
{
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})");
}
else if (declined.Count > 0)
{
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)");
}
else
{
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})");
}
var text = string.Join("\n", lines);
// Keep buttons unless everyone confirmed
var replyMarkup = confirmed.Count == participants.Count
? null
: new InlineKeyboardMarkup([
[
InlineKeyboardButton.WithCallbackData("\u2705 Буду", $"rsvp:confirm:{command.SessionId}"),
InlineKeyboardButton.WithCallbackData("\u274c Не смогу", $"rsvp:decline:{command.SessionId}")
InlineKeyboardButton.WithCallbackData(" Буду", $"rsvp:confirm:{command.SessionId}"),
InlineKeyboardButton.WithCallbackData(" Не смогу", $"rsvp:decline:{command.SessionId}")
]
]);
@@ -319,12 +300,10 @@ public sealed class HandleRsvpHandler(
}
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) =>
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
private static string FormatName(ParticipantRsvp participant) =>
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 GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
@@ -16,37 +13,25 @@ public sealed class CreateSessionHandler(
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{
var text = message.Text ?? "";
string? title = null;
string? link = null;
var scheduledTimes = new List<DateTimeOffset>();
var parseResult = NewSessionCommandParser.Parse(message.Text, DateTimeOffset.UtcNow);
foreach (var line in text.Split('\n'))
foreach (var timeInput in parseResult.PastTimeInputs)
{
var trimmed = line.Trim();
if (trimmed.StartsWith("Название:", StringComparison.OrdinalIgnoreCase))
title = trimmed["Название:".Length..].Trim();
else if (trimmed.StartsWith("Ссылка:", StringComparison.OrdinalIgnoreCase))
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);
}
}
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
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(
chatId: message.Chat.Id,
@@ -55,10 +40,12 @@ public sealed class CreateSessionHandler(
return;
}
var title = parseResult.Title!;
var link = parseResult.Link!;
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 chatId = message.Chat.Id;
var chatTitle = message.Chat.Title ?? "Private Chat";
@@ -67,20 +54,24 @@ public sealed class CreateSessionHandler(
try
{
// 1. Убеждаемся, что GM зарегистрирован
await connection.ExecuteAsync(
@"INSERT INTO players (telegram_id, display_name, telegram_username)
VALUES (@TgId, @Name, @Username)
ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = EXCLUDED.telegram_username;",
"""
INSERT INTO players (telegram_id, display_name, 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 },
transaction);
// 2. Убеждаемся, что Группа зарегистрирована
var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
VALUES (@ChatId, @ChatName, @GmId)
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
RETURNING id;",
"""
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
VALUES (@ChatId, @ChatName, @GmId)
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
RETURNING id;
""",
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
transaction);
@@ -94,29 +85,36 @@ public sealed class CreateSessionHandler(
messageThreadId = topic.MessageThreadId;
}
// 3. Создаем сессии в цикле с общим batch_id
var batchId = Guid.NewGuid();
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>(
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, 'Planned', @ThreadId)
RETURNING id;",
new { BatchId = batchId, GroupId = groupId, Title = title, Link = link, ScheduledAt = dt, ThreadId = messageThreadId },
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, 'Planned', @ThreadId)
RETURNING id;
""",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = link,
ScheduledAt = scheduledAt,
ThreadId = messageThreadId
},
transaction);
sessions.Add(new SessionBatchDto(sessionId, dt.UtcDateTime, "Planned"));
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, "Planned"));
}
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
// 4. Отправляем сообщение в чат
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
var batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
@@ -125,12 +123,10 @@ public sealed class CreateSessionHandler(
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
// 4b. Сохраняем message_id батч-сообщения для дальнейшего редактирования
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessage.MessageId, BatchId = batchId });
// 5. Удаляем исходное сообщение с командой /newsession, чтобы не спамить
try
{
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 GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Sessions.CreateSession;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
// ── Command ──────────────────────────────────────────────────────────
public sealed record HandleRescheduleVoteCommand(
Guid ProposalId,
string Vote, // "yes" or "no"
string Vote,
long TelegramUserId,
string CallbackQueryId,
long ChatId,
int MessageId);
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record VoteProposalDto(
Guid Id,
Guid SessionId,
@@ -32,17 +27,6 @@ internal sealed record VoteProposalDto(
int? ConfirmationMessageId,
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(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
@@ -53,12 +37,15 @@ public sealed class HandleRescheduleVoteHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Load proposal + session info
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.proposed_at AS ProposedAt,
s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId, s.status AS SessionStatus,
SELECT rp.id AS Id,
rp.session_id AS SessionId,
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.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId
@@ -72,12 +59,13 @@ public sealed class HandleRescheduleVoteHandler(
if (proposal is null)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Голосование уже завершено или не найдено.", cancellationToken: ct);
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Голосование уже завершено или не найдено.",
cancellationToken: ct);
return;
}
// 2. Verify voter is a participant of this session
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
@@ -92,23 +80,52 @@ public sealed class HandleRescheduleVoteHandler(
if (playerId is null)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Вы не являетесь участником этой сессии.", cancellationToken: ct);
await bot.AnswerCallbackQuery(
command.CallbackQueryId,
"Вы не являетесь участником этой сессии.",
cancellationToken: ct);
return;
}
// 3. Record vote (upsert)
var inserted = await connection.ExecuteAsync(
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_votes (proposal_id, player_id, 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 },
transaction);
// 4. Handle "no" vote — immediately reject
if (command.Vote == "no")
var participants = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
? 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(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
@@ -117,12 +134,10 @@ public sealed class HandleRescheduleVoteHandler(
await transaction.CommitAsync(ct);
// Get voter's name
var voterName = await connection.QuerySingleOrDefaultAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TgId",
new { TgId = command.TelegramUserId });
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
new { command.TelegramUserId });
// Update voting message — show rejection
try
{
await bot.EditMessageText(
@@ -137,38 +152,15 @@ public sealed class HandleRescheduleVoteHandler(
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);
return;
}
// 5. Handle "yes" vote — check if all approved
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)
if (decision.ShouldRescheduleSession)
{
// 6. All approved — reschedule!
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero); // ProposedAt is stored in UTC
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero);
// Update session time and reset status to Planned for fresh notification cycle
await connection.ExecuteAsync(
"""
UPDATE sessions
@@ -187,19 +179,21 @@ public sealed class HandleRescheduleVoteHandler(
new { Id = command.ProposalId },
transaction);
// Reset all participant RSVP to Pending for the new confirmation cycle
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = 'Pending', responded_at = NULL
WHERE session_id = @SessionId AND is_gm = false
""",
new { proposal.SessionId },
transaction);
if (decision.ShouldResetParticipantRsvps)
{
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = 'Pending',
responded_at = NULL
WHERE session_id = @SessionId AND is_gm = false
""",
new { proposal.SessionId },
transaction);
}
await transaction.CommitAsync(ct);
// Update voting message — show approval
try
{
await bot.EditMessageText(
@@ -214,21 +208,24 @@ public sealed class HandleRescheduleVoteHandler(
logger.LogWarning(ex, "Failed to update vote message after approval");
}
// Re-render batch message
await TryUpdateBatchMessage(proposal, ct);
logger.LogInformation("Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
proposal.SessionId, newTime, command.ProposalId);
logger.LogInformation(
"Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
proposal.SessionId,
newTime,
command.ProposalId);
}
else
{
// Not all voted yet — update the voting message to show progress
await transaction.CommitAsync(ct);
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title, proposal.CurrentScheduledAt,
proposal.Title,
proposal.CurrentScheduledAt,
new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero),
participants, approvedPlayerIds);
participants,
approvedPlayerIds);
var keyboard = new InlineKeyboardMarkup([
[
@@ -253,15 +250,9 @@ public sealed class HandleRescheduleVoteHandler(
}
}
await bot.AnswerCallbackQuery(command.CallbackQueryId,
allApproved ? "Вы подтвердили перенос! Все согласны — время обновлено." : "Вы подтвердили перенос!",
cancellationToken: ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, 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)
{
try
@@ -274,7 +265,9 @@ public sealed class HandleRescheduleVoteHandler(
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
JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id
@@ -285,7 +278,6 @@ public sealed class HandleRescheduleVoteHandler(
if (proposal.BatchMessageId.HasValue)
{
// Edit the original batch schedule message in-place
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
await bot.EditMessageText(
@@ -298,10 +290,9 @@ public sealed class HandleRescheduleVoteHandler(
}
else
{
// Fallback for sessions created before V005 migration (no batch_message_id)
await bot.SendMessage(
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,
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);
}
}