Initial commit: GM-Relay Telegram Bot
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Domain;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed record CancelSessionCommand(
|
||||
Guid SessionId,
|
||||
long TelegramUserId,
|
||||
string CallbackQueryId,
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
// DTOs for AOT compilation
|
||||
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, long GmId);
|
||||
|
||||
public sealed class CancelSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
ILogger<CancelSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(CancelSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// 1. Проверяем, что запрос делает ГМ данной сессии
|
||||
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
|
||||
@"SELECT s.title as Title, s.batch_id as BatchId, g.gm_telegram_id as GmId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
WHERE s.id = @SessionId",
|
||||
new { command.SessionId }, transaction);
|
||||
|
||||
if (session == 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. Отменяем сессию
|
||||
await connection.ExecuteAsync("UPDATE sessions SET status = 'Cancelled' WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
||||
|
||||
// 3. Загружаем весь батч для перерисовки
|
||||
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 { BatchId = session.BatchId }, transaction);
|
||||
|
||||
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 { BatchId = session.BatchId }, transaction);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
// 4. Перерисовываем сообщение
|
||||
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||
|
||||
try
|
||||
{
|
||||
await bot.EditMessageText(
|
||||
chatId: command.ChatId,
|
||||
messageId: command.MessageId,
|
||||
text: renderResult.Text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: renderResult.Markup,
|
||||
cancellationToken: ct);
|
||||
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct);
|
||||
|
||||
// Опционально: написать отдельное сообщение в чат
|
||||
await bot.SendMessage(command.ChatId, $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to update batch message after cancelling session {SessionId}", command.SessionId);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Ошибка при обновлении сообщения.", cancellationToken: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Text.RegularExpressions;
|
||||
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.CreateSession;
|
||||
|
||||
public sealed class CreateSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient botClient,
|
||||
ILogger<CreateSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||
{
|
||||
var text = message.Text ?? "";
|
||||
|
||||
string? title = null;
|
||||
string? link = null;
|
||||
var scheduledTimes = new List<DateTimeOffset>();
|
||||
|
||||
foreach (var line in text.Split('\n'))
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(link) || scheduledTimes.Count == 0)
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
chatId: message.Chat.Id,
|
||||
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nСсылка: https://link",
|
||||
cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var gmId = message.From!.Id;
|
||||
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}");
|
||||
var gmUsername = message.From.Username;
|
||||
|
||||
var chatId = message.Chat.Id;
|
||||
var chatTitle = message.Chat.Title ?? "Private Chat";
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
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;",
|
||||
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;",
|
||||
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
|
||||
transaction);
|
||||
|
||||
int? messageThreadId = null;
|
||||
if (message.Chat.IsForum)
|
||||
{
|
||||
var topic = await botClient.CreateForumTopic(
|
||||
chatId: chatId,
|
||||
name: $"🎲 Игры: {title}",
|
||||
cancellationToken: cancellationToken);
|
||||
messageThreadId = topic.MessageThreadId;
|
||||
}
|
||||
|
||||
// 3. Создаем сессии в цикле с общим batch_id
|
||||
var batchId = Guid.NewGuid();
|
||||
var sessions = new List<SessionBatchDto>();
|
||||
|
||||
foreach (var dt in scheduledTimes.OrderBy(d => d))
|
||||
{
|
||||
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 },
|
||||
transaction);
|
||||
|
||||
sessions.Add(new SessionBatchDto(sessionId, dt.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,
|
||||
text: renderResult.Text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
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(
|
||||
chatId: chatId,
|
||||
messageId: message.MessageId,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, chatId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Ошибка при создании сессии");
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
using GmRelay.Bot.Domain;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed record JoinSessionCommand(
|
||||
Guid SessionId,
|
||||
long TelegramUserId,
|
||||
string DisplayName,
|
||||
string? TelegramUsername,
|
||||
string CallbackQueryId,
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
// DTOs for AOT compilation
|
||||
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title);
|
||||
|
||||
public sealed class JoinSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
ILogger<JoinSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Убеждаемся, что игрок есть в базе
|
||||
var playerId = await connection.ExecuteScalarAsync<Guid>(
|
||||
@"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
|
||||
RETURNING id;",
|
||||
new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername },
|
||||
transaction);
|
||||
|
||||
// 2. Добавляем в участники сессии (статус Pending, так как за 24 часа нужно будет финальное подтверждение)
|
||||
var inserted = await connection.ExecuteAsync(
|
||||
@"INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status)
|
||||
VALUES (@SessionId, @PlayerId, false, 'Pending')
|
||||
ON CONFLICT (session_id, player_id) DO NOTHING;",
|
||||
new { SessionId = command.SessionId, PlayerId = playerId },
|
||||
transaction);
|
||||
|
||||
if (inserted == 0)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы уже записаны!", cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Получаем batch_id по session_id
|
||||
var batchInfo = await connection.QuerySingleAsync<JoinSessionBatchDto>(
|
||||
@"SELECT batch_id as BatchId, title as Title FROM sessions WHERE id = @SessionId",
|
||||
new { command.SessionId }, transaction);
|
||||
|
||||
// Загружаем весь батч для перерисовки
|
||||
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 { BatchId = batchInfo.BatchId }, transaction);
|
||||
|
||||
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 { BatchId = batchInfo.BatchId }, transaction);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
// 4. Перерисовываем сообщение
|
||||
var renderResult = SessionBatchRenderer.Render(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||
|
||||
await bot.EditMessageText(
|
||||
chatId: command.ChatId,
|
||||
messageId: command.MessageId,
|
||||
text: renderResult.Text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: renderResult.Markup,
|
||||
cancellationToken: ct);
|
||||
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы успешно записаны!", cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Ошибка при добавлении игрока к сессии");
|
||||
await transaction.RollbackAsync(ct);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Произошла ошибка при регистрации.", cancellationToken: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using GmRelay.Bot.Domain;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
internal sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status);
|
||||
internal sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername);
|
||||
|
||||
internal static class SessionBatchRenderer
|
||||
{
|
||||
public static (string Text, InlineKeyboardMarkup Markup) Render(
|
||||
string title,
|
||||
IReadOnlyList<SessionBatchDto> sessions,
|
||||
IReadOnlyList<ParticipantBatchDto> participants)
|
||||
{
|
||||
var activeSessions = sessions.OrderBy(s => s.ScheduledAt).ToList();
|
||||
|
||||
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(title)}\n\n" +
|
||||
$"<b>Расписание:</b>\n\n";
|
||||
|
||||
var buttons = new List<InlineKeyboardButton[]>();
|
||||
|
||||
foreach (var session in activeSessions)
|
||||
{
|
||||
var sessionPlayers = participants.Where(p => p.SessionId == session.SessionId).ToList();
|
||||
|
||||
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
||||
messageText += $"👥 Игроки ({sessionPlayers.Count}):\n";
|
||||
|
||||
if (sessionPlayers.Count > 0)
|
||||
{
|
||||
messageText += string.Join("\n", sessionPlayers.Select(p => $" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
messageText += " <i>Пока никто не записался</i>\n";
|
||||
}
|
||||
|
||||
if (session.Status == "Cancelled")
|
||||
{
|
||||
messageText += "❌ <i>Сессия отменена</i>\n\n";
|
||||
}
|
||||
else if (session.Status == "RecruitmentClosed") // custom state we can derive or use
|
||||
{
|
||||
messageText += "🔒 <i>Набор завершен</i>\n\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
messageText += "\n";
|
||||
// Add buttons for this specific session
|
||||
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
||||
buttons.Add(new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData($"✋ На {dateTitle}", $"join_session:{session.SessionId}"),
|
||||
InlineKeyboardButton.WithCallbackData($"❌ Отменить {dateTitle} (ГМ)", $"cancel_session:{session.SessionId}"),
|
||||
InlineKeyboardButton.WithCallbackData($"⏰ (ГМ)", $"reschedule_session:{session.SessionId}")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (messageText, new InlineKeyboardMarkup(buttons));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user