test: cover core bot and web scenarios
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+79
-88
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user