Initial commit: GM-Relay Telegram Bot
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.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,
|
||||
string Status,
|
||||
string CallbackQueryId,
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
|
||||
|
||||
internal sealed record SessionContext(
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
long GmTelegramId,
|
||||
long TelegramChatId);
|
||||
|
||||
internal sealed record ParticipantRsvp(
|
||||
long TelegramId,
|
||||
string DisplayName,
|
||||
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,
|
||||
ILogger<HandleRsvpHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(HandleRsvpCommand command, CancellationToken ct)
|
||||
{
|
||||
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
|
||||
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 { command.SessionId, command.TelegramUserId },
|
||||
transaction);
|
||||
|
||||
if (!participantExists)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(
|
||||
callbackQueryId: command.CallbackQueryId,
|
||||
text: "Вы не являетесь участником этой сессии.",
|
||||
cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 2. Record RSVP (idempotent) ─────────────────────────────
|
||||
|
||||
var updated = await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE session_participants
|
||||
SET rsvp_status = @Status,
|
||||
responded_at = now()
|
||||
WHERE session_id = @SessionId
|
||||
AND player_id = (SELECT id FROM players WHERE telegram_id = @TelegramUserId)
|
||||
AND rsvp_status != @Status
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId, command.Status },
|
||||
transaction);
|
||||
|
||||
if (updated == 0)
|
||||
{
|
||||
// Already in this state — just dismiss the loading spinner
|
||||
var alreadyText = command.Status == RsvpStatus.Confirmed
|
||||
? "Вы уже подтвердили участие."
|
||||
: "Вы уже отказались от участия.";
|
||||
|
||||
await bot.AnswerCallbackQuery(
|
||||
callbackQueryId: command.CallbackQueryId,
|
||||
text: alreadyText,
|
||||
cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 3. Load session context ─────────────────────────────────
|
||||
|
||||
var session = await connection.QuerySingleAsync<SessionContext>(
|
||||
"""
|
||||
SELECT s.title, s.scheduled_at AS ScheduledAt,
|
||||
g.gm_telegram_id AS GmTelegramId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId
|
||||
""",
|
||||
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);
|
||||
|
||||
// Alert GM immediately via private message
|
||||
var declinedPlayer = await connection.QuerySingleAsync<string>(
|
||||
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
|
||||
new { command.TelegramUserId },
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
// Send alert outside transaction (network call)
|
||||
try
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: session.GmTelegramId,
|
||||
text: $"🚨 Отмена! {declinedPlayer} не сможет прийти на игру «{session.Title}».",
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}",
|
||||
command.SessionId);
|
||||
}
|
||||
|
||||
await bot.AnswerCallbackQuery(
|
||||
callbackQueryId: command.CallbackQueryId,
|
||||
text: "Вы отказались от участия.",
|
||||
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
|
||||
FROM session_participants
|
||||
WHERE session_id = @SessionId AND is_gm = false
|
||||
""",
|
||||
new
|
||||
{
|
||||
command.SessionId,
|
||||
Confirmed = RsvpStatus.Confirmed,
|
||||
Declined = RsvpStatus.Declined
|
||||
},
|
||||
transaction);
|
||||
|
||||
var allConfirmed = counts.Confirmed == counts.Total;
|
||||
|
||||
if (allConfirmed)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET status = @Confirmed, updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
new { command.SessionId, Confirmed = SessionStatus.Confirmed },
|
||||
transaction);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
if (allConfirmed)
|
||||
{
|
||||
// Notify group
|
||||
try
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: session.TelegramChatId,
|
||||
text: $"🎉 Игра «{session.Title}» подтверждена! Все участники на месте.",
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}",
|
||||
command.SessionId);
|
||||
}
|
||||
|
||||
// Notify GM privately
|
||||
try
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: session.GmTelegramId,
|
||||
text: $"✅ Все подтвердили участие в «{session.Title}» ({session.ScheduledAt.FormatMoscow()} МСК).",
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}",
|
||||
command.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
await bot.AnswerCallbackQuery(
|
||||
callbackQueryId: command.CallbackQueryId,
|
||||
text: "Вы подтвердили участие!",
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var participants = (await connection.QueryAsync<ParticipantRsvp>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
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
|
||||
ORDER BY sp.responded_at NULLS LAST
|
||||
""",
|
||||
new { command.SessionId })).ToList();
|
||||
|
||||
var confirmed = participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
|
||||
var declined = participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
|
||||
var pending = participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList();
|
||||
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"🎲 Подтвердите участие в «{session.Title}»",
|
||||
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
|
||||
""
|
||||
};
|
||||
|
||||
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)}");
|
||||
|
||||
lines.Add("");
|
||||
|
||||
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}")
|
||||
]
|
||||
]);
|
||||
|
||||
await bot.EditMessageText(
|
||||
chatId: command.ChatId,
|
||||
messageId: command.MessageId,
|
||||
text: text,
|
||||
replyMarkup: replyMarkup,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatName(ParticipantRsvp p) =>
|
||||
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Domain;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||
|
||||
// ── DTOs for Dapper mapping ──────────────────────────────────────────
|
||||
|
||||
internal sealed record SessionInfo(
|
||||
Guid Id,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
Guid GroupId,
|
||||
long TelegramChatId);
|
||||
|
||||
internal sealed record ParticipantInfo(
|
||||
long TelegramId,
|
||||
string DisplayName,
|
||||
string? TelegramUsername);
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Sends the interactive confirmation message (inline keyboard) to the group chat.
|
||||
/// Called by SessionSchedulerService at T-24h.
|
||||
/// </summary>
|
||||
public sealed class SendConfirmationHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
ILogger<SendConfirmationHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
// 1. Load session + group info
|
||||
var session = await connection.QuerySingleOrDefaultAsync<SessionInfo>(
|
||||
"""
|
||||
SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.status = @Planned
|
||||
""",
|
||||
new { SessionId = sessionId, Planned = SessionStatus.Planned });
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
logger.LogWarning("Session {SessionId} not found or not in Planned status", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Load non-GM participants
|
||||
var participants = (await connection.QueryAsync<ParticipantInfo>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
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 { SessionId = sessionId })).ToList();
|
||||
|
||||
if (participants.Count == 0)
|
||||
{
|
||||
logger.LogWarning("Session {SessionId} has no non-GM participants", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Build confirmation message
|
||||
var playerList = string.Join("\n", participants.Select(p =>
|
||||
$" ⏳ {FormatPlayerName(p)}"));
|
||||
|
||||
var text = $"""
|
||||
🎲 Подтвердите участие в «{session.Title}»
|
||||
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
|
||||
|
||||
{playerList}
|
||||
|
||||
Статус: ожидаем подтверждения (0/{participants.Count})
|
||||
""";
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{sessionId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{sessionId}")
|
||||
]
|
||||
]);
|
||||
|
||||
// 4. Send to group
|
||||
var message = await bot.SendMessage(
|
||||
chatId: session.TelegramChatId,
|
||||
text: text,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: ct);
|
||||
|
||||
// 5. Update session status and store message ID
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET status = @Status,
|
||||
confirmation_message_id = @MessageId,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Status = SessionStatus.ConfirmationSent,
|
||||
MessageId = message.MessageId
|
||||
});
|
||||
|
||||
logger.LogInformation(
|
||||
"Confirmation sent for session {SessionId} ({Title}), message_id={MessageId}",
|
||||
sessionId, session.Title, message.MessageId);
|
||||
}
|
||||
|
||||
internal static string FormatPlayerName(ParticipantInfo p) =>
|
||||
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
|
||||
}
|
||||
Reference in New Issue
Block a user