Initial commit: GM-Relay Telegram Bot

This commit is contained in:
2026-04-13 13:52:49 +03:00
commit 9db4bee2f6
51 changed files with 3407 additions and 0 deletions
@@ -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);
}
}
}