Merge pull request #106: refactor: extract remaining Telegram handlers to platform-neutral contracts

This commit is contained in:
2026-05-27 15:19:23 +03:00
46 changed files with 1700 additions and 1032 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.1.0
VERSION: 3.2.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.1.0</Version>
<Version>3.2.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.1.0
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.2.0
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.1.0
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.2.0
restart: always
depends_on:
db:
@@ -84,7 +84,7 @@ services:
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.1.0
image: git.codeanddice.ru/toutsu/gmrelay-web:3.2.0
restart: always
depends_on:
db:
@@ -1,294 +1,178 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using GmRelay.Shared.Platform;
using GmRelay.Bot.Infrastructure.Telegram;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
public sealed class CreateSessionHandler(
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler sharedHandler,
NpgsqlDataSource dataSource,
ITelegramBotClient botClient,
IPlatformMessenger messenger,
ILogger<CreateSessionHandler> logger)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
public async Task HandleAsync(Message message, CancellationToken ct)
{
var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow);
foreach (var timeInput in parseResult.PastTimeInputs)
{
await botClient.SendMessage(
message.Chat.Id,
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
cancellationToken: cancellationToken);
ct);
}
foreach (var timeInput in parseResult.InvalidTimeInputs)
{
await botClient.SendMessage(
message.Chat.Id,
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
cancellationToken: cancellationToken);
ct);
}
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
{
await botClient.SendMessage(
message.Chat.Id,
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
cancellationToken: cancellationToken);
ct);
}
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
{
await botClient.SendMessage(
message.Chat.Id,
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
cancellationToken: cancellationToken);
ct);
}
if (!parseResult.IsValid)
{
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Мест: 4\nСсылка: https://link\nКартинка: https://cover\n\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7",
cancellationToken: cancellationToken);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
"""
Не удалось распознать формат. Пожалуйста, используйте шаблон:
/newsession
Название: My Game
Время: 15.05.2026 19:30
Время: 22.05.2026 19:30
Мест: 4
Ссылка: https://link
Картинка: https://cover
Для повтора можно указать одну дату и строки:
Игр: 4
Интервал: 7
""",
ct);
return;
}
var title = parseResult.Title!;
var link = parseResult.Link!;
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
var gmId = message.From!.Id;
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {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
{
await connection.ExecuteAsync(
"""
INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, 'Telegram', @ExternalId, @Username)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE
SET display_name = EXCLUDED.display_name,
external_username = EXCLUDED.external_username;
""",
new { ExternalId = gmId.ToString(), Name = gmName, Username = gmUsername },
transaction);
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
"""
SELECT g.id AS GroupId,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = g.id
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalGmId
) AS CanManage
FROM game_groups g
WHERE g.platform = 'Telegram'
AND g.external_group_id = @ExternalChatId
""",
new { ExternalChatId = chatId.ToString(), ExternalGmId = gmId.ToString() },
transaction);
Guid groupId;
if (existingGroup is null)
{
groupId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO game_groups (name, platform, external_group_id)
VALUES (@ChatName, 'Telegram', @ExternalChatId)
RETURNING id;
""",
new { ExternalChatId = chatId.ToString(), ChatName = chatTitle },
transaction);
await connection.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE p.platform = 'Telegram'
AND p.external_user_id = @ExternalGmId
ON CONFLICT (group_id, player_id) DO NOTHING
""",
new { GroupId = groupId, ExternalGmId = gmId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
}
else
{
if (!existingGroup.CanManage)
{
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(
chatId,
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
cancellationToken: cancellationToken);
return;
}
groupId = existingGroup.GroupId;
await connection.ExecuteAsync(
"UPDATE game_groups SET name = @ChatName WHERE id = @GroupId",
new { ChatName = chatTitle, GroupId = groupId },
transaction);
}
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
message.Chat.IsForum,
message.MessageThreadId);
var messageThreadId = topicDestination.MessageThreadId;
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
var messageThreadId = topicDestination.MessageThreadId;
if (topicDestination.ShouldCreateForumTopic)
{
try
{
var topic = await botClient.CreateForumTopic(
chatId: chatId,
name: $"🎲 Игры: {title}",
cancellationToken: cancellationToken);
messageThreadId = topic.MessageThreadId;
var topicRef = await messenger.CreateThreadAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"🎲 Игры: {parseResult.Title}",
ct);
messageThreadId = int.Parse(topicRef.ExternalThreadId!, System.Globalization.CultureInfo.InvariantCulture);
}
catch (Telegram.Bot.Exceptions.ApiRequestException ex)
when (TelegramTopicRouting.IsMissingForumTopicRightsError(ex.Message))
catch (Exception ex)
when (ex.Message.Contains("not enough rights") ||
ex.Message.Contains("CHAT_ADMIN_REQUIRED") ||
ex.Message.Contains("not an administrator"))
{
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(
chatId,
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
TelegramTopicRouting.MissingForumTopicRightsMessage,
cancellationToken: cancellationToken);
ct);
return;
}
}
var batchId = Guid.NewGuid();
var sessions = new List<SessionBatchDto>();
var platformGroup = TelegramPlatformIds.Group(message.Chat.Id, messageThreadId, message.Chat.Title ?? "Private Chat");
var platformUser = new PlatformUser(
PlatformKind.Telegram,
gmId.ToString(System.Globalization.CultureInfo.InvariantCulture),
gmName,
gmUsername);
foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value))
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers)
RETURNING id;
""",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = link,
ScheduledAt = scheduledAt,
ThreadId = messageThreadId,
TopicCreatedByBot = topicCreatedByBot,
MaxPlayers = parseResult.MaxPlayers,
Status = SessionStatus.Planned
},
transaction);
var command = new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionCommand(
platformUser,
platformGroup,
parseResult.Title!,
parseResult.Link!,
parseResult.ScheduledTimes,
parseResult.MaxPlayers,
imageReference);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers, link));
}
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
var renderResult = TelegramSessionBatchRenderer.Render(view);
Message batchMessage;
if (imageReference is not null && renderResult.Text.Length <= 1024)
{
// Картинка + расписание умещаются в одном Telegram-фото с подписью
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionResult result;
try
{
batchMessage = await botClient.SendPhoto(
chatId: chatId,
messageThreadId: messageThreadId,
photo: InputFile.FromString(imageReference),
caption: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
result = await sharedHandler.HandleAsync(command, ct);
}
catch (Exception ex)
catch
{
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}, отправляем текстом", batchId);
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
}
else
{
// Текст слишком длинный для caption — fallback на два сообщения
if (imageReference is not null)
{
try
{
await botClient.SendPhoto(
chatId: chatId,
messageThreadId: messageThreadId,
photo: InputFile.FromString(imageReference),
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}", batchId);
}
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
"💥 Произошла ошибка базы данных при создании сессии.",
ct);
throw;
}
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
if (!result.Success)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
result.ErrorMessage!,
ct);
return;
}
var scheduleMessage = new PlatformScheduleMessage(
platformGroup,
result.View!,
null,
imageReference);
var sentMessageRef = await messenger.SendScheduleAsync(scheduleMessage, ct);
// Store batch_message_id
if (int.TryParse(sentMessageRef.ExternalMessageId, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var batchMessageId))
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessage.MessageId, BatchId = batchId });
new { MsgId = batchMessageId, BatchId = result.BatchId });
}
// Delete original message
try
{
await botClient.DeleteMessage(
chatId: chatId,
messageId: message.MessageId,
cancellationToken: cancellationToken);
await messenger.DeleteMessageAsync(
TelegramPlatformIds.Message(message.Chat.Id, null, message.MessageId),
ct);
}
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);
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, message.Chat.Id);
}
}
@@ -1,114 +1,25 @@
using System.Text;
using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration;
using Npgsql;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
IConfiguration configuration)
GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler sharedHandler)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
public Task HandleAsync(Message message, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var command = new GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarCommand(
new PlatformGroup(
PlatformKind.Telegram,
message.Chat.Id.ToString(),
message.Chat.Title ?? "Private Chat",
message.MessageThreadId?.ToString()),
new PlatformUser(
PlatformKind.Telegram,
message.From?.Id.ToString() ?? string.Empty,
message.From?.FirstName ?? string.Empty,
message.From?.Username));
var sessions = await connection.QueryAsync<CalendarSessionDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
+ " FROM sessions s"
+ " JOIN game_groups g ON s.group_id = g.id"
+ " WHERE g.platform = 'Telegram'"
+ " AND g.external_group_id = @ExternalChatId"
+ " AND s.status = @Planned"
+ " AND s.scheduled_at > NOW()"
+ " ORDER BY s.scheduled_at ASC",
new { ExternalChatId = message.Chat.Id.ToString(), Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
"📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken);
return;
}
var sb = new StringBuilder();
sb.AppendLine("BEGIN:VCALENDAR");
sb.AppendLine("VERSION:2.0");
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
foreach (var s in sessionsList)
{
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
sb.AppendLine("BEGIN:VEVENT");
sb.AppendLine($"UID:{s.Id}@gmrelay");
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{s.Title}");
sb.AppendLine("END:VEVENT");
}
sb.AppendLine("END:VCALENDAR");
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
// Create calendar subscription
string? subscriptionUrl = null;
var baseUrl = configuration["Web:BaseUrl"];
var senderId = message.From?.Id;
if (!string.IsNullOrWhiteSpace(baseUrl) && senderId.HasValue)
{
try
{
var token = Guid.NewGuid().ToString("N");
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
@"SELECT id FROM game_groups WHERE platform = 'Telegram' AND external_group_id = @ExternalChatId",
new { ExternalChatId = message.Chat.Id.ToString() });
await connection.ExecuteAsync(
@"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, 'Telegram', @userExternalId, @groupId, @filterType, now(), NULL)",
new { token, userExternalId = senderId.Value.ToString(), groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
}
catch
{
// Non-critical: if subscription creation fails, still send the file
}
}
var actions = subscriptionUrl is not null
? new[]
{
new PlatformMessageAction(
"calendar-subscription",
"🔗 Подписаться на календарь",
subscriptionUrl)
}
: Array.Empty<PlatformMessageAction>();
await messenger.SendCalendarFileAsync(
new PlatformCalendarFile(
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
"schedule.ics",
bytes,
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
actions),
cancellationToken);
return sharedHandler.HandleAsync(command, cancellationToken);
}
}
@@ -1,8 +1,5 @@
using Dapper;
using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
@@ -13,143 +10,88 @@ public sealed record DeleteSessionCommand(
long ChatId,
int MessageId);
internal sealed record DeleteSessionInfoDto(
string Title,
Guid BatchId,
Guid GroupId,
bool CanManage,
int? ThreadId,
bool TopicCreatedByBot);
public sealed class DeleteSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler sharedHandler,
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler listSessionsHandler,
IPlatformMessenger messenger,
ILogger<DeleteSessionHandler> logger)
{
public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var platformUser = new PlatformUser(
PlatformKind.Telegram,
command.TelegramUserId.ToString(),
string.Empty,
null);
// 1. Fetch session and verify group manager.
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.group_id AS GroupId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
""",
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() }, transaction);
var platformGroup = new PlatformGroup(
PlatformKind.Telegram,
command.ChatId.ToString(),
string.Empty);
if (session == null)
var scheduleMessage = TelegramPlatformIds.Message(command.ChatId, null, command.MessageId);
var sharedCommand = new GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionCommand(
command.SessionId,
platformUser,
platformGroup,
scheduleMessage);
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
if (!result.Success)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("owner")),
ct);
return;
}
if (!session.CanManage)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может удалять сессию.", showAlert: true, cancellationToken: ct);
return;
}
// 2. Delete session
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
var remainingInTopic = session.ThreadId.HasValue
? await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM sessions
WHERE group_id = @GroupId
AND thread_id = @ThreadId
""",
new { session.GroupId, ThreadId = session.ThreadId.Value },
transaction)
: 0;
await transaction.CommitAsync(ct);
// 4. If no sessions are left in a bot-owned forum topic, delete the topic.
if (session.ThreadId.HasValue &&
TelegramTopicRouting.ShouldDeleteForumTopic(session.TopicCreatedByBot, remainingInTopic))
if (result.ThreadId.HasValue &&
TelegramTopicRouting.ShouldDeleteForumTopic(result.TopicCreatedByBot, result.RemainingInTopic))
{
try
{
await bot.DeleteForumTopic(command.ChatId, session.ThreadId.Value, cancellationToken: ct);
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", session.ThreadId.Value, session.BatchId);
await messenger.DeleteThreadAsync(
new PlatformGroup(PlatformKind.Telegram, command.ChatId.ToString(), string.Empty, null, result.ThreadId.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)),
ct);
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", result.ThreadId.Value, result.GroupId);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", session.ThreadId.Value);
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", result.ThreadId.Value);
}
}
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия удалена!", cancellationToken: ct);
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
ct);
// 5. Update the /listsessions message (we delete the message or edit it to remove the button)
// A simple way is to re-render the list:
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
var sessions = await readConnection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
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.platform = 'Telegram'
AND manager_player.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.platform = 'Telegram'
AND g.external_group_id = @ExternalChatId
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC",
new
// 5. Update the /listsessions message
var listCommand = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(platformGroup, platformUser);
var listResult = await listSessionsHandler.HandleAsync(listCommand, ct);
if (listResult.Sessions.Count == 0)
{
ExternalChatId = command.ChatId.ToString(),
ExternalUserId = command.TelegramUserId.ToString(),
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
try
{
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch { }
await messenger.UpdateGroupMessageAsync(
scheduleMessage,
"📭 В этой группе нет предстоящих игр.",
[],
ct);
}
catch { }
return;
}
var renderResult = SessionListMessageRenderer.Render(sessionsList);
var text = SessionListMessageRenderer.RenderText(listResult.Sessions);
var actions = listResult.CanManage ? SessionListMessageRenderer.RenderActions(listResult.Sessions) : [];
try
{
await bot.EditMessageText(
command.ChatId,
command.MessageId,
renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
await messenger.UpdateGroupMessageAsync(scheduleMessage, text, actions, ct);
}
catch (Exception ex)
{
@@ -1,118 +1,37 @@
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
using GmRelay.Shared.Platform;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
internal static class SessionListMessageRenderer
{
public static (string Text, InlineKeyboardMarkup? Markup) Render(IReadOnlyList<SessionListItemDto> sessions)
{
var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var session in sessions)
{
var seats = session.MaxPlayers.HasValue
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
}
var canManage = sessions.Count > 0 && sessions.First().CanManage;
if (!canManage)
{
return (text, null);
}
var buttons = new List<InlineKeyboardButton[]>();
foreach (var session in sessions)
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"❌ {dateTitle}", $"cancel_session:{session.Id}"),
InlineKeyboardButton.WithCallbackData($"⏰ {dateTitle}", $"reschedule_session:{session.Id}")
]);
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
{
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"⬆️ Из ожидания {dateTitle}", $"promote_waitlist:{session.Id}")
]);
}
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"🗑 Удалить {dateTitle}", $"delete_session:{session.Id}")
]);
}
return (text, new InlineKeyboardMarkup(buttons));
}
}
public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient)
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler sharedHandler,
IPlatformMessenger messenger)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var command = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(
new PlatformGroup(
PlatformKind.Telegram,
message.Chat.Id.ToString(),
message.Chat.Title ?? "Private Chat",
message.MessageThreadId?.ToString()),
new PlatformUser(
PlatformKind.Telegram,
message.From?.Id.ToString() ?? string.Empty,
message.From?.FirstName ?? string.Empty,
message.From?.Username));
var sessions = await connection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
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.platform = 'Telegram'
AND manager_player.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.platform = 'Telegram'
AND g.external_group_id = @ExternalChatId
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC",
new
var result = await sharedHandler.HandleAsync(command, cancellationToken);
if (result.Sessions.Count == 0)
{
ExternalChatId = message.Chat.Id.ToString(),
ExternalUserId = message.From?.Id.ToString(),
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "📭 В этой группе нет предстоящих игр.",
cancellationToken: cancellationToken);
await messenger.SendGroupMessageAsync(command.Group, "📭 В этой группе нет предстоящих игр.", cancellationToken);
return;
}
var renderResult = SessionListMessageRenderer.Render(sessionsList);
var text = SessionListMessageRenderer.RenderText(result.Sessions);
var actions = result.CanManage ? SessionListMessageRenderer.RenderActions(result.Sessions) : [];
await botClient.SendMessage(
chatId: message.Chat.Id,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
await messenger.SendGroupMessageAsync(command.Group, text, actions, cancellationToken);
}
}
@@ -0,0 +1,63 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Features.Sessions.ListSessions;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal static class SessionListMessageRenderer
{
public static string RenderText(IReadOnlyList<SessionListItemDto> sessions)
{
var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var session in sessions)
{
var seats = session.MaxPlayers.HasValue
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
}
return text;
}
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
{
if (sessions.Count == 0 || !sessions.First().CanManage)
{
return [];
}
var actions = new List<PlatformMessageAction>();
foreach (var session in sessions)
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
actions.Add(new PlatformMessageAction(
$"cancel_session:{session.Id}",
$"❌ {dateTitle}",
$"cancel_session:{session.Id}"));
actions.Add(new PlatformMessageAction(
$"reschedule_session:{session.Id}",
$"⏰ {dateTitle}",
$"reschedule_session:{session.Id}"));
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
{
actions.Add(new PlatformMessageAction(
$"promote_waitlist:{session.Id}",
$"⬆️ Из ожидания {dateTitle}",
$"promote_waitlist:{session.Id}"));
}
actions.Add(new PlatformMessageAction(
$"delete_session:{session.Id}",
$"🗑 Удалить {dateTitle}",
$"delete_session:{session.Id}"));
}
return actions;
}
}
@@ -12,243 +12,156 @@ using GmRelay.Bot.Infrastructure.Telegram;
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, int? ThreadId, string NotificationMode);
// ── 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.
/// Telegram adapter for reschedule time input.
/// Delegates core logic to the shared handler, then performs Telegram-specific
/// message sending, DM notifications, vote_message_id storage, and cleanup.
/// </summary>
public sealed class HandleRescheduleTimeInputHandler(
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler sharedHandler,
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
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();
var command = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputCommand(
new PlatformUser(
PlatformKind.Telegram,
message.From.Id.ToString(),
message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"),
message.From.Username),
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title),
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.external_group_id::BIGINT AS TelegramChatId,
s.thread_id AS ThreadId,
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_external_user_id = @ExternalGmId
AND rp.status = 'AwaitingTime'
AND g.platform = 'Telegram'
AND g.external_group_id = @ExternalChatId
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.platform = 'Telegram'
AND manager_player.external_user_id = @ExternalGmId
)
ORDER BY rp.created_at DESC
LIMIT 1
""",
new { ExternalGmId = gmTelegramId.ToString(), ExternalChatId = chatId.ToString() });
if (proposal is null)
var result = await sharedHandler.HandleAsync(command, ct);
if (!result.Handled)
return false;
// 2. Parse voting input
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
if (!string.IsNullOrEmpty(result.ReplyText) && !result.IsRescheduledImmediately)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
$"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
command.Group,
$"""⚠️ {result.ReplyText}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>""",
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.external_username AS TelegramUsername,
p.external_user_id::BIGINT 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)
if (result.IsRescheduledImmediately)
{
await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct);
await TryDeleteMessage(chatId, message.MessageId, ct);
if (result.UpdatedView is not null && result.BatchMessageId.HasValue)
{
await TryUpdateBatchMessage(
command.Group,
result.UpdatedView,
TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, result.BatchMessageId.Value),
ct);
}
await messenger.SendGroupMessageAsync(command.Group, result.ReplyText!, ct);
await TryDeleteMessage(message.Chat.Id, 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);
// Voting mode
var voteText = BuildVotingMessage(
proposal.Title,
proposal.CurrentScheduledAt,
votingInput.Deadline,
options,
participants,
result.Title!,
result.CurrentScheduledAt,
result.VotingDeadlineAt!.Value,
result.Options,
result.Participants,
[]);
var keyboard = BuildVotingKeyboard(options);
var keyboard = BuildVotingKeyboard(result.Options);
var voteMsg = await bot.SendMessage(
chatId: chatId,
messageThreadId: proposal.ThreadId,
chatId: message.Chat.Id,
messageThreadId: message.MessageThreadId,
text: voteText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
var mode = await GetNotificationModeAsync(result.ProposalId!.Value, ct);
if (mode.ShouldSendDirectMessages())
{
var optionsText = string.Join(
"\n",
options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
result.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> (МСК)
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
📅 Текущее время: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
🗳 Варианты:
{optionsText}
⏳ Дедлайн: <b>{votingInput.Deadline.FormatMoscow()}</b> (МСК)
⏳ Дедлайн: <b>{result.VotingDeadlineAt.Value.FormatMoscow()}</b> (МСК)
Проголосуйте кнопкой в групповом сообщении.
""";
await directSender.SendAsync(
participants.Select(p => new DirectNotificationRecipient(
result.Participants.Select(p => new DirectNotificationRecipient(
p.TelegramId,
p.DisplayName)),
directText,
"reschedule-vote",
proposal.SessionId,
result.ProposalId.Value,
ct);
}
// Store vote message ID
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
new { MsgId = voteMsg.MessageId, Id = proposal.Id });
new { MsgId = voteMsg.MessageId, Id = result.ProposalId.Value });
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);
result.ProposalId.Value,
result.ProposalId.Value,
result.Options.Count,
result.VotingDeadlineAt.Value);
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
return true;
}
private async Task RescheduleImmediately(
NpgsqlConnection connection, AwaitingProposalDto proposal,
DateTimeOffset newTime, long chatId, CancellationToken ct)
private async Task<SessionNotificationMode> GetNotificationModeAsync(Guid proposalId, CancellationToken ct)
{
await using var transaction = await connection.BeginTransactionAsync(ct);
await connection.ExecuteAsync(
await using var connection = await dataSource.OpenConnectionAsync(ct);
var raw = await connection.QuerySingleOrDefaultAsync<string?>(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
confirmation_sent_at = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
SELECT s.notification_mode
FROM sessions s
JOIN reschedule_proposals rp ON rp.session_id = s.id
WHERE rp.id = @Id
""",
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
new { Id = proposalId });
return SessionNotificationModeExtensions.FromDatabaseValue(raw ?? string.Empty);
}
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 messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
$"✅ Сессия «{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>",
private async Task TryUpdateBatchMessage(
PlatformGroup group,
SessionBatchViewModel view,
PlatformMessageRef scheduleMessage,
CancellationToken ct)
{
try
{
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(group, view, scheduleMessage),
ct);
// Re-render batch message with updated time
await TryUpdateBatchMessage(proposal, ct);
logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule");
}
}
internal static string BuildVotingMessage(
@@ -270,7 +183,7 @@ public sealed class HandleRescheduleTimeInputHandler(
var lines = new List<string>
{
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
$"""🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>""",
"",
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
@@ -351,52 +264,6 @@ public sealed class HandleRescheduleTimeInputHandler(
"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, join_link AS JoinLink 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.external_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 view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
view,
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
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
@@ -1,8 +1,7 @@
using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -15,131 +14,49 @@ public sealed record HandleRescheduleVoteCommand(
int MessageId);
public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource,
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
ITelegramBotClient bot,
IPlatformMessenger messenger,
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);
var platformUser = new PlatformUser(
PlatformKind.Telegram,
command.TelegramUserId.ToString(),
string.Empty,
null);
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { command.OptionId },
transaction);
var platformGroup = new PlatformGroup(
PlatformKind.Telegram,
command.ChatId.ToString(),
string.Empty);
if (proposal is null)
var sharedCommand = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteCommand(
command.OptionId,
platformUser,
platformGroup,
command.CallbackQueryId,
TelegramPlatformIds.Message(command.ChatId, null, command.MessageId));
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
if (!result.Success)
{
await AnswerAsync(command.CallbackQueryId, "Голосование уже завершено или не найдено.", ct);
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("дедлайн")),
ct);
return;
}
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
{
await AnswerAsync(command.CallbackQueryId, "Дедлайн уже прошёл. Результаты скоро будут применены.", ct, showAlert: true);
return;
}
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.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, ExternalUserId = command.TelegramUserId.ToString(), Active = ParticipantRegistrationStatus.Active },
transaction);
if (playerId is null)
{
await AnswerAsync(command.CallbackQueryId, "Вы не являетесь участником этой сессии.", ct);
return;
}
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id,
voted_at = now()
""",
new
{
ProposalId = proposal.Id,
PlayerId = playerId.Value,
command.OptionId
},
transaction);
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
p.external_user_id::BIGINT 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
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title,
proposal.CurrentScheduledAt,
proposal.VotingDeadlineAt,
options,
participants,
votes);
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
result.Title!,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Options,
result.Participants,
result.Votes);
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(result.Options);
try
{
@@ -153,12 +70,11 @@ public sealed class HandleRescheduleVoteHandler(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", result.ProposalId);
}
await AnswerAsync(command.CallbackQueryId, "Ваш голос учтён. До дедлайна его можно изменить.", ct);
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
ct);
}
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
}
@@ -95,6 +95,68 @@ public sealed class TelegramPlatformMessenger(
cancellationToken: ct);
}
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
EnsureTelegram(group.Platform);
await bot.SendMessage(
chatId: ParseLong(group.ExternalGroupId),
messageThreadId: ParseNullableInt(group.ExternalThreadId),
text: htmlText,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(actions),
cancellationToken: ct);
}
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
EnsureTelegram(messageRef.Platform);
await bot.EditMessageText(
chatId: ParseLong(messageRef.ExternalGroupId),
messageId: ParseInt(messageRef.ExternalMessageId),
text: htmlText,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(actions),
cancellationToken: ct);
}
public async Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
{
EnsureTelegram(group.Platform);
var topic = await bot.CreateForumTopic(
chatId: ParseLong(group.ExternalGroupId),
name: title,
cancellationToken: ct);
return new PlatformMessageRef(
PlatformKind.Telegram,
group.ExternalGroupId,
topic.MessageThreadId.ToString(CultureInfo.InvariantCulture),
string.Empty);
}
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct)
{
EnsureTelegram(group.Platform);
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
{
return Task.CompletedTask;
}
return bot.DeleteForumTopic(
ParseLong(group.ExternalGroupId),
ParseInt(group.ExternalThreadId),
cancellationToken: ct);
}
public Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
{
EnsureTelegram(messageRef.Platform);
return bot.DeleteMessage(
ParseLong(messageRef.ExternalGroupId),
ParseInt(messageRef.ExternalMessageId),
cancellationToken: ct);
}
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
EnsureTelegram(message.Recipient.Platform);
@@ -5,6 +5,9 @@ using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.ListSessions;
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using BotRescheduleVoteHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler;
using GmRelay.Bot.Features.Sessions.ExportCalendar;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using Telegram.Bot;
@@ -20,7 +23,7 @@ namespace GmRelay.Bot.Infrastructure.Telegram;
/// </summary>
public sealed class UpdateRouter(
HandleRsvpHandler rsvpHandler,
CreateSessionHandler createSessionHandler,
BotCreateSessionHandler createSessionHandler,
JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
@@ -29,8 +32,8 @@ public sealed class UpdateRouter(
ListSessionsHandler listSessionsHandler,
ExportCalendarHandler exportCalendarHandler,
InitiateRescheduleHandler initiateRescheduleHandler,
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
HandleRescheduleVoteHandler rescheduleVoteHandler,
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
BotRescheduleVoteHandler rescheduleVoteHandler,
ITelegramBotClient bot,
IConfiguration configuration,
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
+7 -3
View File
@@ -66,7 +66,8 @@ builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<CreateSessionHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
@@ -74,10 +75,13 @@ builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
builder.Services.AddSingleton<CancelSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
builder.Services.AddSingleton<InitiateRescheduleHandler>();
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
builder.Services.AddSingleton<DirectSessionNotificationSender>();
@@ -1,114 +1,46 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using Dapper;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Npgsql;
using NetCord.Rest;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record DiscordRescheduleVoteInput(
Guid OptionId, ulong UserId, string InteractionId,
string GuildId, string ChannelId, string MessageId);
Guid OptionId,
ulong UserId,
string InteractionId,
string GuildId,
string ChannelId,
string MessageId);
public sealed class DiscordRescheduleVoteHandler(
NpgsqlDataSource dataSource,
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
RestClient restClient,
ILogger<DiscordRescheduleVoteHandler> logger)
{
public async Task<string> HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var command = new HandleRescheduleVoteCommand(
input.OptionId,
new PlatformUser(PlatformKind.Discord, input.UserId.ToString(), string.Empty, null),
new PlatformGroup(PlatformKind.Discord, input.GuildId, string.Empty, input.ChannelId),
input.InteractionId,
new PlatformMessageRef(PlatformKind.Discord, input.ChannelId, null, input.MessageId));
// 1. Load proposal + option
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title, s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { input.OptionId },
transaction);
var result = await sharedHandler.HandleAsync(command, ct);
if (proposal is null)
return "Голосование уже завершено или не найдено.";
if (!result.Success)
{
return result.ReplyText!;
}
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
return "Дедлайн уже прошёл. Результаты скоро будут применены.";
// 2. Verify participant (Discord platform)
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.platform = 'Discord'
AND p.external_user_id = @UserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, UserId = input.UserId.ToString(), Active = ParticipantRegistrationStatus.Active },
transaction);
if (playerId is null)
return "Вы не являетесь участником этой сессии.";
// 3. Upsert vote
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id, voted_at = now()
""",
new { ProposalId = proposal.Id, PlayerId = playerId.Value, input.OptionId },
transaction);
// 4. Reload participants, options, votes for re-rendering
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 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
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId, display_order AS DisplayOrder, proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
// 5. Re-render and update Discord vote message
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt,
options, participants, votes);
result.Title!,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Options,
result.Participants,
result.Votes);
var channelIdUlong = ulong.Parse(input.ChannelId);
var messageIdUlong = ulong.Parse(input.MessageId);
@@ -123,9 +55,9 @@ public sealed class DiscordRescheduleVoteHandler(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id);
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", result.ProposalId);
}
return "Ваш голос учтён. До дедлайна его можно изменить.";
return result.ReplyText!;
}
}
@@ -77,6 +77,38 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
await restClient.SendMessageAsync(GetChannelId(group), htmlText);
}
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
var rows = BuildActionRows(actions);
await restClient.SendMessageAsync(GetChannelId(group), new MessageProperties().WithContent(htmlText).WithComponents(rows));
}
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
var messageId = ParseSnowflake(messageRef.ExternalMessageId);
var rows = BuildActionRows(actions);
await restClient.ModifyMessageAsync(channelId, messageId, options =>
{
options.Content = htmlText;
options.Components = rows;
});
}
public Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
{
// Discord thread creation is not implemented in this adapter
return Task.FromResult(new PlatformMessageRef(PlatformKind.Discord, group.ExternalGroupId, group.ExternalThreadId, string.Empty));
}
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) => Task.CompletedTask;
public async Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
{
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
await restClient.DeleteMessageAsync(channelId, ParseSnowflake(messageRef.ExternalMessageId));
}
public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
await SendDirectContentAsync(message.Recipient, message.HtmlText, ct);
@@ -403,6 +435,30 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
return ParseSnowflake(channelId);
}
private static IReadOnlyList<ActionRowProperties> BuildActionRows(IReadOnlyList<PlatformMessageAction> actions)
{
if (actions.Count == 0)
{
return [];
}
var rows = new List<ActionRowProperties>();
foreach (var chunk in actions.Chunk(5))
{
var row = new ActionRowProperties();
foreach (var action in chunk)
{
row.Add(new ButtonProperties(action.Key, action.Label, ButtonStyle.Secondary)
{
CustomId = action.Payload
});
}
rows.Add(row);
}
return rows;
}
private static ulong ParseSnowflake(string value) =>
ulong.Parse(value, CultureInfo.InvariantCulture);
}
+1
View File
@@ -59,6 +59,7 @@ builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<DiscordRescheduleHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
@@ -0,0 +1,12 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record CreateSessionCommand(
PlatformUser User,
PlatformGroup Group,
string Title,
string Link,
IReadOnlyList<DateTimeOffset> ScheduledTimes,
int? MaxPlayers,
string? ImageReference);
@@ -0,0 +1,157 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
public sealed class CreateSessionHandler(
NpgsqlDataSource dataSource)
{
public async Task<CreateSessionResult> HandleAsync(CreateSessionCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
try
{
var platform = command.User.Platform.ToString();
var externalUserId = command.User.ExternalUserId;
var displayName = command.User.DisplayName;
var externalUsername = command.User.ExternalUsername;
await connection.ExecuteAsync(
"""
INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, @Platform, @ExternalId, @Username)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE
SET display_name = EXCLUDED.display_name,
external_username = EXCLUDED.external_username;
""",
new { ExternalId = externalUserId, Name = displayName, Username = externalUsername },
transaction);
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
"""
SELECT g.id AS GroupId,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = g.id
AND p.platform = @Platform
AND p.external_user_id = @ExternalGmId
) AS CanManage
FROM game_groups g
WHERE g.platform = @Platform
AND g.external_group_id = @ExternalGroupId
""",
new { Platform = platform, ExternalGroupId = command.Group.ExternalGroupId, ExternalGmId = externalUserId },
transaction);
Guid groupId;
if (existingGroup is null)
{
groupId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
VALUES (@ChatName, @Platform, @ExternalGroupId, @ExternalChannelId)
RETURNING id;
""",
new
{
Platform = platform,
ExternalGroupId = command.Group.ExternalGroupId,
ExternalChannelId = command.Group.ExternalChannelId,
ChatName = command.Group.DisplayName
},
transaction);
await connection.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE p.platform = @Platform
AND p.external_user_id = @ExternalGmId
ON CONFLICT (group_id, player_id) DO NOTHING
""",
new { GroupId = groupId, ExternalGmId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
}
else
{
if (!existingGroup.CanManage)
{
await transaction.RollbackAsync(ct);
return new CreateSessionResult(
false,
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
null,
null,
null,
Array.Empty<string>());
}
groupId = existingGroup.GroupId;
await connection.ExecuteAsync(
"""
UPDATE game_groups
SET name = @ChatName
WHERE id = @GroupId
""",
new { ChatName = command.Group.DisplayName, GroupId = groupId },
transaction);
}
var batchId = Guid.NewGuid();
var sessions = new List<SessionBatchDto>();
var orderedTimes = command.ScheduledTimes.OrderBy(v => v).ToList();
foreach (var scheduledAt in orderedTimes)
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
RETURNING id;
""",
new
{
BatchId = batchId,
GroupId = groupId,
command.Title,
Link = command.Link,
ScheduledAt = scheduledAt,
Status = SessionStatus.Planned,
MaxPlayers = command.MaxPlayers
},
transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, command.MaxPlayers, command.Link));
}
await transaction.CommitAsync(ct);
var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty<ParticipantBatchDto>());
return new CreateSessionResult(
true,
null,
view,
batchId,
groupId,
Array.Empty<string>());
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
}
}
@@ -0,0 +1,11 @@
using GmRelay.Shared.Rendering;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record CreateSessionResult(
bool Success,
string? ErrorMessage,
SessionBatchViewModel? View,
Guid? BatchId,
Guid? GroupId,
IReadOnlyList<string> Warnings);
@@ -0,0 +1,7 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
public sealed record ExportCalendarCommand(
PlatformGroup Group,
PlatformUser User);
@@ -0,0 +1,111 @@
using System.Text;
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
IConfiguration configuration)
{
public async Task HandleAsync(ExportCalendarCommand command, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<CalendarSessionDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
+ " FROM sessions s"
+ " JOIN game_groups g ON s.group_id = g.id"
+ " WHERE g.platform = @Platform"
+ " AND g.external_group_id = @ExternalGroupId"
+ " AND s.status = @Planned"
+ " AND s.scheduled_at > NOW()"
+ " ORDER BY s.scheduled_at ASC",
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
{
await messenger.SendGroupMessageAsync(
command.Group,
"📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken);
return;
}
var sb = new StringBuilder();
sb.AppendLine("BEGIN:VCALENDAR");
sb.AppendLine("VERSION:2.0");
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
foreach (var s in sessionsList)
{
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
sb.AppendLine("BEGIN:VEVENT");
sb.AppendLine($"UID:{s.Id}@gmrelay");
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{s.Title}");
sb.AppendLine("END:VEVENT");
}
sb.AppendLine("END:VCALENDAR");
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
// Create calendar subscription
string? subscriptionUrl = null;
var baseUrl = configuration["Web:BaseUrl"];
var senderId = command.User.ExternalUserId;
if (!string.IsNullOrWhiteSpace(baseUrl) && !string.IsNullOrWhiteSpace(senderId))
{
try
{
var token = Guid.NewGuid().ToString("N");
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
@"SELECT id FROM game_groups WHERE platform = @Platform AND external_group_id = @ExternalGroupId",
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId });
await connection.ExecuteAsync(
@"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, @userPlatform, @userExternalId, @groupId, @filterType, now(), NULL)",
new { token, userPlatform = command.Group.Platform.ToString(), userExternalId = senderId, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
}
catch
{
// Non-critical: if subscription creation fails, still send the file
}
}
var actions = subscriptionUrl is not null
? new[]
{
new PlatformMessageAction(
"calendar-subscription",
"🔗 Подписаться на календарь",
subscriptionUrl)
}
: Array.Empty<PlatformMessageAction>();
await messenger.SendCalendarFileAsync(
new PlatformCalendarFile(
command.Group,
"schedule.ics",
bytes,
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
actions),
cancellationToken);
}
}
@@ -0,0 +1,91 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.ListSessions;
internal sealed record DeleteSessionInfoDto(
string Title,
Guid BatchId,
Guid GroupId,
bool CanManage,
int? ThreadId,
bool TopicCreatedByBot);
public sealed record DeleteSessionResult(
bool Success,
string? ReplyText,
string? Title,
Guid? GroupId,
int? ThreadId,
bool TopicCreatedByBot,
int RemainingInTopic);
public sealed class DeleteSessionHandler(
NpgsqlDataSource dataSource)
{
public async Task<DeleteSessionResult> HandleAsync(DeleteSessionCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Fetch session and verify group manager.
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.group_id AS GroupId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
""",
new { command.SessionId, Platform = command.User.Platform.ToString(), ExternalUserId = command.User.ExternalUserId }, transaction);
if (session == null)
{
return new DeleteSessionResult(false, "Сессия не найдена.", null, null, null, false, 0);
}
if (!session.CanManage)
{
return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0);
}
// 2. Delete session
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
var remainingInTopic = session.ThreadId.HasValue
? await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM sessions
WHERE group_id = @GroupId
AND thread_id = @ThreadId
""",
new { session.GroupId, ThreadId = session.ThreadId.Value },
transaction)
: 0;
await transaction.CommitAsync(ct);
return new DeleteSessionResult(
true,
"Сессия удалена!",
session.Title,
session.GroupId,
session.ThreadId,
session.TopicCreatedByBot,
remainingInTopic);
}
}
@@ -0,0 +1,13 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.ListSessions;
public sealed record ListSessionsCommand(
PlatformGroup Group,
PlatformUser User);
public sealed record DeleteSessionCommand(
Guid SessionId,
PlatformUser User,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
@@ -0,0 +1,57 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.ListSessions;
public sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
public sealed record SessionListResult(
IReadOnlyList<SessionListItemDto> Sessions,
bool CanManage);
public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource)
{
public async Task<SessionListResult> HandleAsync(ListSessionsCommand command, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
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.platform = @Platform
AND manager_player.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.platform = @Platform
AND g.external_group_id = @ExternalGroupId
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC",
new
{
Platform = command.Group.Platform.ToString(),
ExternalGroupId = command.Group.ExternalGroupId,
ExternalUserId = command.User.ExternalUserId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionsList = sessions.ToList();
var canManage = sessionsList.Count > 0 && sessionsList.First().CanManage;
return new SessionListResult(sessionsList, canManage);
}
}
@@ -0,0 +1,12 @@
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
internal sealed record AwaitingProposalDto(
Guid Id,
Guid SessionId,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
int? BatchMessageId,
string ExternalGroupId,
int? ThreadId,
string NotificationMode);
@@ -0,0 +1,15 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record HandleRescheduleTimeInputCommand(
PlatformUser User,
PlatformGroup Group,
string Text);
public sealed record HandleRescheduleVoteCommand(
Guid OptionId,
PlatformUser User,
PlatformGroup Group,
string InteractionId,
PlatformMessageRef ScheduleMessage);
@@ -0,0 +1,181 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed class HandleRescheduleTimeInputHandler(
NpgsqlDataSource dataSource)
{
public async Task<HandleRescheduleTimeInputResult> HandleAsync(
HandleRescheduleTimeInputCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var platform = command.User.Platform.ToString();
var externalGmId = command.User.ExternalUserId;
var externalGroupId = command.Group.ExternalGroupId;
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.external_group_id AS ExternalGroupId,
s.thread_id AS ThreadId,
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_external_user_id = @ExternalGmId
AND rp.status = 'AwaitingTime'
AND g.platform = @Platform
AND g.external_group_id = @ExternalGroupId
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.platform = @Platform
AND manager_player.external_user_id = @ExternalGmId
)
ORDER BY rp.created_at DESC
LIMIT 1
""",
new { ExternalGmId = externalGmId, Platform = platform, ExternalGroupId = externalGroupId });
if (proposal is null)
return new HandleRescheduleTimeInputResult(false, false, null, null, null, null, [], [], [], null, default, null);
if (!RescheduleVotingInput.TryParse(command.Text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
{
return new HandleRescheduleTimeInputResult(
true, false, parseError, null, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, null);
}
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
p.external_user_id::BIGINT 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();
if (participants.Count == 0)
{
var newTime = votingInput.Options[0];
var view = await RescheduleImmediatelyAsync(connection, proposal, newTime, ct);
var replyText =
$"""✅ Сессия «{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>""";
return new HandleRescheduleTimeInputResult(
true, true, replyText, view, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, proposal.BatchMessageId);
}
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 = @VoteChatId
WHERE id = @Id
""",
new { votingInput.Deadline, VoteChatId = externalGroupId, 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);
return new HandleRescheduleTimeInputResult(
true,
false,
null,
null,
proposal.Id,
votingInput.Deadline,
options,
participants,
[],
proposal.Title,
proposal.CurrentScheduledAt,
null);
}
private static async Task<SessionBatchViewModel?> RescheduleImmediatelyAsync(
NpgsqlConnection connection,
AwaitingProposalDto proposal,
DateTimeOffset newTime,
CancellationToken ct)
{
await using var transaction = await connection.BeginTransactionAsync(ct);
await connection.ExecuteAsync(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
confirmation_sent_at = NULL,
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);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink 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.external_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();
return SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
}
}
@@ -0,0 +1,17 @@
using GmRelay.Shared.Rendering;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record HandleRescheduleTimeInputResult(
bool Handled,
bool IsRescheduledImmediately,
string? ReplyText,
SessionBatchViewModel? UpdatedView,
Guid? ProposalId,
DateTimeOffset? VotingDeadlineAt,
IReadOnlyList<RescheduleOptionDto> Options,
IReadOnlyList<VoteParticipantDto> Participants,
IReadOnlyList<RescheduleOptionVoteDto> Votes,
string? Title,
DateTime CurrentScheduledAt,
int? BatchMessageId);
@@ -0,0 +1,156 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource)
{
public async Task<HandleRescheduleVoteResult> HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { command.OptionId },
transaction);
if (proposal is null)
{
return new HandleRescheduleVoteResult(
false,
"Голосование уже завершено или не найдено.",
null, null, null, default, default,
Array.Empty<VoteParticipantDto>(),
Array.Empty<RescheduleOptionDto>(),
Array.Empty<RescheduleOptionVoteDto>());
}
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
{
return new HandleRescheduleVoteResult(
false,
"Дедлайн уже прошёл. Результаты скоро будут применены.",
null, null, null, default, default,
Array.Empty<VoteParticipantDto>(),
Array.Empty<RescheduleOptionDto>(),
Array.Empty<RescheduleOptionVoteDto>());
}
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.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new
{
proposal.SessionId,
Platform = command.User.Platform.ToString(),
ExternalUserId = command.User.ExternalUserId,
Active = ParticipantRegistrationStatus.Active
},
transaction);
if (playerId is null)
{
return new HandleRescheduleVoteResult(
false,
"Вы не являетесь участником этой сессии.",
null, null, null, default, default,
Array.Empty<VoteParticipantDto>(),
Array.Empty<RescheduleOptionDto>(),
Array.Empty<RescheduleOptionVoteDto>());
}
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id,
voted_at = now()
""",
new
{
ProposalId = proposal.Id,
PlayerId = playerId.Value,
command.OptionId
},
transaction);
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
p.external_user_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
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
return new HandleRescheduleVoteResult(
true,
"Ваш голос учтён. До дедлайна его можно изменить.",
proposal.Id,
proposal.SessionId,
proposal.Title,
proposal.CurrentScheduledAt,
proposal.VotingDeadlineAt,
participants,
options,
votes);
}
}
@@ -0,0 +1,15 @@
using GmRelay.Shared.Features.Sessions.RescheduleSession;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record HandleRescheduleVoteResult(
bool Success,
string? ReplyText,
Guid? ProposalId,
Guid? SessionId,
string? Title,
DateTime CurrentScheduledAt,
DateTimeOffset VotingDeadlineAt,
IReadOnlyList<VoteParticipantDto> Participants,
IReadOnlyList<RescheduleOptionDto> Options,
IReadOnlyList<RescheduleOptionVoteDto> Votes);
@@ -2,7 +2,23 @@ namespace GmRelay.Shared.Platform;
public interface IPlatformMessenger
{
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support messages with actions.");
Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support message updates with actions.");
Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support thread creation.");
Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support thread deletion.");
Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support message deletion.");
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support schedule messages.");
Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
@@ -73,7 +73,7 @@
</button>
</form>
<div class="nav-version">v3.1.0</div>
<div class="nav-version">v3.2.0</div>
</div>
</Authorized>
<NotAuthorized>
+39 -2
View File
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Exceptions;
using GmRelay.Web.Services;
namespace GmRelay.Web.Services;
@@ -95,6 +96,7 @@ internal sealed record WebBatchSessionRow(
string NotificationMode,
bool TopicCreatedByBot = false);
internal sealed record WebTemplateGroupDto(long TelegramChatId);
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
public sealed class SessionService(
NpgsqlDataSource dataSource,
@@ -1186,6 +1188,10 @@ public sealed class SessionService(
throw new SessionAccessDeniedException(groupId, "0");
}
var topicDestination = await ResolveTemplateBatchTopicAsync(group.TelegramChatId, template.Title);
var messageThreadId = topicDestination.MessageThreadId;
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
var schedule = BatchSchedulePlanner.BuildRecurringSchedule(
firstScheduledAt,
template.SessionCount,
@@ -1197,8 +1203,8 @@ public sealed class SessionService(
{
var sessionId = await conn.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, notification_mode)
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @MaxPlayers, @NotificationMode)
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode)
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode)
RETURNING id
""",
new
@@ -1209,6 +1215,8 @@ public sealed class SessionService(
template.JoinLink,
ScheduledAt = scheduledAt,
Status = SessionStatus.Planned,
ThreadId = messageThreadId,
TopicCreatedByBot = topicCreatedByBot,
template.MaxPlayers,
template.NotificationMode
},
@@ -1223,6 +1231,7 @@ public sealed class SessionService(
var renderResult = TelegramSessionBatchRenderer.Render(view);
var batchMessage = await bot.SendMessage(
chatId: group.TelegramChatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup);
@@ -1242,6 +1251,34 @@ public sealed class SessionService(
template.NotificationMode);
}
private async Task<WebTemplateTopicDestination> ResolveTemplateBatchTopicAsync(long telegramChatId, string title)
{
var chat = await bot.GetChat(chatId: telegramChatId);
if (!chat.IsForum)
{
return new WebTemplateTopicDestination(null, TopicCreatedByBot: false);
}
try
{
var topic = await bot.CreateForumTopic(
chatId: telegramChatId,
name: $"🎲 Игры: {title}");
return new WebTemplateTopicDestination(topic.MessageThreadId, TopicCreatedByBot: true);
}
catch (ApiRequestException ex) when (IsMissingForumTopicRightsError(ex.Message))
{
throw new InvalidOperationException(
"Не удалось создать Telegram topic. Сделайте бота admin и включите право Manage Topics, затем повторите действие.",
ex);
}
}
private static bool IsMissingForumTopicRightsError(string apiError) =>
apiError.Contains("not enough rights", StringComparison.OrdinalIgnoreCase) ||
apiError.Contains("CHAT_ADMIN_REQUIRED", StringComparison.OrdinalIgnoreCase) ||
apiError.Contains("not an administrator", StringComparison.OrdinalIgnoreCase);
private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync(
Npgsql.NpgsqlConnection conn,
Guid sessionId)
@@ -62,7 +62,7 @@ public sealed class DiscordProjectStructureTests
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
Assert.Contains("gmrelay-discord-bot:3.1.0", compose);
Assert.Contains("gmrelay-discord-bot:3.2.0", compose);
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
@@ -76,13 +76,13 @@ public sealed class DiscordProjectStructureTests
{
var repoRoot = GetRepoRoot();
Assert.Contains("<Version>3.1.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 3.1.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:3.1.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:3.1.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:3.1.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("<Version>3.2.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 3.2.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains(
"v3.1.0",
"v3.2.0",
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
}
@@ -1,5 +1,5 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
@@ -69,14 +69,14 @@ public sealed class DiscordLandingPromisesSmokeTests
};
var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero);
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
var voteMessage = HandleRescheduleTimeInputHandler.BuildVotingMessage(
var voteMessage = BotRescheduleHandler.BuildVotingMessage(
scenario.Title,
scenario.Sessions[0].ScheduledAt,
deadline,
options,
voteParticipants,
[]);
var voteKeyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
var voteKeyboard = BotRescheduleHandler.BuildVotingKeyboard(options);
Assert.Contains("Landing Promise Smoke", voteMessage);
Assert.Contains("0/2", voteMessage);
@@ -1,5 +1,5 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Rendering;
@@ -65,14 +65,14 @@ public sealed class TelegramLandingPromisesSmokeTests
};
var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero);
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
var voteMessage = HandleRescheduleTimeInputHandler.BuildVotingMessage(
var voteMessage = BotRescheduleHandler.BuildVotingMessage(
scenario.Title,
scenario.Sessions[0].ScheduledAt,
deadline,
options,
voteParticipants,
[]);
var voteKeyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
var voteKeyboard = BotRescheduleHandler.BuildVotingKeyboard(options);
Assert.Contains("Landing Promise Smoke", voteMessage);
Assert.Contains("0/2", voteMessage);
@@ -0,0 +1,36 @@
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class CreateSessionCommandContractTests
{
[Fact]
public void CreateSessionCommand_ShouldExposePlatformNeutralContext()
{
AssertProperty<CreateSessionCommand>("User", typeof(PlatformUser));
AssertProperty<CreateSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<CreateSessionCommand>("Title", typeof(string));
AssertProperty<CreateSessionCommand>("Link", typeof(string));
AssertProperty<CreateSessionCommand>("ScheduledTimes", typeof(IReadOnlyList<DateTimeOffset>));
AssertProperty<CreateSessionCommand>("MaxPlayers", typeof(int?));
AssertProperty<CreateSessionCommand>("ImageReference", typeof(string));
AssertNoTelegramSpecificProperties<CreateSessionCommand>();
}
private static void AssertProperty<T>(string name, Type expectedType)
{
var property = Assert.Single(typeof(T).GetProperties(), p => p.Name == name);
Assert.Equal(expectedType, property.PropertyType);
}
private static void AssertNoTelegramSpecificProperties<T>()
{
var names = typeof(T).GetProperties().Select(p => p.Name).ToArray();
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
Assert.DoesNotContain("ChatId", names);
Assert.DoesNotContain("MessageId", names);
Assert.DoesNotContain("TelegramUserId", names);
Assert.DoesNotContain("TelegramUsername", names);
}
}
@@ -0,0 +1,36 @@
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class CreateSessionHandlerTests
{
[Fact]
public async Task SharedHandler_ShouldExist_AndBePlatformNeutral()
{
var handler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs");
Assert.Contains("CreateSessionCommand", handler, StringComparison.Ordinal);
Assert.Contains("CreateSessionResult", handler, StringComparison.Ordinal);
Assert.Contains("command.User", handler, StringComparison.Ordinal);
Assert.Contains("command.Group", handler, StringComparison.Ordinal);
Assert.DoesNotContain("ITelegramBotClient", handler, StringComparison.Ordinal);
Assert.DoesNotContain("Telegram.Bot", handler, StringComparison.Ordinal);
Assert.DoesNotContain("InlineKeyboardMarkup", handler, StringComparison.Ordinal);
Assert.DoesNotContain("MessageThreadId", handler, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -0,0 +1,31 @@
using GmRelay.Shared.Features.Sessions.ExportCalendar;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Features.Sessions.ExportCalendar;
public sealed class ExportCalendarCommandContractTests
{
[Fact]
public void ExportCalendarCommand_ShouldExposePlatformNeutralContext()
{
AssertProperty<ExportCalendarCommand>("Group", typeof(PlatformGroup));
AssertProperty<ExportCalendarCommand>("User", typeof(PlatformUser));
AssertNoTelegramSpecificProperties<ExportCalendarCommand>();
}
private static void AssertProperty<T>(string name, Type expectedType)
{
var property = Assert.Single(typeof(T).GetProperties(), p => p.Name == name);
Assert.Equal(expectedType, property.PropertyType);
}
private static void AssertNoTelegramSpecificProperties<T>()
{
var names = typeof(T).GetProperties().Select(p => p.Name).ToArray();
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
Assert.DoesNotContain("ChatId", names);
Assert.DoesNotContain("MessageId", names);
Assert.DoesNotContain("TelegramUserId", names);
Assert.DoesNotContain("TelegramUsername", names);
}
}
@@ -0,0 +1,41 @@
using GmRelay.Shared.Features.Sessions.ListSessions;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Features.Sessions.ListSessions;
public sealed class ListSessionsCommandContractTests
{
[Fact]
public void ListSessionsCommand_ShouldExposePlatformNeutralContext()
{
AssertProperty<ListSessionsCommand>("Group", typeof(PlatformGroup));
AssertProperty<ListSessionsCommand>("User", typeof(PlatformUser));
AssertNoTelegramSpecificProperties<ListSessionsCommand>();
}
[Fact]
public void DeleteSessionCommand_ShouldExposePlatformNeutralContext()
{
AssertProperty<DeleteSessionCommand>("SessionId", typeof(Guid));
AssertProperty<DeleteSessionCommand>("User", typeof(PlatformUser));
AssertProperty<DeleteSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<DeleteSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertNoTelegramSpecificProperties<DeleteSessionCommand>();
}
private static void AssertProperty<T>(string name, Type expectedType)
{
var property = Assert.Single(typeof(T).GetProperties(), p => p.Name == name);
Assert.Equal(expectedType, property.PropertyType);
}
private static void AssertNoTelegramSpecificProperties<T>()
{
var names = typeof(T).GetProperties().Select(p => p.Name).ToArray();
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
Assert.DoesNotContain("ChatId", names);
Assert.DoesNotContain("MessageId", names);
Assert.DoesNotContain("TelegramUserId", names);
Assert.DoesNotContain("TelegramUsername", names);
}
}
@@ -1,5 +1,6 @@
using GmRelay.Bot.Features.Sessions.ListSessions;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.ListSessions;
namespace GmRelay.Bot.Tests.Features.Sessions.ListSessions;
@@ -22,17 +23,15 @@ public sealed class SessionListMessageRendererTests
true)
};
var result = SessionListMessageRenderer.Render(sessions);
Assert.NotNull(result.Markup);
var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList();
var text = SessionListMessageRenderer.RenderText(sessions);
var actions = SessionListMessageRenderer.RenderActions(sessions);
Assert.Contains("Ravenloft", result.Text);
Assert.Collection(
buttons.Select(button => button.CallbackData),
callbackData => Assert.Equal($"cancel_session:{sessionId}", callbackData),
callbackData => Assert.Equal($"reschedule_session:{sessionId}", callbackData),
callbackData => Assert.Equal($"promote_waitlist:{sessionId}", callbackData),
callbackData => Assert.Equal($"delete_session:{sessionId}", callbackData));
Assert.Contains("Ravenloft", text);
Assert.Equal(4, actions.Count);
Assert.Contains(actions, a => a.Payload == $"cancel_session:{sessionId}");
Assert.Contains(actions, a => a.Payload == $"reschedule_session:{sessionId}");
Assert.Contains(actions, a => a.Payload == $"promote_waitlist:{sessionId}");
Assert.Contains(actions, a => a.Payload == $"delete_session:{sessionId}");
}
[Fact]
@@ -51,8 +50,7 @@ public sealed class SessionListMessageRendererTests
false)
};
var result = SessionListMessageRenderer.Render(sessions);
Assert.Null(result.Markup);
var actions = SessionListMessageRenderer.RenderActions(sessions);
Assert.Empty(actions);
}
}
@@ -1,4 +1,4 @@
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using BotHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
@@ -72,7 +72,7 @@ public sealed class HandleRescheduleTimeInputHandlerTests
new(secondOptionId, bobId, "Bob", null)
};
var text = HandleRescheduleTimeInputHandler.BuildVotingMessage(
var text = BotHandler.BuildVotingMessage(
"Shadowrun",
currentTime,
deadline,
@@ -101,7 +101,7 @@ public sealed class HandleRescheduleTimeInputHandlerTests
new(secondOptionId, 2, new DateTimeOffset(2026, 4, 27, 17, 0, 0, TimeSpan.Zero))
};
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
var keyboard = BotHandler.BuildVotingKeyboard(options);
var buttons = keyboard.InlineKeyboard.SelectMany(row => row).ToList();
Assert.Collection(
@@ -0,0 +1,43 @@
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
public sealed class RescheduleCommandContractTests
{
[Fact]
public void HandleRescheduleTimeInputCommand_ShouldExposePlatformNeutralContext()
{
AssertProperty<HandleRescheduleTimeInputCommand>("User", typeof(PlatformUser));
AssertProperty<HandleRescheduleTimeInputCommand>("Group", typeof(PlatformGroup));
AssertProperty<HandleRescheduleTimeInputCommand>("Text", typeof(string));
AssertNoTelegramSpecificProperties<HandleRescheduleTimeInputCommand>();
}
[Fact]
public void HandleRescheduleVoteCommand_ShouldExposePlatformNeutralContext()
{
AssertProperty<HandleRescheduleVoteCommand>("OptionId", typeof(Guid));
AssertProperty<HandleRescheduleVoteCommand>("User", typeof(PlatformUser));
AssertProperty<HandleRescheduleVoteCommand>("Group", typeof(PlatformGroup));
AssertProperty<HandleRescheduleVoteCommand>("InteractionId", typeof(string));
AssertProperty<HandleRescheduleVoteCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertNoTelegramSpecificProperties<HandleRescheduleVoteCommand>();
}
private static void AssertProperty<T>(string name, Type expectedType)
{
var property = Assert.Single(typeof(T).GetProperties(), p => p.Name == name);
Assert.Equal(expectedType, property.PropertyType);
}
private static void AssertNoTelegramSpecificProperties<T>()
{
var names = typeof(T).GetProperties().Select(p => p.Name).ToArray();
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
Assert.DoesNotContain("ChatId", names);
Assert.DoesNotContain("MessageId", names);
Assert.DoesNotContain("TelegramUserId", names);
Assert.DoesNotContain("TelegramUsername", names);
}
}
@@ -58,7 +58,7 @@ public sealed class PlatformIdentityMigrationTests
[Fact]
public async Task Code_ShouldQueryPlayersUsingExternalUserIdFallback()
{
var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs");
var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs");
Assert.Contains("external_user_id", createHandler, StringComparison.Ordinal);
}
@@ -66,7 +66,7 @@ public sealed class PlatformIdentityMigrationTests
[Fact]
public async Task Code_ShouldQueryGroupsUsingExternalGroupIdFallback()
{
var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs");
var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs");
Assert.Contains("external_group_id", createHandler, StringComparison.Ordinal);
}
@@ -20,12 +20,13 @@ public sealed class TelegramPlatformMessengerSourceTests
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs")]
[InlineData("src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs")]
public async Task SessionFlows_ShouldUsePlatformMessengerForOutboundTelegramWork(string relativePath)
[InlineData("src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs", "src/GmRelay.Shared/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs")]
public async Task SessionFlows_ShouldUsePlatformMessengerForOutboundTelegramWork(string relativePath, string? sharedPath = null)
{
var source = await ReadRepositoryFileAsync(relativePath);
var sharedSource = sharedPath is not null ? await ReadRepositoryFileAsync(sharedPath) : string.Empty;
Assert.Contains("IPlatformMessenger", source, StringComparison.Ordinal);
Assert.Contains("IPlatformMessenger", source + sharedSource, StringComparison.Ordinal);
Assert.DoesNotContain("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal);
Assert.DoesNotContain(".AnswerCallbackQuery(", source, StringComparison.Ordinal);
}
@@ -12,11 +12,11 @@ public sealed class TelegramTopicIntegrationSmokeTests
Assert.Contains("topic_created_by_bot", migration, StringComparison.Ordinal);
Assert.Contains("ResolveNewScheduleDestination", createHandler, StringComparison.Ordinal);
Assert.Contains("message.MessageThreadId", createHandler, StringComparison.Ordinal);
Assert.Contains("topic_created_by_bot", createHandler, StringComparison.Ordinal);
Assert.Contains("topicCreatedByBot", createHandler, StringComparison.Ordinal);
Assert.Contains("MissingForumTopicRightsMessage", createHandler, StringComparison.Ordinal);
Assert.Contains("TopicCreatedByBot", deleteHandler, StringComparison.Ordinal);
Assert.Contains("ShouldDeleteForumTopic", deleteHandler, StringComparison.Ordinal);
Assert.Contains("remainingInTopic", deleteHandler, StringComparison.Ordinal);
Assert.Contains("RemainingInTopic", deleteHandler, StringComparison.Ordinal);
}
[Fact]
@@ -28,6 +28,7 @@ public sealed class TelegramTopicIntegrationSmokeTests
var cancelHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs");
var initiateRescheduleHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs");
var rescheduleInputHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs");
var sharedRescheduleInputHandler = await ReadRepositoryFileAsync("src/GmRelay.Shared/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs");
var rescheduleDeadlineService = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs");
var telegramMessenger = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.cs");
@@ -48,9 +49,9 @@ public sealed class TelegramTopicIntegrationSmokeTests
Assert.Contains("int? MessageThreadId", initiateRescheduleHandler, StringComparison.Ordinal);
Assert.Contains("TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId)", initiateRescheduleHandler, StringComparison.Ordinal);
Assert.Contains("int? ThreadId", rescheduleInputHandler, StringComparison.Ordinal);
Assert.Contains("s.thread_id AS ThreadId", rescheduleInputHandler, StringComparison.Ordinal);
Assert.Contains("TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId)", rescheduleInputHandler, StringComparison.Ordinal);
Assert.Contains("message.MessageThreadId", rescheduleInputHandler, StringComparison.Ordinal);
Assert.Contains("s.thread_id AS ThreadId", sharedRescheduleInputHandler, StringComparison.Ordinal);
Assert.Contains("TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId", rescheduleInputHandler, StringComparison.Ordinal);
Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
@@ -60,6 +61,19 @@ public sealed class TelegramTopicIntegrationSmokeTests
Assert.Contains("ExternalThreadId", telegramMessenger, StringComparison.Ordinal);
}
[Fact]
public async Task WebTemplateBatches_ShouldCreateAndPersistForumTopic()
{
var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
Assert.Contains("GetChat", sessionService, StringComparison.Ordinal);
Assert.Contains("CreateForumTopic", sessionService, StringComparison.Ordinal);
Assert.Contains("thread_id, topic_created_by_bot", sessionService, StringComparison.Ordinal);
Assert.Contains("ThreadId = messageThreadId", sessionService, StringComparison.Ordinal);
Assert.Contains("TopicCreatedByBot = topicCreatedByBot", sessionService, StringComparison.Ordinal);
Assert.Contains("messageThreadId: messageThreadId", sessionService, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);