Initial commit: GM-Relay Telegram Bot
This commit is contained in:
+265
@@ -0,0 +1,265 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Domain;
|
||||
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);
|
||||
|
||||
internal sealed record VoteParticipantDto(Guid PlayerId, string DisplayName, string? TelegramUsername);
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Handles text input from the GM who has an AwaitingTime proposal.
|
||||
/// Parses the new time, creates a voting message, and tags all participants.
|
||||
/// If no participants are registered, reschedules immediately.
|
||||
/// </summary>
|
||||
public sealed class HandleRescheduleTimeInputHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
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
|
||||
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
|
||||
ORDER BY rp.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
new { GmId = gmTelegramId, ChatId = chatId });
|
||||
|
||||
if (proposal is null)
|
||||
return false;
|
||||
|
||||
// 2. Parse the new time
|
||||
if (!MoscowTime.TryParseMoscow(text, out var newTime))
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
text: "⚠️ Не удалось распознать время. Используйте формат: <code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\nНапример: <code>25.04.2026 19:30</code>",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (newTime <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
text: "⚠️ Новое время должно быть в будущем. Попробуйте снова.",
|
||||
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
|
||||
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 })).ToList();
|
||||
|
||||
// 4. If no participants — reschedule immediately
|
||||
if (participants.Count == 0)
|
||||
{
|
||||
await RescheduleImmediately(connection, proposal, newTime, chatId, ct);
|
||||
await TryDeleteMessage(chatId, message.MessageId, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. Create voting message
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// Update proposal with proposed time and Voting status
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE reschedule_proposals
|
||||
SET proposed_at = @ProposedAt, status = 'Voting', vote_chat_id = @ChatId
|
||||
WHERE id = @Id
|
||||
""",
|
||||
new { ProposedAt = newTime, ChatId = chatId, Id = proposal.Id },
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
// Build voting message text
|
||||
var voteText = BuildVotingMessage(proposal.Title, proposal.CurrentScheduledAt, newTime, participants, []);
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{proposal.Id}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{proposal.Id}")
|
||||
]
|
||||
]);
|
||||
|
||||
var voteMsg = await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
text: voteText,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: 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}", proposal.SessionId, proposal.Id);
|
||||
|
||||
// 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 = 'Planned', updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
new { NewTime = newTime, proposal.SessionId },
|
||||
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 newTime,
|
||||
IReadOnlyList<VoteParticipantDto> participants,
|
||||
IReadOnlyCollection<Guid> approvedPlayerIds)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
|
||||
"",
|
||||
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
|
||||
$"📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)",
|
||||
"",
|
||||
"Для переноса нужно согласие всех участников:"
|
||||
};
|
||||
|
||||
foreach (var p in participants)
|
||||
{
|
||||
var name = p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
|
||||
var icon = approvedPlayerIds.Contains(p.PlayerId) ? "✅" : "⏳";
|
||||
lines.Add($" {icon} {name}");
|
||||
}
|
||||
|
||||
lines.Add("");
|
||||
lines.Add($"Голоса: {approvedPlayerIds.Count}/{participants.Count} ✅");
|
||||
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var batchSessions = (await conn.QueryAsync<GmRelay.Bot.Features.Sessions.CreateSession.SessionBatchDto>(
|
||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||
new { proposal.BatchId })).ToList();
|
||||
|
||||
var batchParticipants = (await conn.QueryAsync<GmRelay.Bot.Features.Sessions.CreateSession.ParticipantBatchDto>(
|
||||
"""
|
||||
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
|
||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||
ORDER BY sp.responded_at ASC, p.created_at ASC
|
||||
""",
|
||||
new { proposal.BatchId })).ToList();
|
||||
|
||||
if (proposal.BatchMessageId.HasValue)
|
||||
{
|
||||
var renderResult = GmRelay.Bot.Features.Sessions.CreateSession.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Domain;
|
||||
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"
|
||||
long TelegramUserId,
|
||||
string CallbackQueryId,
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record VoteProposalDto(
|
||||
Guid Id,
|
||||
Guid SessionId,
|
||||
DateTime ProposedAt,
|
||||
string Title,
|
||||
DateTime CurrentScheduledAt,
|
||||
Guid BatchId,
|
||||
string SessionStatus,
|
||||
long TelegramChatId,
|
||||
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,
|
||||
ILogger<HandleRescheduleVoteHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
||||
{
|
||||
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,
|
||||
s.confirmation_message_id AS ConfirmationMessageId,
|
||||
s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
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.id = @ProposalId AND rp.status = 'Voting'
|
||||
""",
|
||||
new { command.ProposalId },
|
||||
transaction);
|
||||
|
||||
if (proposal is null)
|
||||
{
|
||||
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
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
AND sp.is_gm = false
|
||||
""",
|
||||
new { proposal.SessionId, command.TelegramUserId },
|
||||
transaction);
|
||||
|
||||
if (playerId is null)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||
"Вы не являетесь участником этой сессии.", cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Record vote (upsert)
|
||||
var inserted = 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()
|
||||
""",
|
||||
new { command.ProposalId, PlayerId = playerId.Value, command.Vote },
|
||||
transaction);
|
||||
|
||||
// 4. Handle "no" vote — immediately reject
|
||||
if (command.Vote == "no")
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
|
||||
new { Id = command.ProposalId },
|
||||
transaction);
|
||||
|
||||
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 });
|
||||
|
||||
// Update voting message — show rejection
|
||||
try
|
||||
{
|
||||
await bot.EditMessageText(
|
||||
chatId: command.ChatId,
|
||||
messageId: command.MessageId,
|
||||
text: $"❌ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» отклонён!</b>\n\n{voterName ?? "Участник"} проголосовал(а) против. Время сессии остаётся прежним:\n📅 <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update vote message after rejection");
|
||||
}
|
||||
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы проголосовали против переноса.", 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)
|
||||
{
|
||||
// 6. All approved — reschedule!
|
||||
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(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET scheduled_at = @NewTime,
|
||||
status = 'Planned',
|
||||
confirmation_message_id = NULL,
|
||||
link_message_id = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
new { NewTime = newTime, proposal.SessionId },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE reschedule_proposals SET status = 'Approved' WHERE id = @Id",
|
||||
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);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
// Update voting message — show approval
|
||||
try
|
||||
{
|
||||
await bot.EditMessageText(
|
||||
chatId: command.ChatId,
|
||||
messageId: command.MessageId,
|
||||
text: $"✅ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» одобрен!</b>\n\nВсе участники согласились.\n📅 Новое время: <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК)\n\n<i>Уведомления будут приходить согласно новому расписанию.</i>",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
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);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Not all voted yet — update the voting message to show progress
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||
proposal.Title, proposal.CurrentScheduledAt,
|
||||
new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero),
|
||||
participants, approvedPlayerIds);
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{command.ProposalId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{command.ProposalId}")
|
||||
]
|
||||
]);
|
||||
|
||||
try
|
||||
{
|
||||
await bot.EditMessageText(
|
||||
chatId: command.ChatId,
|
||||
messageId: command.MessageId,
|
||||
text: voteText,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update vote message with progress");
|
||||
}
|
||||
}
|
||||
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||
new { proposal.BatchId })).ToList();
|
||||
|
||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||
"""
|
||||
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
|
||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||
ORDER BY sp.responded_at ASC, p.created_at ASC
|
||||
""",
|
||||
new { proposal.BatchId })).ToList();
|
||||
|
||||
if (proposal.BatchMessageId.HasValue)
|
||||
{
|
||||
// Edit the original batch schedule message in-place
|
||||
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
|
||||
{
|
||||
// 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> (МСК).",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update batch message for proposal {ProposalId}", proposal.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
|
||||
// ── Command ──────────────────────────────────────────────────────────
|
||||
|
||||
public sealed record InitiateRescheduleCommand(
|
||||
Guid SessionId,
|
||||
long TelegramUserId,
|
||||
string CallbackQueryId,
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record RescheduleSessionInfoDto(string Title, long GmId);
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Handles the "⏰ Перенести" button press from the batch message.
|
||||
/// Creates a reschedule proposal in AwaitingTime status and prompts
|
||||
/// the GM to enter the new time via a regular text message.
|
||||
/// </summary>
|
||||
public sealed class InitiateRescheduleHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
ILogger<InitiateRescheduleHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(InitiateRescheduleCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
// 1. Verify GM ownership
|
||||
var session = await connection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
|
||||
"""
|
||||
SELECT s.title AS Title, g.gm_telegram_id AS GmId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
WHERE s.id = @SessionId AND s.status != 'Cancelled'
|
||||
""",
|
||||
new { command.SessionId });
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.GmId != command.TelegramUserId)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||
"Только Мастер Игры (GM) может переносить сессию.", showAlert: true, cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check no active proposal exists
|
||||
var hasActive = await connection.ExecuteScalarAsync<bool>(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM reschedule_proposals
|
||||
WHERE session_id = @SessionId AND status IN ('AwaitingTime', 'Voting')
|
||||
)
|
||||
""",
|
||||
new { command.SessionId });
|
||||
|
||||
if (hasActive)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||
"Уже есть активный запрос на перенос этой сессии.", showAlert: true, cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Create proposal in AwaitingTime status
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO reschedule_proposals (session_id, proposed_by, status)
|
||||
VALUES (@SessionId, @GmId, 'AwaitingTime')
|
||||
""",
|
||||
new { command.SessionId, GmId = command.TelegramUserId });
|
||||
|
||||
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
|
||||
|
||||
// 4. Prompt GM in chat
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||
"Введите новое время в чат (формат: ДД.ММ.ГГГГ ЧЧ:ММ)", cancellationToken: ct);
|
||||
|
||||
await bot.SendMessage(
|
||||
chatId: command.ChatId,
|
||||
text: $"⏰ Укажите новое время для сессии «{session.Title}» в формате:\n<code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\n\nНапример: <code>25.04.2026 19:30</code>",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user