412 lines
16 KiB
C#
412 lines
16 KiB
C#
using Dapper;
|
|
using GmRelay.Bot.Features.Notifications;
|
|
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.RescheduleSession;
|
|
|
|
// ── DTOs ─────────────────────────────────────────────────────────────
|
|
|
|
internal sealed record AwaitingProposalDto(
|
|
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
|
|
Guid BatchId, int? BatchMessageId, long TelegramChatId, string NotificationMode);
|
|
|
|
internal sealed record VoteParticipantDto(
|
|
Guid PlayerId,
|
|
string DisplayName,
|
|
string? TelegramUsername,
|
|
long TelegramId = 0);
|
|
|
|
// ── Handler ──────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Handles text input from the GM who has an AwaitingTime proposal.
|
|
/// Parses reschedule options with a voting deadline, creates a voting message,
|
|
/// and tags all participants.
|
|
/// If no participants are registered, reschedules immediately.
|
|
/// </summary>
|
|
public sealed class HandleRescheduleTimeInputHandler(
|
|
NpgsqlDataSource dataSource,
|
|
ITelegramBotClient bot,
|
|
DirectSessionNotificationSender directSender,
|
|
ILogger<HandleRescheduleTimeInputHandler> logger)
|
|
{
|
|
/// <summary>
|
|
/// Attempts to handle a text message as reschedule time input.
|
|
/// Returns true if it was handled (i.e. user had an AwaitingTime proposal).
|
|
/// </summary>
|
|
public async Task<bool> TryHandleAsync(Message message, CancellationToken ct)
|
|
{
|
|
if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
|
|
return false;
|
|
|
|
var gmTelegramId = message.From.Id;
|
|
var chatId = message.Chat.Id;
|
|
var text = message.Text.Trim();
|
|
|
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
|
|
|
// 1. Check if this GM has an AwaitingTime proposal in this chat
|
|
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
|
|
"""
|
|
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
|
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
|
g.telegram_chat_id AS TelegramChatId,
|
|
s.notification_mode AS NotificationMode
|
|
FROM reschedule_proposals rp
|
|
JOIN sessions s ON s.id = rp.session_id
|
|
JOIN game_groups g ON g.id = s.group_id
|
|
WHERE rp.proposed_by = @GmId
|
|
AND rp.status = 'AwaitingTime'
|
|
AND g.telegram_chat_id = @ChatId
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM group_managers gm
|
|
JOIN players manager_player ON manager_player.id = gm.player_id
|
|
WHERE gm.group_id = s.group_id
|
|
AND manager_player.telegram_id = @GmId
|
|
)
|
|
ORDER BY rp.created_at DESC
|
|
LIMIT 1
|
|
""",
|
|
new { GmId = gmTelegramId, ChatId = chatId });
|
|
|
|
if (proposal is null)
|
|
return false;
|
|
|
|
// 2. Parse voting input
|
|
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
|
|
{
|
|
await bot.SendMessage(
|
|
chatId: chatId,
|
|
text: $"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
|
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
cancellationToken: ct);
|
|
return true;
|
|
}
|
|
|
|
// 3. Load participants (non-GM) signed up for this session
|
|
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
|
"""
|
|
SELECT p.id AS PlayerId,
|
|
p.display_name AS DisplayName,
|
|
p.telegram_username AS TelegramUsername,
|
|
p.telegram_id AS TelegramId
|
|
FROM session_participants sp
|
|
JOIN players p ON p.id = sp.player_id
|
|
WHERE sp.session_id = @SessionId
|
|
AND sp.is_gm = false
|
|
AND sp.registration_status = @Active
|
|
""",
|
|
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
|
|
|
// 4. If no participants — reschedule immediately
|
|
if (participants.Count == 0)
|
|
{
|
|
await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct);
|
|
await TryDeleteMessage(chatId, message.MessageId, ct);
|
|
return true;
|
|
}
|
|
|
|
// 5. Create voting message
|
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
|
var options = votingInput.Options
|
|
.Select((proposedAt, index) => new RescheduleOptionDto(
|
|
Guid.NewGuid(),
|
|
index + 1,
|
|
proposedAt))
|
|
.ToList();
|
|
|
|
await connection.ExecuteAsync(
|
|
"""
|
|
UPDATE reschedule_proposals
|
|
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @ChatId
|
|
WHERE id = @Id
|
|
""",
|
|
new { votingInput.Deadline, ChatId = chatId, Id = proposal.Id },
|
|
transaction);
|
|
|
|
foreach (var option in options)
|
|
{
|
|
await connection.ExecuteAsync(
|
|
"""
|
|
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
|
|
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
|
|
""",
|
|
new
|
|
{
|
|
option.OptionId,
|
|
ProposalId = proposal.Id,
|
|
option.ProposedAt,
|
|
option.DisplayOrder
|
|
},
|
|
transaction);
|
|
}
|
|
|
|
await transaction.CommitAsync(ct);
|
|
|
|
var voteText = BuildVotingMessage(
|
|
proposal.Title,
|
|
proposal.CurrentScheduledAt,
|
|
votingInput.Deadline,
|
|
options,
|
|
participants,
|
|
[]);
|
|
var keyboard = BuildVotingKeyboard(options);
|
|
|
|
var voteMsg = await bot.SendMessage(
|
|
chatId: chatId,
|
|
text: voteText,
|
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
replyMarkup: keyboard,
|
|
cancellationToken: ct);
|
|
|
|
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
|
|
if (mode.ShouldSendDirectMessages())
|
|
{
|
|
var optionsText = string.Join(
|
|
"\n",
|
|
options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
|
|
var directText = $"""
|
|
🔄 <b>Голосование за перенос сессии</b>
|
|
|
|
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
|
|
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
|
🗳 Варианты:
|
|
{optionsText}
|
|
|
|
⏳ Дедлайн: <b>{votingInput.Deadline.FormatMoscow()}</b> (МСК)
|
|
|
|
Проголосуйте кнопкой в групповом сообщении.
|
|
""";
|
|
|
|
await directSender.SendAsync(
|
|
participants.Select(p => new DirectNotificationRecipient(
|
|
p.TelegramId,
|
|
p.DisplayName)),
|
|
directText,
|
|
"reschedule-vote",
|
|
proposal.SessionId,
|
|
ct);
|
|
}
|
|
|
|
// Store vote message ID
|
|
await connection.ExecuteAsync(
|
|
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
|
|
new { MsgId = voteMsg.MessageId, Id = proposal.Id });
|
|
|
|
logger.LogInformation(
|
|
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
|
|
proposal.SessionId,
|
|
proposal.Id,
|
|
options.Count,
|
|
votingInput.Deadline);
|
|
|
|
// Delete GM's time input message
|
|
await TryDeleteMessage(chatId, message.MessageId, ct);
|
|
|
|
return true;
|
|
}
|
|
|
|
private async Task RescheduleImmediately(
|
|
NpgsqlConnection connection, AwaitingProposalDto proposal,
|
|
DateTimeOffset newTime, long chatId, CancellationToken ct)
|
|
{
|
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
|
|
|
await connection.ExecuteAsync(
|
|
"""
|
|
UPDATE sessions
|
|
SET scheduled_at = @NewTime,
|
|
status = @Status,
|
|
one_hour_reminder_processed_at = NULL,
|
|
updated_at = now()
|
|
WHERE id = @SessionId
|
|
""",
|
|
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
|
|
transaction);
|
|
|
|
await connection.ExecuteAsync(
|
|
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
|
|
new { NewTime = newTime, Id = proposal.Id },
|
|
transaction);
|
|
|
|
await transaction.CommitAsync(ct);
|
|
|
|
await bot.SendMessage(
|
|
chatId: chatId,
|
|
text: $"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
|
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
cancellationToken: ct);
|
|
|
|
// Re-render batch message with updated time
|
|
await TryUpdateBatchMessage(proposal, ct);
|
|
|
|
logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId);
|
|
}
|
|
|
|
internal static string BuildVotingMessage(
|
|
string title,
|
|
DateTime currentTime,
|
|
DateTimeOffset deadline,
|
|
IReadOnlyList<RescheduleOptionDto> options,
|
|
IReadOnlyList<VoteParticipantDto> participants,
|
|
IReadOnlyList<RescheduleOptionVoteDto> votes)
|
|
{
|
|
var votesByOption = votes
|
|
.GroupBy(v => v.OptionId)
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet();
|
|
var pendingParticipants = participants
|
|
.Where(p => !votedPlayerIds.Contains(p.PlayerId))
|
|
.Select(FormatParticipantName)
|
|
.ToList();
|
|
|
|
var lines = new List<string>
|
|
{
|
|
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
|
|
"",
|
|
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
|
|
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
|
|
"",
|
|
"Выберите один из вариантов:"
|
|
};
|
|
|
|
foreach (var option in options.OrderBy(x => x.DisplayOrder))
|
|
{
|
|
var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []);
|
|
lines.Add(
|
|
$"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК) — {FormatVoteCount(optionVotes.Count)}");
|
|
|
|
if (optionVotes.Count > 0)
|
|
{
|
|
lines.Add($" {string.Join(", ", optionVotes.Select(FormatParticipantName))}");
|
|
}
|
|
}
|
|
|
|
if (pendingParticipants.Count > 0)
|
|
{
|
|
lines.Add("");
|
|
lines.Add($"Не проголосовали: {string.Join(", ", pendingParticipants)}");
|
|
}
|
|
|
|
lines.Add("");
|
|
lines.Add($"Голосов: {votedPlayerIds.Count}/{participants.Count}");
|
|
lines.Add("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется.");
|
|
|
|
return string.Join("\n", lines);
|
|
}
|
|
|
|
internal static InlineKeyboardMarkup BuildVotingKeyboard(IReadOnlyList<RescheduleOptionDto> options)
|
|
{
|
|
return new InlineKeyboardMarkup(
|
|
options
|
|
.OrderBy(option => option.DisplayOrder)
|
|
.Select(option => new[]
|
|
{
|
|
InlineKeyboardButton.WithCallbackData(
|
|
$"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}",
|
|
$"reschedule_vote:{option.OptionId}")
|
|
}));
|
|
}
|
|
|
|
internal static string FormatParticipantName(VoteParticipantDto participant)
|
|
{
|
|
return participant.TelegramUsername is { Length: > 0 } username
|
|
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
|
|
: System.Net.WebUtility.HtmlEncode(participant.DisplayName);
|
|
}
|
|
|
|
internal static string FormatParticipantName(RescheduleOptionVoteDto vote)
|
|
{
|
|
return vote.TelegramUsername is { Length: > 0 } username
|
|
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
|
|
: System.Net.WebUtility.HtmlEncode(vote.DisplayName);
|
|
}
|
|
|
|
private static string FormatVoteCount(int count)
|
|
{
|
|
var modulo100 = count % 100;
|
|
var modulo10 = count % 10;
|
|
var word = modulo100 is >= 11 and <= 14
|
|
? "голосов"
|
|
: modulo10 switch
|
|
{
|
|
1 => "голос",
|
|
>= 2 and <= 4 => "голоса",
|
|
_ => "голосов"
|
|
};
|
|
|
|
return $"{count} {word}";
|
|
}
|
|
|
|
private static string FormatButtonTime(DateTimeOffset utc)
|
|
=> utc.ToOffset(TimeSpan.FromHours(3)).ToString(
|
|
"dd.MM HH:mm",
|
|
System.Globalization.CultureInfo.InvariantCulture);
|
|
|
|
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
|
|
|
var batchSessions = (await conn.QueryAsync<SessionBatchDto>(
|
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
|
new { proposal.BatchId })).ToList();
|
|
|
|
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
|
|
"""
|
|
SELECT sp.session_id AS SessionId,
|
|
p.display_name AS DisplayName,
|
|
p.telegram_username AS TelegramUsername,
|
|
sp.registration_status AS RegistrationStatus
|
|
FROM session_participants sp
|
|
JOIN players p ON sp.player_id = p.id
|
|
JOIN sessions s ON sp.session_id = s.id
|
|
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
|
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
|
|
""",
|
|
new { proposal.BatchId })).ToList();
|
|
|
|
if (proposal.BatchMessageId.HasValue)
|
|
{
|
|
var renderResult = SessionBatchRenderer.Render(
|
|
proposal.Title, batchSessions, batchParticipants);
|
|
|
|
await bot.EditMessageText(
|
|
chatId: proposal.TelegramChatId,
|
|
messageId: proposal.BatchMessageId.Value,
|
|
text: renderResult.Text,
|
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
replyMarkup: renderResult.Markup,
|
|
cancellationToken: ct);
|
|
}
|
|
else
|
|
{
|
|
logger.LogWarning("No batch_message_id stored for session {SessionId}, cannot edit batch message in-place", proposal.SessionId);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule for session {SessionId}", proposal.SessionId);
|
|
}
|
|
}
|
|
|
|
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await bot.DeleteMessage(chatId, messageId, cancellationToken: ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning(ex, "Failed to delete message {MessageId} in chat {ChatId}", messageId, chatId);
|
|
}
|
|
}
|
|
}
|