Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3418d1a46c | |||
| fac5d75c7e | |||
| 7a2965b43f | |||
| a0df94fc91 | |||
| 79694f7de8 | |||
| 542f15f2d6 | |||
| 64216f5a26 | |||
| 383e2c1d8d | |||
| bfa979a224 | |||
| c69ebf6c03 | |||
| 040b0a3cdb | |||
| a5aed14dd2 | |||
| 9fc434b42b | |||
| c2cc7fd9a8 | |||
| 3447acd8c4 | |||
| 56aeca5288 | |||
| 6ed0a120a0 | |||
| 682dd3fdec |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.0.10
|
||||
VERSION: 3.3.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>3.0.10</Version>
|
||||
<Version>3.3.0</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||
|
||||
**Текущая версия:** `v2.8.0`.
|
||||
**Текущая версия:** `v3.3.0`.
|
||||
|
||||
---
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
|
||||
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
|
||||
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
||||
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
|
||||
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
|
||||
- **📦 Bulk-операции для Batch Sessions**:
|
||||
- обновить общий `title`/`link` у всей пачки;
|
||||
|
||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
||||
crond -f
|
||||
|
||||
bot:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.0.10
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.3.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
discord:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.0.10
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.3.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -84,7 +84,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.0.10
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.3.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -8,8 +8,9 @@ C4Context
|
||||
|
||||
Person(gm, "Game Master", "Creates sessions and manages schedules")
|
||||
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
|
||||
Person(visitor, "Public visitor", "Views published club schedules without private player data")
|
||||
|
||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, and shared scheduling logic")
|
||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club pages, and shared scheduling logic")
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
|
||||
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
|
||||
@@ -19,6 +20,7 @@ C4Context
|
||||
Rel(gm, discord, "Uses /newsession and /listsessions")
|
||||
Rel(player, telegram, "Uses inline buttons")
|
||||
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
|
||||
Rel(visitor, gmrelay, "Views public club and session pages")
|
||||
Rel(telegram, gmrelay, "Updates via long polling")
|
||||
Rel(discord, gmrelay, "Gateway events and component interactions")
|
||||
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
||||
@@ -34,13 +36,14 @@ C4Container
|
||||
|
||||
Person(gm, "Game Master")
|
||||
Person(player, "Player")
|
||||
Person(visitor, "Public visitor")
|
||||
|
||||
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
|
||||
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
|
||||
Container(discordBot, "Discord Gateway Worker", "Внутри GmRelay.Bot", "NetCord Gateway, slash commands, scheduler notifications, button interactions, healthcheck :8082")
|
||||
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, editing and stats")
|
||||
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club pages, editing and stats")
|
||||
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
|
||||
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, platform identities")
|
||||
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, platform identities")
|
||||
}
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API")
|
||||
@@ -50,6 +53,7 @@ C4Container
|
||||
Rel(gm, discord, "Slash commands")
|
||||
Rel(player, telegram, "Callback queries")
|
||||
Rel(player, discord, "Button interactions")
|
||||
Rel(visitor, web, "Read-only public schedule pages")
|
||||
Rel(telegram, bot, "GetUpdates")
|
||||
Rel(discord, discordBot, "Gateway events")
|
||||
Rel(bot, telegram, "Bot API calls")
|
||||
|
||||
@@ -42,12 +42,13 @@ public sealed class CancelSessionHandler(
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
AND p.platform = 'Telegram'
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId }, transaction);
|
||||
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() }, transaction);
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
@@ -89,7 +90,7 @@ public sealed class CancelSessionHandler(
|
||||
|
||||
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
SELECT p.external_user_id::BIGINT AS TelegramId,
|
||||
p.display_name AS DisplayName
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
|
||||
@@ -1,292 +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";
|
||||
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
|
||||
message.Chat.IsForum,
|
||||
message.MessageThreadId);
|
||||
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
|
||||
var messageThreadId = topicDestination.MessageThreadId;
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
if (topicDestination.ShouldCreateForumTopic)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
||||
VALUES (@TgId, @Name, @Username, 'Telegram', @TgId::TEXT, @Username)
|
||||
ON CONFLICT (telegram_id) DO UPDATE
|
||||
SET display_name = EXCLUDED.display_name,
|
||||
telegram_username = EXCLUDED.telegram_username,
|
||||
platform = COALESCE(players.platform, 'Telegram'),
|
||||
external_user_id = COALESCE(players.external_user_id, EXCLUDED.telegram_id::TEXT),
|
||||
external_username = COALESCE(players.external_username, EXCLUDED.telegram_username);
|
||||
""",
|
||||
new { TgId = gmId, 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 COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT
|
||||
) AS CanManage
|
||||
FROM game_groups g
|
||||
WHERE COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) = @ChatId::TEXT
|
||||
""",
|
||||
new { ChatId = chatId, GmId = gmId },
|
||||
transaction);
|
||||
|
||||
Guid groupId;
|
||||
if (existingGroup is null)
|
||||
{
|
||||
groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
|
||||
VALUES (@ChatId, @ChatName, @GmId, 'Telegram', @ChatId::TEXT)
|
||||
RETURNING id;
|
||||
""",
|
||||
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO group_managers (group_id, player_id, role)
|
||||
SELECT @GroupId, p.id, @OwnerRole
|
||||
FROM players p
|
||||
WHERE COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT
|
||||
ON CONFLICT (group_id, player_id) DO NOTHING
|
||||
""",
|
||||
new { GroupId = groupId, GmId = gmId, 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;
|
||||
if (topicDestination.ShouldCreateForumTopic)
|
||||
{
|
||||
try
|
||||
{
|
||||
var topic = await botClient.CreateForumTopic(
|
||||
chatId: chatId,
|
||||
name: $"🎲 Игры: {title}",
|
||||
cancellationToken: cancellationToken);
|
||||
messageThreadId = topic.MessageThreadId;
|
||||
}
|
||||
catch (Telegram.Bot.Exceptions.ApiRequestException ex)
|
||||
when (TelegramTopicRouting.IsMissingForumTopicRightsError(ex.Message))
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
TelegramTopicRouting.MissingForumTopicRightsMessage,
|
||||
cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var batchId = Guid.NewGuid();
|
||||
var sessions = new List<SessionBatchDto>();
|
||||
|
||||
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);
|
||||
|
||||
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-фото с подписью
|
||||
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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
batchMessage = await botClient.SendMessage(
|
||||
chatId: chatId,
|
||||
messageThreadId: messageThreadId,
|
||||
text: renderResult.Text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: renderResult.Markup,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
|
||||
new { MsgId = batchMessage.MessageId, BatchId = batchId });
|
||||
|
||||
try
|
||||
{
|
||||
await botClient.DeleteMessage(
|
||||
chatId: chatId,
|
||||
messageId: message.MessageId,
|
||||
cancellationToken: cancellationToken);
|
||||
var topicRef = await messenger.CreateThreadAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
$"🎲 Игры: {parseResult.Title}",
|
||||
ct);
|
||||
messageThreadId = int.Parse(topicRef.ExternalThreadId!, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception ex)
|
||||
when (ex.Message.Contains("not enough rights") ||
|
||||
ex.Message.Contains("CHAT_ADMIN_REQUIRED") ||
|
||||
ex.Message.Contains("not an administrator"))
|
||||
{
|
||||
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, chatId);
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
TelegramTopicRouting.MissingForumTopicRightsMessage,
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
var command = new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionCommand(
|
||||
platformUser,
|
||||
platformGroup,
|
||||
parseResult.Title!,
|
||||
parseResult.Link!,
|
||||
parseResult.ScheduledTimes,
|
||||
parseResult.MaxPlayers,
|
||||
imageReference);
|
||||
|
||||
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionResult result;
|
||||
try
|
||||
{
|
||||
result = await sharedHandler.HandleAsync(command, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
"💥 Произошла ошибка базы данных при создании сессии.",
|
||||
ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
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 = batchMessageId, BatchId = result.BatchId });
|
||||
}
|
||||
|
||||
// Delete original message
|
||||
try
|
||||
{
|
||||
await messenger.DeleteMessageAsync(
|
||||
TelegramPlatformIds.Message(message.Chat.Id, null, message.MessageId),
|
||||
ct);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,13 +41,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
AND p.platform = 'Telegram'
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
FOR UPDATE
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId },
|
||||
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() },
|
||||
transaction);
|
||||
|
||||
if (session is null)
|
||||
@@ -150,7 +151,7 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
p.external_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
|
||||
@@ -1,113 +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.telegram_chat_id = @ChatId"
|
||||
+ " AND s.status = @Planned"
|
||||
+ " AND s.scheduled_at > NOW()"
|
||||
+ " ORDER BY s.scheduled_at ASC",
|
||||
new { ChatId = message.Chat.Id, 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 telegram_chat_id = @ChatId",
|
||||
new { ChatId = message.Chat.Id });
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
|
||||
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
|
||||
new { token, userTelegramId = senderId.Value, 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,138 +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.telegram_id = @TelegramUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId }, 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.telegram_id = @TelegramUserId
|
||||
) 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.telegram_chat_id = @ChatId 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
|
||||
{
|
||||
ChatId = command.ChatId,
|
||||
command.TelegramUserId,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||
});
|
||||
// 5. Update the /listsessions message
|
||||
var listCommand = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(platformGroup, platformUser);
|
||||
var listResult = await listSessionsHandler.HandleAsync(listCommand, ct);
|
||||
|
||||
var sessionsList = sessions.ToList();
|
||||
|
||||
if (sessionsList.Count == 0)
|
||||
if (listResult.Sessions.Count == 0)
|
||||
{
|
||||
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch { }
|
||||
try
|
||||
{
|
||||
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,114 +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.telegram_id = @TelegramUserId
|
||||
) 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.telegram_chat_id = @ChatId 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
|
||||
{
|
||||
ChatId = message.Chat.Id,
|
||||
TelegramUserId = message.From?.Id,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||
});
|
||||
var result = await sharedHandler.HandleAsync(command, cancellationToken);
|
||||
|
||||
var sessionsList = sessions.ToList();
|
||||
|
||||
if (sessionsList.Count == 0)
|
||||
if (result.Sessions.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;
|
||||
}
|
||||
}
|
||||
+80
-211
@@ -12,241 +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.telegram_chat_id 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 = @GmId
|
||||
AND rp.status = 'AwaitingTime'
|
||||
AND g.telegram_chat_id = @ChatId
|
||||
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.telegram_id = @GmId
|
||||
)
|
||||
ORDER BY rp.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
new { GmId = gmTelegramId, ChatId = chatId });
|
||||
|
||||
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.telegram_username AS TelegramUsername,
|
||||
p.telegram_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
|
||||
""",
|
||||
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>",
|
||||
ct);
|
||||
|
||||
// Re-render batch message with updated time
|
||||
await TryUpdateBatchMessage(proposal, ct);
|
||||
|
||||
logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId);
|
||||
private async Task TryUpdateBatchMessage(
|
||||
PlatformGroup group,
|
||||
SessionBatchViewModel view,
|
||||
PlatformMessageRef scheduleMessage,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(group, view, scheduleMessage),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule");
|
||||
}
|
||||
}
|
||||
|
||||
internal static string BuildVotingMessage(
|
||||
@@ -268,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> (МСК)",
|
||||
@@ -349,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.telegram_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
|
||||
|
||||
+35
-118
@@ -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,130 +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.telegram_id = @TelegramUserId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
""",
|
||||
new { proposal.SessionId, command.TelegramUserId, 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.telegram_username AS TelegramUsername,
|
||||
p.telegram_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.telegram_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
|
||||
{
|
||||
@@ -152,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);
|
||||
}
|
||||
|
||||
@@ -45,12 +45,13 @@ public sealed class InitiateRescheduleHandler(
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
AND p.platform = 'Telegram'
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId AND s.status != @Cancelled
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled });
|
||||
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString(), Cancelled = SessionStatus.Cancelled });
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
@@ -83,10 +84,10 @@ public sealed class InitiateRescheduleHandler(
|
||||
// 3. Create proposal in AwaitingTime status
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO reschedule_proposals (session_id, proposed_by, source_platform, status)
|
||||
VALUES (@SessionId, @GmId, 'Telegram', 'AwaitingTime')
|
||||
INSERT INTO reschedule_proposals (session_id, proposed_by_external_user_id, source_platform, status)
|
||||
VALUES (@SessionId, @ProposedBy, 'Telegram', 'AwaitingTime')
|
||||
""",
|
||||
new { command.SessionId, GmId = command.TelegramUserId });
|
||||
new { command.SessionId, ProposedBy = command.TelegramUserId.ToString() });
|
||||
|
||||
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
|
||||
|
||||
|
||||
+2
-2
@@ -79,7 +79,7 @@ public sealed class RescheduleVotingDeadlineService(
|
||||
"""
|
||||
SELECT rp.vote_message_id AS VoteMessageId,
|
||||
s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.external_group_id::BIGINT AS TelegramChatId,
|
||||
s.thread_id AS ThreadId
|
||||
FROM reschedule_proposals rp
|
||||
JOIN sessions s ON s.id = rp.session_id
|
||||
@@ -169,7 +169,7 @@ public sealed class RescheduleVotingDeadlineService(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
p.external_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- =============================================================
|
||||
-- V024: Deprecate legacy Telegram-specific columns
|
||||
-- =============================================================
|
||||
-- Scope: Complete platform migration by backfilling any remaining
|
||||
-- external_* gaps and officially deprecating telegram_* columns.
|
||||
-- No columns are dropped — rollback-safe.
|
||||
-- =============================================================
|
||||
|
||||
-- 1. Backfill players platform identity (safeguard for any rows missed in V016)
|
||||
UPDATE players
|
||||
SET platform = 'Telegram',
|
||||
external_user_id = telegram_id::TEXT,
|
||||
external_username = telegram_username
|
||||
WHERE platform IS NULL;
|
||||
|
||||
-- 2. Backfill game_groups platform identity (safeguard for any rows missed in V016)
|
||||
UPDATE game_groups
|
||||
SET platform = 'Telegram',
|
||||
external_group_id = telegram_chat_id::TEXT
|
||||
WHERE platform IS NULL;
|
||||
|
||||
-- 3. Add platform identity to calendar_subscriptions
|
||||
ALTER TABLE calendar_subscriptions
|
||||
ADD COLUMN user_platform VARCHAR(50),
|
||||
ADD COLUMN user_external_id VARCHAR(255);
|
||||
|
||||
UPDATE calendar_subscriptions
|
||||
SET user_external_id = user_telegram_id::TEXT,
|
||||
user_platform = 'Telegram'
|
||||
WHERE user_platform IS NULL;
|
||||
|
||||
-- 4. Migrate calendar subscription index
|
||||
DROP INDEX IF EXISTS ix_calendar_subscriptions_user_telegram_id;
|
||||
CREATE INDEX ix_calendar_subscriptions_user_external_id ON calendar_subscriptions (user_external_id);
|
||||
|
||||
-- 5. Deprecation comments on legacy columns
|
||||
COMMENT ON COLUMN players.telegram_id IS 'DEPRECATED: use platform + external_user_id';
|
||||
COMMENT ON COLUMN players.telegram_username IS 'DEPRECATED: use external_username';
|
||||
COMMENT ON COLUMN game_groups.telegram_chat_id IS 'DEPRECATED: use platform + external_group_id';
|
||||
COMMENT ON COLUMN game_groups.gm_telegram_id IS 'DEPRECATED: group ownership is tracked in group_managers';
|
||||
COMMENT ON COLUMN calendar_subscriptions.user_telegram_id IS 'DEPRECATED: use user_platform + user_external_id';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- =============================================================
|
||||
-- V025: Backfill proposed_by_external_user_id for Telegram proposals
|
||||
-- =============================================================
|
||||
-- Scope: Ensure all reschedule_proposals have proposed_by_external_user_id
|
||||
-- populated so that InitiateRescheduleHandler can stop writing proposed_by.
|
||||
-- =============================================================
|
||||
|
||||
UPDATE reschedule_proposals
|
||||
SET proposed_by_external_user_id = proposed_by::TEXT
|
||||
WHERE proposed_by_external_user_id IS NULL
|
||||
AND proposed_by IS NOT NULL;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Public club pages and read-only schedule publication controls.
|
||||
|
||||
ALTER TABLE game_groups
|
||||
ADD COLUMN public_slug VARCHAR(120),
|
||||
ADD COLUMN public_schedule_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN public_schedule_updated_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE UNIQUE INDEX ux_game_groups_public_slug
|
||||
ON game_groups (lower(public_slug))
|
||||
WHERE public_slug IS NOT NULL;
|
||||
|
||||
CREATE INDEX ix_sessions_public_schedule
|
||||
ON sessions (group_id, scheduled_at)
|
||||
WHERE is_public = true AND status <> 'Cancelled';
|
||||
@@ -66,18 +66,24 @@ 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>();
|
||||
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
|
||||
builder.Services.AddSingleton<CancelSessionHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler>();
|
||||
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>();
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public sealed record DiscordDeleteSessionResult(
|
||||
string ReplyText,
|
||||
SessionBatchViewModel? UpdatedView,
|
||||
string? EmptyMessage = null);
|
||||
|
||||
public sealed class DiscordDeleteSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordPermissionChecker permissionChecker,
|
||||
DiscordListSessionsHandler listSessionsHandler,
|
||||
ILogger<DiscordDeleteSessionHandler> logger)
|
||||
{
|
||||
public async Task<DiscordDeleteSessionResult> HandleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
ulong userId,
|
||||
ulong resolvedPermissions,
|
||||
ulong guildOwnerId,
|
||||
Guid sessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
||||
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
||||
new { GuildId = guildId });
|
||||
|
||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||
{
|
||||
return new DiscordDeleteSessionResult(
|
||||
"Только owner, администратор или manager могут удалять сессии.",
|
||||
UpdatedView: null);
|
||||
}
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
var deletedRows = await connection.ExecuteAsync(
|
||||
"""
|
||||
DELETE FROM sessions s
|
||||
USING game_groups g
|
||||
WHERE s.group_id = g.id
|
||||
AND s.id = @SessionId
|
||||
AND g.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId
|
||||
""",
|
||||
new { SessionId = sessionId, GuildId = guildId },
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
if (deletedRows == 0)
|
||||
{
|
||||
return new DiscordDeleteSessionResult(
|
||||
"Сессия не найдена или уже удалена.",
|
||||
UpdatedView: null);
|
||||
}
|
||||
|
||||
logger.LogInformation("Deleted Discord session {SessionId} in guild {GuildId}", sessionId, guildId);
|
||||
|
||||
var updatedView = await listSessionsHandler.BuildScheduleAsync(
|
||||
guildId,
|
||||
channelId,
|
||||
userId,
|
||||
resolvedPermissions,
|
||||
guildOwnerId,
|
||||
cancellationToken);
|
||||
|
||||
return updatedView is null
|
||||
? new DiscordDeleteSessionResult(
|
||||
"Сессия удалена.",
|
||||
UpdatedView: null,
|
||||
EmptyMessage: "В этом сервере нет предстоящих игр.")
|
||||
: new DiscordDeleteSessionResult("Сессия удалена.", updatedView);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ApplicationCommands;
|
||||
|
||||
@@ -18,8 +19,17 @@ public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandC
|
||||
var guildId = Context.Interaction.GuildId?.ToString()
|
||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
||||
var channelId = Context.Channel.Id.ToString();
|
||||
var member = Context.User as GuildInteractionUser;
|
||||
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
|
||||
var guildOwnerId = 0UL;
|
||||
|
||||
var view = await _handler.BuildScheduleAsync(guildId, channelId, CancellationToken.None);
|
||||
var view = await _handler.BuildScheduleAsync(
|
||||
guildId,
|
||||
channelId,
|
||||
Context.User.Id,
|
||||
resolvedPermissions,
|
||||
guildOwnerId,
|
||||
CancellationToken.None);
|
||||
|
||||
if (view is null)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
@@ -9,11 +10,22 @@ internal sealed record DiscordSessionListItemDto(
|
||||
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
|
||||
int PlayerCount, int WaitlistCount);
|
||||
|
||||
public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
||||
public sealed class DiscordListSessionsHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordPermissionChecker permissionChecker)
|
||||
{
|
||||
public Task<SessionBatchViewModel?> BuildScheduleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
CancellationToken cancellationToken) =>
|
||||
BuildScheduleAsync(guildId, channelId, 0, 0, 0, cancellationToken);
|
||||
|
||||
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
ulong userId,
|
||||
ulong resolvedPermissions,
|
||||
ulong guildOwnerId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
@@ -29,7 +41,7 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
||||
WHERE g.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId
|
||||
AND s.status != @Cancelled
|
||||
AND s.scheduled_at > NOW()
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
|
||||
ORDER BY s.scheduled_at ASC",
|
||||
new
|
||||
@@ -44,11 +56,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
||||
if (sessionList.Count == 0)
|
||||
return null;
|
||||
|
||||
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
||||
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
||||
new { GuildId = guildId });
|
||||
|
||||
var canManage = permissionChecker.CanManageSchedule(
|
||||
guildOwnerId,
|
||||
userId,
|
||||
dbManagerUserIds,
|
||||
resolvedPermissions);
|
||||
|
||||
var sessionIds = sessionList.Select(s => s.Id).ToList();
|
||||
var participants = await connection.QueryAsync<ParticipantBatchDto>(
|
||||
@"SELECT sp.session_id as SessionId,
|
||||
p.display_name as DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
|
||||
p.external_username as TelegramUsername,
|
||||
sp.registration_status as RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
@@ -60,6 +86,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
||||
var batchDtos = sessionList.Select(s => new SessionBatchDto(
|
||||
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
|
||||
|
||||
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
|
||||
var view = SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
|
||||
return canManage ? AddManagerActions(view) : view;
|
||||
}
|
||||
|
||||
internal static SessionBatchViewModel AddManagerActions(SessionBatchViewModel view) =>
|
||||
view with
|
||||
{
|
||||
Sessions = view.Sessions
|
||||
.Select(session =>
|
||||
{
|
||||
if (SessionStatus.IsCancelled(session.Status))
|
||||
return session;
|
||||
|
||||
var actions = session.AvailableActions
|
||||
.Concat([new AvailableAction("delete_session", $"Удалить {session.ScheduledAt.FormatMoscowShort()}", session.SessionId)])
|
||||
.ToList();
|
||||
|
||||
return session with { AvailableActions = actions };
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,10 +44,12 @@ public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandCon
|
||||
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
|
||||
|
||||
ulong guildOwnerId = 0;
|
||||
var guildName = guildId.ToString();
|
||||
try
|
||||
{
|
||||
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
|
||||
guildOwnerId = guild.OwnerId;
|
||||
guildName = guild.Name;
|
||||
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
|
||||
}
|
||||
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
@@ -80,6 +82,7 @@ public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandCon
|
||||
var view = await _handler.HandleAsync(
|
||||
guildId: guildId.ToString(),
|
||||
channelId: Context.Channel!.Id.ToString(),
|
||||
groupName: guildName,
|
||||
userId: Context.User.Id,
|
||||
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
|
||||
resolvedPermissions: resolvedPermissions,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using System.Globalization;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
@@ -12,35 +12,40 @@ public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, strin
|
||||
public sealed class DiscordNewSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordPermissionChecker permissionChecker,
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<DiscordNewSessionHandler> logger)
|
||||
{
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
|
||||
public static TimeParseResult ParseTimeInput(string input)
|
||||
{
|
||||
if (DateTimeOffset.TryParseExact(
|
||||
input.Trim(),
|
||||
var trimmed = input.Trim();
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
trimmed,
|
||||
"yyyy-MM-dd HH:mm",
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AssumeUniversal,
|
||||
out var result))
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var dt1))
|
||||
{
|
||||
if (result < DateTimeOffset.UtcNow)
|
||||
var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime();
|
||||
if (offset < DateTimeOffset.UtcNow)
|
||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
||||
|
||||
return new TimeParseResult(true, result.ToUniversalTime(), null);
|
||||
return new TimeParseResult(true, offset, null);
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParseExact(
|
||||
input.Trim(),
|
||||
if (DateTime.TryParseExact(
|
||||
trimmed,
|
||||
"dd.MM.yyyy HH:mm",
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AssumeUniversal,
|
||||
out var altResult))
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var dt2))
|
||||
{
|
||||
if (altResult < DateTimeOffset.UtcNow)
|
||||
var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime();
|
||||
if (offset < DateTimeOffset.UtcNow)
|
||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
||||
|
||||
return new TimeParseResult(true, altResult.ToUniversalTime(), null);
|
||||
return new TimeParseResult(true, offset, null);
|
||||
}
|
||||
|
||||
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
|
||||
@@ -49,6 +54,7 @@ public sealed class DiscordNewSessionHandler(
|
||||
public async Task<SessionBatchViewModel> HandleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
string groupName,
|
||||
ulong userId,
|
||||
string userDisplayName,
|
||||
ulong resolvedPermissions,
|
||||
@@ -60,13 +66,18 @@ public sealed class DiscordNewSessionHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
var displayGroupName = string.IsNullOrWhiteSpace(groupName) || string.Equals(groupName, guildId, StringComparison.Ordinal)
|
||||
? title
|
||||
: groupName.Trim();
|
||||
|
||||
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
||||
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
||||
WHERE g.platform = 'Discord'
|
||||
AND p.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId",
|
||||
new { GuildId = guildId });
|
||||
|
||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||
@@ -75,6 +86,7 @@ public sealed class DiscordNewSessionHandler(
|
||||
}
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
var transactionCommitted = false;
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
@@ -89,13 +101,13 @@ public sealed class DiscordNewSessionHandler(
|
||||
|
||||
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
|
||||
VALUES (@GuildId, 'Discord', @GuildId, @ChannelId)
|
||||
VALUES (@GroupName, 'Discord', @GuildId, @ChannelId)
|
||||
ON CONFLICT (platform, external_group_id)
|
||||
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
|
||||
DO UPDATE SET name = EXCLUDED.name,
|
||||
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
|
||||
RETURNING id",
|
||||
new { GuildId = guildId, ChannelId = channelId },
|
||||
new { GroupName = displayGroupName, GuildId = guildId, ChannelId = channelId },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
@@ -125,23 +137,19 @@ public sealed class DiscordNewSessionHandler(
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
transactionCommitted = true;
|
||||
logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId);
|
||||
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
|
||||
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||
|
||||
await messenger.SendScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId),
|
||||
view,
|
||||
null),
|
||||
cancellationToken);
|
||||
|
||||
return view;
|
||||
return SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
if (!transactionCommitted)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
|
||||
p.external_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
@@ -14,6 +16,7 @@ public sealed class DiscordSessionInteractionModule(
|
||||
JoinSessionHandler joinSessionHandler,
|
||||
LeaveSessionHandler leaveSessionHandler,
|
||||
HandleRsvpHandler rsvpHandler,
|
||||
DiscordDeleteSessionHandler deleteSessionHandler,
|
||||
DiscordRescheduleVoteHandler voteHandler,
|
||||
DiscordInteractionReplyCache interactionReplies,
|
||||
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
|
||||
@@ -28,21 +31,22 @@ public sealed class DiscordSessionInteractionModule(
|
||||
}
|
||||
|
||||
var input = CreateInput(parsedSessionId);
|
||||
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
||||
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||
SessionInteractionResult result;
|
||||
try
|
||||
{
|
||||
await joinSessionHandler.HandleAsync(
|
||||
DiscordSessionInteractionMapper.CreateJoinCommand(input),
|
||||
result = await joinSessionHandler.HandleAsync(
|
||||
DiscordSessionInteractionMapper.CreateJoinCommand(input) with { DeferScheduleUpdate = true },
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
|
||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
|
||||
return;
|
||||
}
|
||||
|
||||
await CompleteWithStoredReplyAsync(input.InteractionId);
|
||||
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
|
||||
}
|
||||
|
||||
[ComponentInteraction("leave_session")]
|
||||
@@ -55,21 +59,56 @@ public sealed class DiscordSessionInteractionModule(
|
||||
}
|
||||
|
||||
var input = CreateInput(parsedSessionId);
|
||||
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
||||
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||
SessionInteractionResult result;
|
||||
try
|
||||
{
|
||||
await leaveSessionHandler.HandleAsync(
|
||||
DiscordSessionInteractionMapper.CreateLeaveCommand(input),
|
||||
result = await leaveSessionHandler.HandleAsync(
|
||||
DiscordSessionInteractionMapper.CreateLeaveCommand(input) with { DeferScheduleUpdate = true },
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
|
||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
|
||||
return;
|
||||
}
|
||||
|
||||
await CompleteWithStoredReplyAsync(input.InteractionId);
|
||||
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
|
||||
}
|
||||
|
||||
[ComponentInteraction("delete_session")]
|
||||
public async Task DeleteAsync(string sessionId)
|
||||
{
|
||||
if (!Guid.TryParse(sessionId, out var parsedSessionId))
|
||||
{
|
||||
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
|
||||
return;
|
||||
}
|
||||
|
||||
var input = CreateInput(parsedSessionId);
|
||||
var member = Context.User as GuildInteractionUser;
|
||||
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
|
||||
|
||||
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||
try
|
||||
{
|
||||
var result = await deleteSessionHandler.HandleAsync(
|
||||
guildId: input.GuildId,
|
||||
channelId: input.ChannelId,
|
||||
userId: input.UserId,
|
||||
resolvedPermissions: resolvedPermissions,
|
||||
guildOwnerId: 0,
|
||||
sessionId: parsedSessionId,
|
||||
CancellationToken.None);
|
||||
|
||||
await CompleteDeleteResponseAsync(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord delete interaction for session {SessionId}", parsedSessionId);
|
||||
await FollowupEphemeralAsync("Не удалось удалить сессию.");
|
||||
}
|
||||
}
|
||||
|
||||
[ComponentInteraction("rsvp")]
|
||||
@@ -124,7 +163,7 @@ public sealed class DiscordSessionInteractionModule(
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
|
||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,9 +229,85 @@ public sealed class DiscordSessionInteractionModule(
|
||||
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
|
||||
}
|
||||
|
||||
private async Task CompleteScheduleUpdateResponseAsync(string interactionId, SessionInteractionResult result)
|
||||
{
|
||||
var updatedView = result.UpdatedView;
|
||||
if (updatedView is not null && SourceMessageHasDeleteAction())
|
||||
{
|
||||
updatedView = DiscordListSessionsHandler.AddManagerActions(updatedView);
|
||||
}
|
||||
|
||||
if (updatedView is not null)
|
||||
{
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(updatedView);
|
||||
await ModifyResponseAsync(options =>
|
||||
{
|
||||
options.Embeds = embeds;
|
||||
options.Components = actionRows;
|
||||
});
|
||||
}
|
||||
|
||||
var reply = interactionReplies.Take(interactionId);
|
||||
await FollowupEphemeralAsync(reply?.Text ?? result.ReplyText);
|
||||
}
|
||||
|
||||
private async Task CompleteDeleteResponseAsync(DiscordDeleteSessionResult result)
|
||||
{
|
||||
if (result.UpdatedView is not null)
|
||||
{
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(result.UpdatedView);
|
||||
await ModifyResponseAsync(options =>
|
||||
{
|
||||
options.Embeds = embeds;
|
||||
options.Components = actionRows;
|
||||
});
|
||||
}
|
||||
else if (result.EmptyMessage is not null)
|
||||
{
|
||||
await ModifyResponseAsync(options =>
|
||||
{
|
||||
options.Content = result.EmptyMessage;
|
||||
options.Embeds = [];
|
||||
options.Components = [];
|
||||
});
|
||||
}
|
||||
|
||||
await FollowupEphemeralAsync(result.ReplyText);
|
||||
}
|
||||
|
||||
private Task CompleteResponseAsync(string text) =>
|
||||
ModifyResponseAsync(options => options.Content = text);
|
||||
|
||||
private Task FollowupEphemeralAsync(string text) =>
|
||||
FollowupAsync(new InteractionMessageProperties()
|
||||
.WithContent(text)
|
||||
.WithFlags(MessageFlags.Ephemeral));
|
||||
|
||||
private bool SourceMessageHasDeleteAction() =>
|
||||
Context.Interaction.Message?.Components.Any(ComponentContainsDeleteAction) == true;
|
||||
|
||||
private static bool ComponentContainsDeleteAction(object? component)
|
||||
{
|
||||
if (component is null)
|
||||
return false;
|
||||
|
||||
if (component is IInteractiveComponent interactive
|
||||
&& interactive.CustomId.StartsWith("delete_session:", StringComparison.Ordinal))
|
||||
return true;
|
||||
|
||||
var nestedComponents = component.GetType().GetProperty("Components")?.GetValue(component) as IEnumerable;
|
||||
if (nestedComponents is null)
|
||||
return false;
|
||||
|
||||
foreach (var nestedComponent in nestedComponents)
|
||||
{
|
||||
if (ComponentContainsDeleteAction(nestedComponent))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
|
||||
InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
|
||||
@@ -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);
|
||||
@@ -98,17 +130,34 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
||||
CancellationToken ct)
|
||||
{
|
||||
var channelId = GetChannelId(request.Group);
|
||||
var message = await restClient.SendMessageAsync(
|
||||
channelId,
|
||||
new MessageProperties()
|
||||
.WithEmbeds([BuildConfirmationEmbed(request)])
|
||||
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
|
||||
try
|
||||
{
|
||||
var message = await restClient.SendMessageAsync(
|
||||
channelId,
|
||||
new MessageProperties()
|
||||
.WithEmbeds([BuildConfirmationEmbed(request)])
|
||||
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
request.Group.ExternalGroupId,
|
||||
null,
|
||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||
logger?.LogInformation(
|
||||
"Confirmation request sent to Discord channel {ChannelId}, message id {MessageId}",
|
||||
channelId,
|
||||
message.Id);
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
request.Group.ExternalGroupId,
|
||||
null,
|
||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(
|
||||
ex,
|
||||
"Failed to send confirmation request to Discord channel {ChannelId} for session {SessionId}",
|
||||
channelId,
|
||||
request.SessionId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
|
||||
@@ -135,15 +184,32 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
||||
CancellationToken ct)
|
||||
{
|
||||
var channelId = GetChannelId(notification.Group);
|
||||
var message = await restClient.SendMessageAsync(
|
||||
channelId,
|
||||
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
|
||||
try
|
||||
{
|
||||
var message = await restClient.SendMessageAsync(
|
||||
channelId,
|
||||
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
notification.Group.ExternalGroupId,
|
||||
null,
|
||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||
logger?.LogInformation(
|
||||
"Join link sent to Discord channel {ChannelId}, message id {MessageId}",
|
||||
channelId,
|
||||
message.Id);
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
notification.Group.ExternalGroupId,
|
||||
null,
|
||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(
|
||||
ex,
|
||||
"Failed to send join link to Discord channel {ChannelId} for session {SessionId}",
|
||||
channelId,
|
||||
notification.SessionId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendDirectSessionNotificationAsync(
|
||||
@@ -272,14 +338,16 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
||||
? "—"
|
||||
: string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User)));
|
||||
|
||||
return new EmbedProperties()
|
||||
var embed = new EmbedProperties()
|
||||
.WithTitle($"Ссылка на игру: {notification.Title}")
|
||||
.WithDescription(
|
||||
$"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" +
|
||||
$"Ссылка: {notification.JoinLink}\n\n" +
|
||||
$"Участники: {mentions}")
|
||||
.WithUrl(notification.JoinLink)
|
||||
.WithColor(new Color(0x57F287));
|
||||
|
||||
var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(notification.JoinLink);
|
||||
return embedUrl is null ? embed : embed.WithUrl(embedUrl);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ActionRowProperties> BuildRsvpRows(Guid sessionId, bool disabled)
|
||||
@@ -367,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);
|
||||
}
|
||||
|
||||
@@ -56,8 +56,10 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||
|
||||
builder.Services.AddSingleton<DiscordPermissionChecker>();
|
||||
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,43 @@
|
||||
namespace GmRelay.DiscordBot.Rendering;
|
||||
|
||||
public static class DiscordEmbedUrls
|
||||
{
|
||||
public static string? NormalizeHttpUrl(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
var candidate = value.Trim();
|
||||
if (IsSupportedHttpUrl(candidate, out var normalized))
|
||||
return normalized;
|
||||
|
||||
if (candidate.Contains("://", StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
return IsSupportedHttpUrl($"https://{candidate}", out normalized)
|
||||
&& HasPublicHost(normalized)
|
||||
? normalized
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool IsSupportedHttpUrl(string value, out string normalized)
|
||||
{
|
||||
normalized = string.Empty;
|
||||
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized = uri.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasPublicHost(string value) =>
|
||||
Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||
&& uri.Host.Contains('.', StringComparison.Ordinal);
|
||||
}
|
||||
@@ -70,9 +70,10 @@ public static class DiscordSessionBatchRenderer
|
||||
.WithInline()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(session.JoinLink))
|
||||
var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(session.JoinLink);
|
||||
if (embedUrl is not null)
|
||||
{
|
||||
embed = embed.WithUrl(session.JoinLink);
|
||||
embed = embed.WithUrl(embedUrl);
|
||||
}
|
||||
|
||||
embed = embed.WithColor(GetColor(session));
|
||||
|
||||
@@ -56,8 +56,8 @@ public sealed class HandleRsvpHandler(
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND COALESCE(p.platform, 'Telegram') = @Platform
|
||||
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @ExternalUserId
|
||||
AND p.platform = @Platform
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
)
|
||||
@@ -90,8 +90,8 @@ public sealed class HandleRsvpHandler(
|
||||
AND player_id = (
|
||||
SELECT id
|
||||
FROM players
|
||||
WHERE COALESCE(platform, 'Telegram') = @Platform
|
||||
AND COALESCE(external_user_id, telegram_id::TEXT) = @ExternalUserId
|
||||
WHERE platform = @Platform
|
||||
AND external_user_id = @ExternalUserId
|
||||
LIMIT 1
|
||||
)
|
||||
AND registration_status = @Active
|
||||
@@ -265,10 +265,10 @@ public sealed class HandleRsvpHandler(
|
||||
|
||||
var participants = (await connection.QueryAsync<ParticipantRsvpRow>(
|
||||
"""
|
||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
SELECT p.platform AS Platform,
|
||||
p.external_user_id AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
||||
p.external_username AS ExternalUsername,
|
||||
sp.rsvp_status AS RsvpStatus,
|
||||
sp.registration_status AS RegistrationStatus,
|
||||
sp.is_gm AS IsGm
|
||||
@@ -312,23 +312,13 @@ public sealed class HandleRsvpHandler(
|
||||
var rows = await connection.QueryAsync<RsvpRecipientRow>(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
p.platform AS Platform,
|
||||
p.external_user_id AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
|
||||
p.external_username AS ExternalUsername
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = @GroupId
|
||||
UNION
|
||||
SELECT DISTINCT
|
||||
COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
|
||||
FROM game_groups g
|
||||
JOIN players p ON p.telegram_id = g.gm_telegram_id
|
||||
WHERE g.id = @GroupId
|
||||
AND g.gm_telegram_id IS NOT NULL
|
||||
""",
|
||||
new { GroupId = groupId },
|
||||
transaction);
|
||||
|
||||
+6
-6
@@ -45,10 +45,10 @@ public sealed class SendConfirmationHandler(
|
||||
s.title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.group_id AS GroupId,
|
||||
COALESCE(g.platform, 'Telegram') AS Platform,
|
||||
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
|
||||
g.platform AS Platform,
|
||||
g.external_group_id AS ExternalGroupId,
|
||||
g.name AS DisplayName,
|
||||
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
|
||||
g.external_channel_id AS ExternalChannelId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM sessions s
|
||||
@@ -65,10 +65,10 @@ public sealed class SendConfirmationHandler(
|
||||
|
||||
var participants = (await connection.QueryAsync<ConfirmationParticipantRow>(
|
||||
"""
|
||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
SELECT p.platform AS Platform,
|
||||
p.external_user_id AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
||||
p.external_username AS ExternalUsername,
|
||||
sp.rsvp_status AS RsvpStatus,
|
||||
sp.registration_status AS RegistrationStatus,
|
||||
sp.is_gm AS IsGm
|
||||
|
||||
@@ -47,10 +47,10 @@ public sealed class SendJoinLinkHandler(
|
||||
s.title,
|
||||
s.join_link AS JoinLink,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
COALESCE(g.platform, 'Telegram') AS Platform,
|
||||
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
|
||||
g.platform AS Platform,
|
||||
g.external_group_id AS ExternalGroupId,
|
||||
g.name AS DisplayName,
|
||||
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
|
||||
g.external_channel_id AS ExternalChannelId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM sessions s
|
||||
@@ -58,14 +58,14 @@ public sealed class SendJoinLinkHandler(
|
||||
WHERE s.id = @SessionId
|
||||
AND s.status = @Confirmed
|
||||
AND (
|
||||
(COALESCE(g.platform, 'Telegram') = 'Telegram' AND s.link_message_id IS NULL)
|
||||
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
|
||||
OR (
|
||||
COALESCE(g.platform, 'Telegram') <> 'Telegram'
|
||||
g.platform <> 'Telegram'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM platform_messages pm
|
||||
WHERE pm.session_id = s.id
|
||||
AND pm.platform = COALESCE(g.platform, 'Telegram')
|
||||
AND pm.platform = g.platform
|
||||
AND pm.purpose = 'join_link'
|
||||
)
|
||||
)
|
||||
@@ -81,10 +81,10 @@ public sealed class SendJoinLinkHandler(
|
||||
|
||||
var players = (await connection.QueryAsync<JoinLinkPlayerRow>(
|
||||
"""
|
||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
SELECT p.platform AS Platform,
|
||||
p.external_user_id AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
||||
p.external_username AS ExternalUsername,
|
||||
sp.rsvp_status AS RsvpStatus,
|
||||
sp.registration_status AS RegistrationStatus,
|
||||
sp.is_gm AS IsGm
|
||||
|
||||
+3
-3
@@ -56,10 +56,10 @@ public sealed class SendOneHourReminderHandler(
|
||||
|
||||
var recipients = (await connection.QueryAsync<OneHourReminderRecipientRow>(
|
||||
"""
|
||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
SELECT p.platform AS Platform,
|
||||
p.external_user_id AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
|
||||
p.external_username AS ExternalUsername
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
|
||||
@@ -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);
|
||||
@@ -13,7 +13,12 @@ public sealed record JoinSessionCommand(
|
||||
PlatformUser User,
|
||||
string InteractionId,
|
||||
PlatformGroup Group,
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
PlatformMessageRef ScheduleMessage,
|
||||
bool DeferScheduleUpdate = false);
|
||||
|
||||
public sealed record SessionInteractionResult(
|
||||
string ReplyText,
|
||||
SessionBatchViewModel? UpdatedView = null);
|
||||
|
||||
// DTOs for AOT compilation
|
||||
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
|
||||
@@ -24,7 +29,7 @@ public sealed class JoinSessionHandler(
|
||||
IScheduleMessageUpdateLock scheduleUpdateLock,
|
||||
ILogger<JoinSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
|
||||
public async Task<SessionInteractionResult> HandleAsync(JoinSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
@@ -35,30 +40,19 @@ public sealed class JoinSessionHandler(
|
||||
{
|
||||
// 1. Убеждаемся, что игрок есть в базе
|
||||
var platform = command.User.Platform.ToString();
|
||||
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
|
||||
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
|
||||
: (long?)null;
|
||||
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
|
||||
? command.User.ExternalUsername
|
||||
: null;
|
||||
|
||||
var playerId = await connection.ExecuteScalarAsync<Guid>(
|
||||
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
||||
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
|
||||
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
|
||||
VALUES (@Name, @Platform, @ExternalUserId, @ExternalUsername)
|
||||
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,
|
||||
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
|
||||
platform = EXCLUDED.platform,
|
||||
external_user_id = EXCLUDED.external_user_id,
|
||||
external_username = EXCLUDED.external_username
|
||||
RETURNING id;",
|
||||
new
|
||||
{
|
||||
LegacyTelegramId = legacyTelegramId,
|
||||
Name = command.User.DisplayName,
|
||||
LegacyTelegramUsername = legacyTelegramUsername,
|
||||
Platform = platform,
|
||||
command.User.ExternalUserId,
|
||||
command.User.ExternalUsername
|
||||
@@ -77,15 +71,13 @@ public sealed class JoinSessionHandler(
|
||||
if (batchInfo is null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||
return;
|
||||
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||
}
|
||||
|
||||
if (SessionStatus.IsCancelled(batchInfo.Status))
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||
return;
|
||||
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||
}
|
||||
|
||||
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
|
||||
@@ -105,8 +97,7 @@ public sealed class JoinSessionHandler(
|
||||
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||
? "Вы уже в листе ожидания!"
|
||||
: "Вы уже записаны!";
|
||||
await AnswerAsync(command.InteractionId, alreadyText, ct);
|
||||
return;
|
||||
return await AnswerAsync(command.InteractionId, alreadyText, ct);
|
||||
}
|
||||
|
||||
var activeParticipants = await connection.ExecuteScalarAsync<int>(
|
||||
@@ -139,8 +130,7 @@ public sealed class JoinSessionHandler(
|
||||
if (inserted == 0)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
|
||||
return;
|
||||
return await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
|
||||
}
|
||||
|
||||
// Загружаем весь батч для перерисовки
|
||||
@@ -154,7 +144,7 @@ public sealed class JoinSessionHandler(
|
||||
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
|
||||
@"SELECT sp.session_id as SessionId,
|
||||
p.display_name as DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
|
||||
p.external_username as TelegramUsername,
|
||||
sp.registration_status as RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
@@ -168,17 +158,20 @@ public sealed class JoinSessionHandler(
|
||||
|
||||
// 4. Перерисовываем сообщение
|
||||
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
command.Group,
|
||||
view,
|
||||
command.ScheduleMessage),
|
||||
ct);
|
||||
if (!command.DeferScheduleUpdate)
|
||||
{
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
command.Group,
|
||||
view,
|
||||
command.ScheduleMessage),
|
||||
ct);
|
||||
}
|
||||
|
||||
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
||||
: "Вы успешно записаны!";
|
||||
await AnswerAsync(command.InteractionId, callbackText, ct);
|
||||
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -191,10 +184,17 @@ public sealed class JoinSessionHandler(
|
||||
var errorText = transactionCommitted
|
||||
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
|
||||
: "Произошла ошибка при регистрации.";
|
||||
await AnswerAsync(command.InteractionId, errorText, ct);
|
||||
return await AnswerAsync(command.InteractionId, errorText, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
|
||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||
private async Task<SessionInteractionResult> AnswerAsync(
|
||||
string interactionId,
|
||||
string text,
|
||||
CancellationToken ct,
|
||||
SessionBatchViewModel? updatedView = null)
|
||||
{
|
||||
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||
return new SessionInteractionResult(text, updatedView);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ public sealed record LeaveSessionCommand(
|
||||
PlatformUser User,
|
||||
string InteractionId,
|
||||
PlatformGroup Group,
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
PlatformMessageRef ScheduleMessage,
|
||||
bool DeferScheduleUpdate = false);
|
||||
|
||||
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
|
||||
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
|
||||
@@ -24,7 +25,7 @@ public sealed class LeaveSessionHandler(
|
||||
IScheduleMessageUpdateLock scheduleUpdateLock,
|
||||
ILogger<LeaveSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
|
||||
public async Task<SessionInteractionResult> HandleAsync(LeaveSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
@@ -49,15 +50,13 @@ public sealed class LeaveSessionHandler(
|
||||
if (session is null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||
return;
|
||||
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||
}
|
||||
|
||||
if (SessionStatus.IsCancelled(session.Status))
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||
return;
|
||||
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||
}
|
||||
|
||||
var platform = command.User.Platform.ToString();
|
||||
@@ -81,8 +80,7 @@ public sealed class LeaveSessionHandler(
|
||||
if (participant is null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
|
||||
return;
|
||||
return await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
@@ -175,7 +173,7 @@ public sealed class LeaveSessionHandler(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
|
||||
p.external_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
@@ -190,12 +188,15 @@ public sealed class LeaveSessionHandler(
|
||||
transactionCommitted = true;
|
||||
|
||||
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
command.Group,
|
||||
view,
|
||||
command.ScheduleMessage),
|
||||
ct);
|
||||
if (!command.DeferScheduleUpdate)
|
||||
{
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
command.Group,
|
||||
view,
|
||||
command.ScheduleMessage),
|
||||
ct);
|
||||
}
|
||||
|
||||
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||
? "Вы удалены из листа ожидания."
|
||||
@@ -203,7 +204,7 @@ public sealed class LeaveSessionHandler(
|
||||
? "Вы отписались от сессии."
|
||||
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
|
||||
|
||||
await AnswerAsync(command.InteractionId, callbackText, ct);
|
||||
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -216,10 +217,17 @@ public sealed class LeaveSessionHandler(
|
||||
var errorText = transactionCommitted
|
||||
? "Запись снята, но не удалось обновить сообщение расписания."
|
||||
: "Произошла ошибка при отмене записи.";
|
||||
await AnswerAsync(command.InteractionId, errorText, ct);
|
||||
return await AnswerAsync(command.InteractionId, errorText, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
|
||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||
private async Task<SessionInteractionResult> AnswerAsync(
|
||||
string interactionId,
|
||||
string text,
|
||||
CancellationToken ct,
|
||||
SessionBatchViewModel? updatedView = null)
|
||||
{
|
||||
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||
return new SessionInteractionResult(text, updatedView);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
+15
@@ -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);
|
||||
+181
@@ -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);
|
||||
}
|
||||
}
|
||||
+17
@@ -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);
|
||||
+156
@@ -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);
|
||||
@@ -78,8 +78,8 @@ public sealed class RescheduleVotingFinalizer(
|
||||
"""
|
||||
SELECT p.id AS PlayerId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
p.telegram_id AS TelegramId
|
||||
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
|
||||
@@ -107,7 +107,7 @@ public sealed class RescheduleVotingFinalizer(
|
||||
SELECT rov.option_id AS OptionId,
|
||||
p.id AS PlayerId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername
|
||||
p.external_username AS TelegramUsername
|
||||
FROM reschedule_option_votes rov
|
||||
JOIN players p ON p.id = rov.player_id
|
||||
WHERE rov.proposal_id = @ProposalId
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
|
||||
using GmRelay.Shared.Features.Reminders.SendJoinLink;
|
||||
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
||||
@@ -20,6 +21,11 @@ public sealed class SessionSchedulerService(
|
||||
ILogger<SessionSchedulerService> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan BackoffDuration = TimeSpan.FromMinutes(15);
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _confirmationBackoff = new();
|
||||
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _oneHourBackoff = new();
|
||||
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _joinLinkBackoff = new();
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
@@ -71,14 +77,30 @@ public sealed class SessionSchedulerService(
|
||||
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
if (_confirmationBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
|
||||
{
|
||||
logger.LogDebug(
|
||||
"Skipping confirmation for session {SessionId} until {Backoff}",
|
||||
sessionId,
|
||||
backoffUntil);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await confirmationHandler.HandleAsync(sessionId, ct);
|
||||
_confirmationBackoff.TryRemove(sessionId, out _);
|
||||
logger.LogInformation("Confirmation sent for session {SessionId}", sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to send confirmation for session {SessionId}", sessionId);
|
||||
var nextAttempt = now.Add(BackoffDuration);
|
||||
_confirmationBackoff[sessionId] = nextAttempt;
|
||||
logger.LogError(
|
||||
ex,
|
||||
"Failed to send confirmation for session {SessionId}, backing off until {Backoff}",
|
||||
sessionId,
|
||||
nextAttempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,14 +120,30 @@ public sealed class SessionSchedulerService(
|
||||
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
if (_oneHourBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
|
||||
{
|
||||
logger.LogDebug(
|
||||
"Skipping one-hour reminder for session {SessionId} until {Backoff}",
|
||||
sessionId,
|
||||
backoffUntil);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await oneHourReminderHandler.HandleAsync(sessionId, ct);
|
||||
_oneHourBackoff.TryRemove(sessionId, out _);
|
||||
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId);
|
||||
var nextAttempt = now.Add(BackoffDuration);
|
||||
_oneHourBackoff[sessionId] = nextAttempt;
|
||||
logger.LogError(
|
||||
ex,
|
||||
"Failed to process one-hour reminder for session {SessionId}, backing off until {Backoff}",
|
||||
sessionId,
|
||||
nextAttempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,14 +163,30 @@ public sealed class SessionSchedulerService(
|
||||
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
if (_joinLinkBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
|
||||
{
|
||||
logger.LogDebug(
|
||||
"Skipping join link for session {SessionId} until {Backoff}",
|
||||
sessionId,
|
||||
backoffUntil);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await joinLinkHandler.HandleAsync(sessionId, ct);
|
||||
_joinLinkBackoff.TryRemove(sessionId, out _);
|
||||
logger.LogInformation("Join link sent for session {SessionId}", sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to send join link for session {SessionId}", sessionId);
|
||||
var nextAttempt = now.Add(BackoffDuration);
|
||||
_joinLinkBackoff[sessionId] = nextAttempt;
|
||||
logger.LogError(
|
||||
ex,
|
||||
"Failed to send join link for session {SessionId}, backing off until {Backoff}",
|
||||
sessionId,
|
||||
nextAttempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.0.10</div>
|
||||
<div class="nav-version">v3.3.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="public-shell">
|
||||
<header class="public-topbar">
|
||||
<a class="public-brand" href="/">
|
||||
<img src="/logo.png" alt="GM-Relay" />
|
||||
<span>GM-Relay</span>
|
||||
</a>
|
||||
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
|
||||
</header>
|
||||
|
||||
<main class="public-content">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
Произошла непредвиденная ошибка.
|
||||
<a href="." class="reload">Перезагрузить</a>
|
||||
<span class="dismiss">×</span>
|
||||
</div>
|
||||
@@ -40,8 +40,8 @@
|
||||
</span>
|
||||
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
|
||||
{
|
||||
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.ExternalUserId)" @onclick="() => RemoveCoGm(manager.ExternalUserId ?? manager.TelegramId.ToString())">
|
||||
@(removingCoGmId == manager.ExternalUserId ? "⏳ Удаляем..." : "Убрать")
|
||||
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == ManagerKey(manager))" @onclick="() => RemoveCoGm(manager)">
|
||||
@(removingCoGmId == ManagerKey(manager) ? "⏳ Удаляем..." : "Убрать")
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,8 @@
|
||||
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
|
||||
<div class="batch-bulk-fields">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Telegram ID co-GM</label>
|
||||
<InputNumber @bind-Value="coGmModel.TelegramId" class="gm-form-control" min="1" />
|
||||
<label class="gm-form-label">@CoGmIdLabel</label>
|
||||
<InputText @bind-Value="coGmModel.ExternalUserId" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Имя</label>
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Username</label>
|
||||
<InputText @bind-Value="coGmModel.TelegramUsername" class="gm-form-control" />
|
||||
<InputText @bind-Value="coGmModel.ExternalUsername" class="gm-form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
|
||||
@@ -72,6 +72,58 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (publicSettings is not null)
|
||||
{
|
||||
<div class="glass-card animate-slide-up public-settings-panel" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Публичная страница клуба</h3>
|
||||
<p>@publicSettings.PublicSessionCount опубликованных игр без состава игроков и приватных ссылок</p>
|
||||
</div>
|
||||
<span class="status-badge @(publicSettings.PublicScheduleEnabled ? "status-success" : "status-neutral")">
|
||||
@(publicSettings.PublicScheduleEnabled ? "Включена" : "Выключена")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<EditForm Model="@publicSettingsModel" OnValidSubmit="SavePublicSettings">
|
||||
<div class="batch-bulk-fields">
|
||||
<div class="gm-form-group public-toggle-field">
|
||||
<label class="gm-checkbox-label">
|
||||
<InputCheckbox @bind-Value="publicSettingsModel.PublicScheduleEnabled" />
|
||||
<span>Включить публичное расписание</span>
|
||||
</label>
|
||||
<div class="gm-form-hint">Если выключено, публичная страница и ссылки на сессии недоступны.</div>
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Короткий адрес</label>
|
||||
<InputText @bind-Value="publicSettingsModel.PublicSlug" class="gm-form-control" />
|
||||
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-club`.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="public-settings-actions">
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@savingPublicSettings">
|
||||
@(savingPublicSettings ? "Сохраняем..." : "Сохранить публикацию")
|
||||
</button>
|
||||
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
|
||||
{
|
||||
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">
|
||||
Открыть публичную страницу
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
|
||||
{
|
||||
<div class="public-link-row">
|
||||
<span>Ссылка клуба</span>
|
||||
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
@@ -201,6 +253,17 @@
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
<div class="batch-publish-row">
|
||||
<span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
|
||||
@FormatBatchPublication(batch)
|
||||
</span>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@IsBatchPublishBusy(batch)" @onclick="() => SetBatchPublic(batch, !batch.AllSessionsPublic)">
|
||||
@(IsBatchPublishBusy(batch)
|
||||
? "Обновляем..."
|
||||
: batch.AllSessionsPublic ? "Скрыть batch" : "Опубликовать batch")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="batch-clone-row">
|
||||
<select @bind="batch.CloneInterval" class="gm-form-control">
|
||||
<option value="week">Следующая неделя</option>
|
||||
@@ -249,6 +312,16 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="session-table-actions">
|
||||
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
|
||||
@(publishingSessionId == session.Id
|
||||
? "Обновляем..."
|
||||
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
||||
</button>
|
||||
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
||||
{
|
||||
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a>
|
||||
}
|
||||
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline">
|
||||
✏️ Изменить
|
||||
</a>
|
||||
@@ -337,6 +410,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-card-actions">
|
||||
<button type="button" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
|
||||
@(publishingSessionId == session.Id
|
||||
? "Обновляем..."
|
||||
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
||||
</button>
|
||||
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
||||
{
|
||||
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">Публичная ссылка</a>
|
||||
}
|
||||
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
|
||||
✏️ Изменить
|
||||
</a>
|
||||
@@ -398,17 +480,23 @@
|
||||
private List<WebSession>? sessions;
|
||||
private List<WebCampaignTemplate>? campaignTemplates;
|
||||
private WebGroupManagement? groupManagement;
|
||||
private WebPublicGroupSettings? publicSettings;
|
||||
private List<BatchBulkEditModel> batchModels = [];
|
||||
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
||||
private Guid? promotingSessionId;
|
||||
private Guid? processingBatchId;
|
||||
private Guid? processingTemplateId;
|
||||
private Guid? publishingBatchId;
|
||||
private Guid? publishingSessionId;
|
||||
private string? removingCoGmId;
|
||||
private bool isAddingCoGm;
|
||||
private bool savingPublicSettings;
|
||||
private string? currentPlatform;
|
||||
private string? externalUserId;
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
private CoGmEditModel coGmModel = new();
|
||||
private PublicSettingsEditModel publicSettingsModel = new();
|
||||
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
|
||||
private HashSet<Guid> expandedSessions = new();
|
||||
private Guid? kickingParticipantId;
|
||||
@@ -423,6 +511,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
currentPlatform = platform;
|
||||
await LoadSessions();
|
||||
}
|
||||
|
||||
@@ -442,6 +531,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
publicSettings = await SessionService.GetPublicGroupSettingsForCurrentUserAsync(GroupId);
|
||||
if (publicSettings is null)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
return;
|
||||
}
|
||||
|
||||
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
|
||||
if (campaignTemplates is null)
|
||||
{
|
||||
@@ -451,6 +547,92 @@
|
||||
|
||||
RebuildBatchModels();
|
||||
RebuildCampaignTemplateModels();
|
||||
RebuildPublicSettingsModel();
|
||||
}
|
||||
|
||||
private async Task SavePublicSettings()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
savingPublicSettings = true;
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.UpdatePublicGroupSettingsForCurrentUserAsync(
|
||||
GroupId,
|
||||
publicSettingsModel.PublicSlug,
|
||||
publicSettingsModel.PublicScheduleEnabled);
|
||||
successMessage = "Настройки публичной страницы обновлены.";
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось обновить публичную страницу: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingPublicSettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetBatchPublic(BatchBulkEditModel batch, bool isPublic)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
publishingBatchId = batch.BatchId;
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.SetBatchPublicForCurrentUserAsync(batch.BatchId, isPublic);
|
||||
successMessage = isPublic
|
||||
? "Batch опубликован в публичном расписании."
|
||||
: "Batch скрыт из публичного расписания.";
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось обновить публичность batch: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
publishingBatchId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetSessionPublic(Guid sessionId, bool isPublic)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
publishingSessionId = sessionId;
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.SetSessionPublicForCurrentUserAsync(sessionId, isPublic);
|
||||
successMessage = isPublic
|
||||
? "Сессия опубликована в публичном расписании."
|
||||
: "Сессия скрыта из публичного расписания.";
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось обновить публичность сессии: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
publishingSessionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddCoGm()
|
||||
@@ -458,9 +640,16 @@
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
if (!coGmModel.TelegramId.HasValue || coGmModel.TelegramId.Value <= 0)
|
||||
var coGmExternalUserId = coGmModel.ExternalUserId.Trim();
|
||||
if (coGmExternalUserId.Length == 0)
|
||||
{
|
||||
errorMessage = "Telegram ID co-GM должен быть положительным числом.";
|
||||
errorMessage = $"{CoGmIdLabel} должен быть заполнен.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsValidPlatformUserId(CoGmPlatform, coGmExternalUserId))
|
||||
{
|
||||
errorMessage = $"{CoGmIdLabel} должен быть положительным числом.";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -470,10 +659,10 @@
|
||||
{
|
||||
await SessionService.AddCoGmForOwnerAsync(
|
||||
GroupId,
|
||||
"Telegram",
|
||||
coGmModel.TelegramId.Value.ToString(),
|
||||
CoGmPlatform,
|
||||
coGmExternalUserId,
|
||||
coGmModel.DisplayName,
|
||||
coGmModel.TelegramUsername);
|
||||
coGmModel.ExternalUsername);
|
||||
|
||||
coGmModel = new();
|
||||
successMessage = "Co-GM добавлен.";
|
||||
@@ -493,15 +682,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveCoGm(string coGmExternalUserId)
|
||||
private async Task RemoveCoGm(WebGroupManager manager)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
removingCoGmId = coGmExternalUserId;
|
||||
removingCoGmId = ManagerKey(manager);
|
||||
var platform = ManagerPlatform(manager);
|
||||
var coGmExternalUserId = ManagerExternalUserId(manager);
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.RemoveCoGmForOwnerAsync(GroupId, "Telegram", coGmExternalUserId);
|
||||
await SessionService.RemoveCoGmForOwnerAsync(GroupId, platform, coGmExternalUserId);
|
||||
successMessage = "Co-GM удалён.";
|
||||
await LoadSessions();
|
||||
}
|
||||
@@ -795,7 +986,9 @@
|
||||
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
|
||||
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
|
||||
IntervalDays = InferIntervalDays(orderedSessions),
|
||||
SessionCount = orderedSessions.Count
|
||||
SessionCount = orderedSessions.Count,
|
||||
PublicSessionCount = orderedSessions.Count(session => session.IsPublic),
|
||||
AllSessionsPublic = orderedSessions.All(session => session.IsPublic)
|
||||
};
|
||||
})
|
||||
.OrderBy(batch => batch.FirstScheduledAtLocal)
|
||||
@@ -823,6 +1016,20 @@
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
private void RebuildPublicSettingsModel()
|
||||
{
|
||||
if (publicSettings is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
publicSettingsModel = new PublicSettingsEditModel
|
||||
{
|
||||
PublicScheduleEnabled = publicSettings.PublicScheduleEnabled,
|
||||
PublicSlug = publicSettings.PublicSlug ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
|
||||
{
|
||||
batch.Title = batch.Title.Trim();
|
||||
@@ -832,24 +1039,76 @@
|
||||
|
||||
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
|
||||
|
||||
private bool IsBatchPublishBusy(BatchBulkEditModel batch) => publishingBatchId == batch.BatchId;
|
||||
|
||||
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
|
||||
|
||||
private string? PublicClubUrl =>
|
||||
string.IsNullOrWhiteSpace(publicSettings?.PublicSlug)
|
||||
? null
|
||||
: Navigation.ToAbsoluteUri($"/club/{publicSettings.PublicSlug}").ToString();
|
||||
|
||||
private string PublicSessionUrl(Guid sessionId) =>
|
||||
Navigation.ToAbsoluteUri($"/s/{sessionId}").ToString();
|
||||
|
||||
private static string FormatPublicationStatus(WebSession session) =>
|
||||
session.IsPublic ? "Опубликована" : "Скрыта";
|
||||
|
||||
private static string GetPublicationStatusClass(WebSession session) =>
|
||||
session.IsPublic ? "status-success" : "status-neutral";
|
||||
|
||||
private static string FormatBatchPublication(BatchBulkEditModel batch) =>
|
||||
batch.PublicSessionCount == 0
|
||||
? "Все игры скрыты"
|
||||
: batch.PublicSessionCount == batch.SessionCount
|
||||
? "Все игры опубликованы"
|
||||
: $"{batch.PublicSessionCount}/{batch.SessionCount} опубликовано";
|
||||
|
||||
private string CoGmPlatform =>
|
||||
string.IsNullOrWhiteSpace(groupManagement?.Group.Platform)
|
||||
? "Telegram"
|
||||
: groupManagement.Group.Platform;
|
||||
|
||||
private string CoGmIdLabel => $"{CoGmPlatform} ID co-GM";
|
||||
|
||||
private string CurrentUserRole =>
|
||||
groupManagement?.Managers.FirstOrDefault(manager => manager.ExternalUserId == externalUserId)?.Role
|
||||
groupManagement?.Managers.FirstOrDefault(manager =>
|
||||
string.Equals(ManagerPlatform(manager), currentPlatform, StringComparison.OrdinalIgnoreCase) &&
|
||||
ManagerExternalUserId(manager) == externalUserId)?.Role
|
||||
?? GroupManagerRoleExtensions.CoGmValue;
|
||||
|
||||
private static string FormatRole(string role) =>
|
||||
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
||||
|
||||
private static string FormatManager(WebGroupManager manager)
|
||||
private string FormatManager(WebGroupManager manager)
|
||||
{
|
||||
var username = string.IsNullOrWhiteSpace(manager.TelegramUsername)
|
||||
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
: "@" + manager.TelegramUsername;
|
||||
var username = string.IsNullOrWhiteSpace(manager.ExternalUsername)
|
||||
? manager.TelegramUsername
|
||||
: manager.ExternalUsername;
|
||||
var identity = string.IsNullOrWhiteSpace(username)
|
||||
? $"{ManagerPlatform(manager)} {ManagerExternalUserId(manager)}"
|
||||
: "@" + username.TrimStart('@');
|
||||
|
||||
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {username}";
|
||||
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {identity}";
|
||||
}
|
||||
|
||||
private string ManagerPlatform(WebGroupManager manager) =>
|
||||
string.IsNullOrWhiteSpace(manager.Platform) ? CoGmPlatform : manager.Platform;
|
||||
|
||||
private static string ManagerExternalUserId(WebGroupManager manager) =>
|
||||
string.IsNullOrWhiteSpace(manager.ExternalUserId)
|
||||
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
: manager.ExternalUserId;
|
||||
|
||||
private string ManagerKey(WebGroupManager manager) =>
|
||||
$"{ManagerPlatform(manager)}:{ManagerExternalUserId(manager)}";
|
||||
|
||||
private static bool IsValidPlatformUserId(string platform, string externalUserId) =>
|
||||
string.Equals(platform, "Telegram", StringComparison.OrdinalIgnoreCase)
|
||||
? long.TryParse(externalUserId, out var telegramId) && telegramId > 0
|
||||
: !string.Equals(platform, "Discord", StringComparison.OrdinalIgnoreCase) ||
|
||||
(ulong.TryParse(externalUserId, out var platformId) && platformId > 0);
|
||||
|
||||
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
|
||||
{
|
||||
if (orderedSessions.Count < 2)
|
||||
@@ -926,6 +1185,8 @@
|
||||
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
|
||||
public int IntervalDays { get; set; } = 7;
|
||||
public int SessionCount { get; init; }
|
||||
public int PublicSessionCount { get; init; }
|
||||
public bool AllSessionsPublic { get; init; }
|
||||
public string CloneInterval { get; set; } = "week";
|
||||
}
|
||||
|
||||
@@ -944,8 +1205,14 @@
|
||||
|
||||
private sealed class CoGmEditModel
|
||||
{
|
||||
public long? TelegramId { get; set; }
|
||||
public string ExternalUserId { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string? TelegramUsername { get; set; }
|
||||
public string? ExternalUsername { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PublicSettingsEditModel
|
||||
{
|
||||
public bool PublicScheduleEnabled { get; set; }
|
||||
public string? PublicSlug { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,9 +44,14 @@
|
||||
<div class="group-card-icon">🎮</div>
|
||||
<h3 class="group-card-title">@group.Name</h3>
|
||||
<p class="group-card-id">ID: @(group.Platform == "Discord" ? group.ExternalGroupId : group.TelegramChatId.ToString())</p>
|
||||
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;">
|
||||
@FormatRole(group.ManagerRole)
|
||||
</span>
|
||||
<div class="group-card-meta">
|
||||
<span class="status-badge platform-badge">
|
||||
@FormatPlatform(group.Platform)
|
||||
</span>
|
||||
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
|
||||
@FormatRole(group.ManagerRole)
|
||||
</span>
|
||||
</div>
|
||||
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
|
||||
Посмотреть игры →
|
||||
</a>
|
||||
@@ -81,6 +86,20 @@
|
||||
font-family: 'Courier New', monospace;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.group-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.platform-badge {
|
||||
background: rgba(88, 101, 242, 0.15);
|
||||
color: #9ea8ff;
|
||||
border-color: rgba(88, 101, 242, 0.35);
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
@@ -104,4 +123,7 @@
|
||||
|
||||
private static string FormatRole(string role) =>
|
||||
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
||||
|
||||
private static string FormatPlatform(string? platform) =>
|
||||
string.Equals(platform, "Discord", StringComparison.OrdinalIgnoreCase) ? "Discord" : "Telegram";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
@page "/club/{Slug}"
|
||||
@layout PublicLayout
|
||||
@inject ISessionStore SessionStore
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@if (loaded && club is null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</HeadContent>
|
||||
|
||||
<section class="public-hero public-hero-compact">
|
||||
<span class="status-badge status-neutral">Недоступно</span>
|
||||
<h1>Публичная страница не найдена</h1>
|
||||
<p>Расписание клуба выключено или адрес больше не используется.</p>
|
||||
</section>
|
||||
}
|
||||
else if (!loaded)
|
||||
{
|
||||
<section class="public-hero public-hero-compact">
|
||||
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 75%;"></div>
|
||||
</section>
|
||||
}
|
||||
else if (club is not null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="description" content="@($"Публичное расписание клуба {club.Name} в GM-Relay.")" />
|
||||
</HeadContent>
|
||||
|
||||
<section class="public-hero">
|
||||
<span class="status-badge status-success">Публичное расписание</span>
|
||||
<h1>@club.Name</h1>
|
||||
<p>Открытые игры клуба без состава игроков, личных данных и приватных ссылок.</p>
|
||||
<div class="public-share-row">
|
||||
<span>Ссылка клуба</span>
|
||||
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (club.Sessions.Count == 0)
|
||||
{
|
||||
<div class="glass-card public-empty-state">
|
||||
<h2>Опубликованных игр пока нет</h2>
|
||||
<p>Когда owner или co-GM откроет сессии для публичного расписания, они появятся здесь.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="public-session-list">
|
||||
@foreach (var session in club.Sessions)
|
||||
{
|
||||
<article class="public-session-card">
|
||||
<div class="public-session-main">
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
<h2>@session.Title</h2>
|
||||
<div class="public-session-meta">
|
||||
<span>@session.ScheduledAt.FormatMoscow()</span>
|
||||
<span>@FormatSeats(session)</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Slug { get; set; }
|
||||
|
||||
private WebPublicClub? club;
|
||||
private bool loaded;
|
||||
|
||||
private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay";
|
||||
|
||||
private string PublicClubUrl =>
|
||||
club is null
|
||||
? Navigation.ToAbsoluteUri($"/club/{Slug}").ToString()
|
||||
: Navigation.ToAbsoluteUri($"/club/{club.Slug}").ToString();
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loaded = false;
|
||||
club = string.IsNullOrWhiteSpace(Slug)
|
||||
? null
|
||||
: await SessionStore.GetPublicClubBySlugAsync(Slug.Trim());
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private string PublicSessionPath(Guid sessionId) => $"/s/{sessionId}";
|
||||
|
||||
private static string FormatSeats(WebPublicSession session)
|
||||
{
|
||||
var seats = session.MaxPlayers.HasValue
|
||||
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
||||
: $"{session.ActivePlayerCount} игроков";
|
||||
|
||||
return session.WaitlistedPlayerCount > 0
|
||||
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
|
||||
: seats;
|
||||
}
|
||||
|
||||
private static string GetStatusClass(string status) => status switch
|
||||
{
|
||||
SessionStatus.Confirmed => "status-success",
|
||||
SessionStatus.ConfirmationSent => "status-warning",
|
||||
SessionStatus.Planned => "status-info",
|
||||
_ => "status-neutral"
|
||||
};
|
||||
|
||||
private static string TranslateStatus(string status) => status switch
|
||||
{
|
||||
SessionStatus.Planned => "Запланировано",
|
||||
SessionStatus.ConfirmationSent => "Ждем подтверждения",
|
||||
SessionStatus.Confirmed => "Подтверждено",
|
||||
_ => status
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
@page "/s/{SessionId:guid}"
|
||||
@layout PublicLayout
|
||||
@inject ISessionStore SessionStore
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@if (loaded && session is null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</HeadContent>
|
||||
|
||||
<section class="public-hero public-hero-compact">
|
||||
<span class="status-badge status-neutral">Недоступно</span>
|
||||
<h1>Сессия не опубликована</h1>
|
||||
<p>Эта игра скрыта, отменена, уже прошла или клуб выключил публичное расписание.</p>
|
||||
</section>
|
||||
}
|
||||
else if (!loaded)
|
||||
{
|
||||
<section class="public-hero public-hero-compact">
|
||||
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 75%;"></div>
|
||||
</section>
|
||||
}
|
||||
else if (session is not null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="description" content="@($"Публичная сессия {session.Title} клуба {session.GroupName} в GM-Relay.")" />
|
||||
</HeadContent>
|
||||
|
||||
<section class="public-hero public-hero-compact">
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
<h1>@session.Title</h1>
|
||||
<p>@session.GroupName</p>
|
||||
</section>
|
||||
|
||||
<article class="glass-card public-session-detail">
|
||||
<div class="public-detail-grid">
|
||||
<div>
|
||||
<span>Время</span>
|
||||
<strong>@session.ScheduledAt.FormatMoscow()</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Места</span>
|
||||
<strong>@FormatSeats(session)</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Статус</span>
|
||||
<strong>@TranslateStatus(session.Status)</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="public-settings-actions">
|
||||
@if (!string.IsNullOrWhiteSpace(session.GroupSlug))
|
||||
{
|
||||
<a class="btn-gm btn-gm-primary" href="@($"/club/{session.GroupSlug}")">Расписание клуба</a>
|
||||
}
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionUrl" target="_blank" rel="noopener noreferrer">Ссылка на сессию</a>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid SessionId { get; set; }
|
||||
|
||||
private WebPublicSession? session;
|
||||
private bool loaded;
|
||||
|
||||
private string PageTitleText => session is null ? "Публичная сессия — GM-Relay" : $"{session.Title} — GM-Relay";
|
||||
|
||||
private string PublicSessionUrl => Navigation.ToAbsoluteUri($"/s/{SessionId}").ToString();
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loaded = false;
|
||||
session = await SessionStore.GetPublicSessionAsync(SessionId);
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private static string FormatSeats(WebPublicSession session)
|
||||
{
|
||||
var seats = session.MaxPlayers.HasValue
|
||||
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
||||
: $"{session.ActivePlayerCount} игроков";
|
||||
|
||||
return session.WaitlistedPlayerCount > 0
|
||||
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
|
||||
: seats;
|
||||
}
|
||||
|
||||
private static string GetStatusClass(string status) => status switch
|
||||
{
|
||||
SessionStatus.Confirmed => "status-success",
|
||||
SessionStatus.ConfirmationSent => "status-warning",
|
||||
SessionStatus.Planned => "status-info",
|
||||
_ => "status-neutral"
|
||||
};
|
||||
|
||||
private static string TranslateStatus(string status) => status switch
|
||||
{
|
||||
SessionStatus.Planned => "Запланировано",
|
||||
SessionStatus.ConfirmationSent => "Ждем подтверждения",
|
||||
SessionStatus.Confirmed => "Подтверждено",
|
||||
_ => status
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.RegularExpressions;
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
@@ -54,6 +55,71 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
return await sessionStore.GetUpcomingSessionsAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task<WebPublicGroupSettings?> GetPublicGroupSettingsForCurrentUserAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
return null;
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
return null;
|
||||
|
||||
return await sessionStore.GetPublicGroupSettingsAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task UpdatePublicGroupSettingsForCurrentUserAsync(
|
||||
Guid groupId,
|
||||
string? publicSlug,
|
||||
bool publicScheduleEnabled)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
var normalizedSlug = NormalizePublicSlug(publicSlug);
|
||||
if (publicScheduleEnabled && normalizedSlug is null)
|
||||
{
|
||||
throw new InvalidOperationException("Для публичной страницы нужен короткий адрес.");
|
||||
}
|
||||
|
||||
await sessionStore.UpdatePublicGroupSettingsAsync(groupId, normalizedSlug, publicScheduleEnabled);
|
||||
}
|
||||
|
||||
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var session = await GetSessionForCurrentUserAsync(sessionId);
|
||||
if (session is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
await sessionStore.SetSessionPublicAsync(sessionId, session.GroupId, isPublic);
|
||||
}
|
||||
|
||||
public async Task SetBatchPublicForCurrentUserAsync(Guid batchId, bool isPublic)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var batch = await GetBatchForCurrentUserAsync(batchId);
|
||||
if (batch is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
await sessionStore.SetBatchPublicAsync(batchId, batch.GroupId, isPublic);
|
||||
}
|
||||
|
||||
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
@@ -390,4 +456,20 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
JoinLink = joinLink
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizePublicSlug(string? publicSlug)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(publicSlug))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var slug = Regex.Replace(publicSlug.Trim().ToLowerInvariant(), @"[\s_]+", "-").Trim('-');
|
||||
if (slug.Length is < 3 or > 80 || !Regex.IsMatch(slug, "^[a-z0-9]+(?:-[a-z0-9]+)*$"))
|
||||
{
|
||||
throw new InvalidOperationException("Короткий адрес может содержать только латинские буквы, цифры и дефисы.");
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
|
||||
public string GenerateToken() => Guid.NewGuid().ToString("N");
|
||||
|
||||
public async Task<string> CreateSubscriptionAsync(
|
||||
long userTelegramId,
|
||||
string userPlatform,
|
||||
string userExternalId,
|
||||
Guid? groupId,
|
||||
CalendarSubscriptionFilter filter,
|
||||
CancellationToken ct = default)
|
||||
@@ -20,9 +21,9 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
|
||||
var token = GenerateToken();
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(
|
||||
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
|
||||
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
|
||||
new { token, userTelegramId, groupId, filterType = (int)filter });
|
||||
@"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, userExternalId, groupId, filterType = (int)filter });
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -31,7 +32,7 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var subscription = await connection.QueryFirstOrDefaultAsync<SubscriptionRecord>(
|
||||
@"SELECT id, user_telegram_id as UserTelegramId, group_id as GroupId, filter_type as FilterType
|
||||
@"SELECT id, group_id as GroupId, filter_type as FilterType
|
||||
FROM calendar_subscriptions
|
||||
WHERE token = @token
|
||||
AND (expires_at IS NULL OR expires_at > now())",
|
||||
@@ -88,6 +89,6 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
|
||||
.Replace("\n", "\\n")
|
||||
.Replace("\r", "");
|
||||
|
||||
private sealed record SubscriptionRecord(Guid Id, long UserTelegramId, Guid? GroupId, int FilterType);
|
||||
private sealed record SubscriptionRecord(Guid Id, Guid? GroupId, int FilterType);
|
||||
private sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
||||
}
|
||||
|
||||
@@ -24,10 +24,41 @@ public sealed record SessionAuditLogEntry(
|
||||
string? NewValue,
|
||||
DateTime ChangedAt);
|
||||
|
||||
public sealed record WebPublicGroupSettings(
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? PublicSlug,
|
||||
bool PublicScheduleEnabled,
|
||||
int PublicSessionCount);
|
||||
|
||||
public sealed record WebPublicSession(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? GroupSlug,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount);
|
||||
|
||||
public sealed record WebPublicClub(
|
||||
Guid GroupId,
|
||||
string Name,
|
||||
string Slug,
|
||||
IReadOnlyList<WebPublicSession> Sessions);
|
||||
|
||||
public interface ISessionStore
|
||||
{
|
||||
Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId);
|
||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||
Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId);
|
||||
Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled);
|
||||
Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic);
|
||||
Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic);
|
||||
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug);
|
||||
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId);
|
||||
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
||||
|
||||
@@ -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;
|
||||
@@ -27,6 +28,7 @@ public sealed record WebGameGroup(
|
||||
public sealed record WebGroupManager(
|
||||
long TelegramId,
|
||||
string? ExternalUserId,
|
||||
string? Platform,
|
||||
string DisplayName,
|
||||
string? TelegramUsername,
|
||||
string? ExternalUsername,
|
||||
@@ -34,7 +36,17 @@ public sealed record WebGroupManager(
|
||||
DateTime AddedAt)
|
||||
{
|
||||
public WebGroupManager(long telegramId, string displayName, string? telegramUsername, string role, DateTime addedAt)
|
||||
: this(telegramId, null, displayName, telegramUsername, null, role, addedAt) { }
|
||||
: this(telegramId, null, null, displayName, telegramUsername, null, role, addedAt) { }
|
||||
|
||||
public WebGroupManager(
|
||||
long telegramId,
|
||||
string? externalUserId,
|
||||
string displayName,
|
||||
string? telegramUsername,
|
||||
string? externalUsername,
|
||||
string role,
|
||||
DateTime addedAt)
|
||||
: this(telegramId, externalUserId, null, displayName, telegramUsername, externalUsername, role, addedAt) { }
|
||||
}
|
||||
|
||||
public sealed record WebGroupManagement(
|
||||
@@ -55,7 +67,8 @@ public sealed record WebSession(
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount,
|
||||
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
|
||||
int? ThreadId = null);
|
||||
int? ThreadId = null,
|
||||
bool IsPublic = false);
|
||||
|
||||
public sealed record WebParticipant(
|
||||
Guid Id,
|
||||
@@ -95,6 +108,8 @@ internal sealed record WebBatchSessionRow(
|
||||
string NotificationMode,
|
||||
bool TopicCreatedByBot = false);
|
||||
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
||||
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
||||
internal sealed record WebPublicGroupRow(Guid GroupId, string Name, string Slug);
|
||||
|
||||
public sealed class SessionService(
|
||||
NpgsqlDataSource dataSource,
|
||||
@@ -104,24 +119,45 @@ public sealed class SessionService(
|
||||
public async Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||||
if (effectiveId is null)
|
||||
var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
|
||||
if (playerIds.Length == 0)
|
||||
return [];
|
||||
|
||||
return (await conn.QueryAsync<WebGameGroup>(
|
||||
"""
|
||||
WITH visible_groups AS (
|
||||
SELECT gm.group_id,
|
||||
CASE
|
||||
WHEN bool_or(gm.role = @OwnerRole) THEN @OwnerRole
|
||||
ELSE @CoGmRole
|
||||
END AS ManagerRole
|
||||
FROM group_managers gm
|
||||
WHERE gm.player_id = ANY(@PlayerIds)
|
||||
GROUP BY gm.group_id
|
||||
)
|
||||
SELECT g.id,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.external_group_id::BIGINT AS TelegramChatId,
|
||||
g.external_group_id AS ExternalGroupId,
|
||||
g.name,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
|
||||
g.platform AS Platform,
|
||||
gm.role AS ManagerRole
|
||||
FROM group_managers gm
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE gm.player_id = @PlayerId
|
||||
vg.ManagerRole
|
||||
FROM visible_groups vg
|
||||
JOIN game_groups g ON g.id = vg.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT s.title
|
||||
FROM sessions s
|
||||
WHERE s.group_id = g.id
|
||||
ORDER BY s.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
ORDER BY g.name
|
||||
""",
|
||||
new { PlayerId = effectiveId.Value })).ToList();
|
||||
new
|
||||
{
|
||||
PlayerIds = playerIds,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||||
CoGmRole = GroupManagerRoleExtensions.CoGmValue
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
|
||||
@@ -130,22 +166,207 @@ public sealed class SessionService(
|
||||
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
|
||||
"""
|
||||
SELECT g.id,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.external_group_id::BIGINT AS TelegramChatId,
|
||||
g.external_group_id AS ExternalGroupId,
|
||||
g.name,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
|
||||
g.platform AS Platform,
|
||||
@OwnerRole AS ManagerRole
|
||||
FROM game_groups g
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT s.title
|
||||
FROM sessions s
|
||||
WHERE s.group_id = g.id
|
||||
ORDER BY s.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
WHERE g.id = @GroupId
|
||||
""",
|
||||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||||
}
|
||||
|
||||
public async Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<WebPublicGroupSettings>(
|
||||
"""
|
||||
SELECT g.id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
|
||||
g.public_slug AS PublicSlug,
|
||||
g.public_schedule_enabled AS PublicScheduleEnabled,
|
||||
COALESCE(public_counts.count, 0)::int AS PublicSessionCount
|
||||
FROM game_groups g
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT s.title
|
||||
FROM sessions s
|
||||
WHERE s.group_id = g.id
|
||||
ORDER BY s.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM sessions s
|
||||
WHERE s.group_id = g.id
|
||||
AND s.is_public = true
|
||||
) public_counts ON true
|
||||
WHERE g.id = @GroupId
|
||||
""",
|
||||
new { GroupId = groupId });
|
||||
}
|
||||
|
||||
public async Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
try
|
||||
{
|
||||
await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE game_groups
|
||||
SET public_slug = @PublicSlug,
|
||||
public_schedule_enabled = @PublicScheduleEnabled,
|
||||
public_schedule_updated_at = now()
|
||||
WHERE id = @GroupId
|
||||
""",
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
PublicSlug = string.IsNullOrWhiteSpace(publicSlug) ? null : publicSlug,
|
||||
PublicScheduleEnabled = publicScheduleEnabled
|
||||
});
|
||||
}
|
||||
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
throw new InvalidOperationException("Public slug is already in use.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var updatedRows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET is_public = @IsPublic,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
AND group_id = @GroupId
|
||||
""",
|
||||
new { SessionId = sessionId, GroupId = groupId, IsPublic = isPublic });
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
throw new SessionAccessDeniedException(sessionId, "0");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var updatedRows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET is_public = @IsPublic,
|
||||
updated_at = now()
|
||||
WHERE batch_id = @BatchId
|
||||
AND group_id = @GroupId
|
||||
""",
|
||||
new { BatchId = batchId, GroupId = groupId, IsPublic = isPublic });
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
throw new SessionAccessDeniedException(batchId, "0");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>(
|
||||
"""
|
||||
SELECT g.id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
|
||||
g.public_slug AS Slug
|
||||
FROM game_groups g
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT s.title
|
||||
FROM sessions s
|
||||
WHERE s.group_id = g.id
|
||||
ORDER BY s.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
WHERE g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND lower(g.public_slug) = lower(@Slug)
|
||||
""",
|
||||
new { Slug = slug });
|
||||
|
||||
if (group is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId);
|
||||
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions);
|
||||
}
|
||||
|
||||
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<WebPublicSession>(
|
||||
"""
|
||||
SELECT s.id AS Id,
|
||||
s.group_id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
|
||||
g.public_slug AS GroupSlug,
|
||||
s.title AS Title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.status AS Status,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT recent.title
|
||||
FROM sessions recent
|
||||
WHERE recent.group_id = g.id
|
||||
ORDER BY recent.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
) active_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
WHERE s.id = @SessionId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||||
if (effectiveId is null)
|
||||
var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
|
||||
if (playerIds.Length == 0)
|
||||
return false;
|
||||
|
||||
return await conn.ExecuteScalarAsync<bool>(
|
||||
@@ -154,17 +375,17 @@ public sealed class SessionService(
|
||||
SELECT 1
|
||||
FROM group_managers
|
||||
WHERE group_id = @GroupId
|
||||
AND player_id = @PlayerId
|
||||
AND player_id = ANY(@PlayerIds)
|
||||
)
|
||||
""",
|
||||
new { GroupId = groupId, PlayerId = effectiveId.Value });
|
||||
new { GroupId = groupId, PlayerIds = playerIds });
|
||||
}
|
||||
|
||||
public async Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||||
if (effectiveId is null)
|
||||
var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
|
||||
if (playerIds.Length == 0)
|
||||
return false;
|
||||
|
||||
return await conn.ExecuteScalarAsync<bool>(
|
||||
@@ -173,11 +394,11 @@ public sealed class SessionService(
|
||||
SELECT 1
|
||||
FROM group_managers
|
||||
WHERE group_id = @GroupId
|
||||
AND player_id = @PlayerId
|
||||
AND player_id = ANY(@PlayerIds)
|
||||
AND role = @OwnerRole
|
||||
)
|
||||
""",
|
||||
new { GroupId = groupId, PlayerId = effectiveId.Value, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||||
new { GroupId = groupId, PlayerIds = playerIds, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||||
}
|
||||
|
||||
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
|
||||
@@ -185,11 +406,16 @@ public sealed class SessionService(
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebGroupManager>(
|
||||
"""
|
||||
SELECT COALESCE(p.telegram_id, 0) AS TelegramId,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
SELECT CASE
|
||||
WHEN p.platform = 'Telegram' AND p.external_user_id ~ '^[0-9]+$'
|
||||
THEN p.external_user_id::BIGINT
|
||||
ELSE 0
|
||||
END AS TelegramId,
|
||||
p.external_user_id AS ExternalUserId,
|
||||
p.platform AS Platform,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
||||
p.external_username AS TelegramUsername,
|
||||
p.external_username AS ExternalUsername,
|
||||
gm.role AS Role,
|
||||
gm.created_at AS AddedAt
|
||||
FROM group_managers gm
|
||||
@@ -210,7 +436,7 @@ public sealed class SessionService(
|
||||
SELECT
|
||||
p.id AS PlayerId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
||||
p.external_username AS ExternalUsername,
|
||||
COUNT(DISTINCT s.id) AS TotalSessions,
|
||||
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount,
|
||||
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount,
|
||||
@@ -229,7 +455,7 @@ public sealed class SessionService(
|
||||
WHERE s.group_id = @GroupId
|
||||
AND s.scheduled_at <= now()
|
||||
AND sp.is_gm = false
|
||||
GROUP BY p.id, p.display_name, p.external_username, p.telegram_username
|
||||
GROUP BY p.id, p.display_name, p.external_username
|
||||
ORDER BY AttendanceRate DESC, ConfirmedCount DESC
|
||||
""",
|
||||
new { GroupId = groupId })).ToList();
|
||||
@@ -328,12 +554,13 @@ public sealed class SessionService(
|
||||
return (await conn.QueryAsync<WebSession>(
|
||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.external_group_id::BIGINT AS TelegramChatId,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -366,12 +593,13 @@ public sealed class SessionService(
|
||||
return await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.external_group_id::BIGINT AS TelegramChatId,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -425,12 +653,13 @@ public sealed class SessionService(
|
||||
var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.external_group_id::BIGINT AS TelegramChatId,
|
||||
s.max_players AS MaxPlayers,
|
||||
0 AS ActivePlayerCount,
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||||
@@ -511,12 +740,13 @@ public sealed class SessionService(
|
||||
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.external_group_id::BIGINT AS TelegramChatId,
|
||||
s.max_players AS MaxPlayers,
|
||||
0 AS ActivePlayerCount,
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||
@@ -610,11 +840,11 @@ public sealed class SessionService(
|
||||
return (await conn.QueryAsync<WebParticipant>(
|
||||
"""
|
||||
SELECT sp.id AS Id,
|
||||
COALESCE(p.telegram_id, 0) AS TelegramId,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
COALESCE(p.external_user_id::BIGINT, 0) AS TelegramId,
|
||||
p.external_user_id AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
||||
p.external_username AS TelegramUsername,
|
||||
p.external_username AS ExternalUsername,
|
||||
sp.rsvp_status AS RsvpStatus,
|
||||
sp.registration_status AS RegistrationStatus,
|
||||
sp.is_gm AS IsGm,
|
||||
@@ -637,12 +867,13 @@ public sealed class SessionService(
|
||||
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.external_group_id::BIGINT AS TelegramChatId,
|
||||
s.max_players AS MaxPlayers,
|
||||
0 AS ActivePlayerCount,
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||
@@ -658,9 +889,9 @@ public sealed class SessionService(
|
||||
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
|
||||
"""
|
||||
SELECT sp.id AS Id,
|
||||
p.telegram_id AS TelegramId,
|
||||
p.external_user_id::BIGINT AS TelegramId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
p.external_username AS TelegramUsername,
|
||||
sp.rsvp_status AS RsvpStatus,
|
||||
sp.registration_status AS RegistrationStatus,
|
||||
sp.is_gm AS IsGm,
|
||||
@@ -843,7 +1074,7 @@ public sealed class SessionService(
|
||||
s.status AS Status,
|
||||
s.max_players AS MaxPlayers,
|
||||
s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.external_group_id::BIGINT AS TelegramChatId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||
s.notification_mode AS NotificationMode
|
||||
@@ -927,7 +1158,7 @@ public sealed class SessionService(
|
||||
s.status AS Status,
|
||||
s.max_players AS MaxPlayers,
|
||||
s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.external_group_id::BIGINT AS TelegramChatId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||
s.notification_mode AS NotificationMode
|
||||
@@ -1149,7 +1380,7 @@ public sealed class SessionService(
|
||||
}
|
||||
|
||||
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
|
||||
"SELECT telegram_chat_id AS TelegramChatId FROM game_groups WHERE id = @GroupId",
|
||||
"SELECT external_group_id::BIGINT AS TelegramChatId FROM game_groups WHERE id = @GroupId",
|
||||
new { GroupId = groupId },
|
||||
transaction);
|
||||
|
||||
@@ -1158,6 +1389,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,
|
||||
@@ -1169,8 +1404,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
|
||||
@@ -1181,6 +1416,8 @@ public sealed class SessionService(
|
||||
template.JoinLink,
|
||||
ScheduledAt = scheduledAt,
|
||||
Status = SessionStatus.Planned,
|
||||
ThreadId = messageThreadId,
|
||||
TopicCreatedByBot = topicCreatedByBot,
|
||||
template.MaxPlayers,
|
||||
template.NotificationMode
|
||||
},
|
||||
@@ -1195,6 +1432,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);
|
||||
@@ -1214,13 +1452,41 @@ 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)
|
||||
{
|
||||
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
SELECT p.external_user_id::BIGINT AS TelegramId,
|
||||
p.display_name AS DisplayName
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
@@ -1237,7 +1503,7 @@ public sealed class SessionService(
|
||||
{
|
||||
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
|
||||
"""
|
||||
SELECT DISTINCT p.telegram_id AS TelegramId,
|
||||
SELECT DISTINCT p.external_user_id::BIGINT AS TelegramId,
|
||||
p.display_name AS DisplayName
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
@@ -1290,7 +1556,7 @@ public sealed class SessionService(
|
||||
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
|
||||
@"SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
p.external_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
@@ -1315,6 +1581,62 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
|
||||
NpgsqlConnection conn,
|
||||
Guid groupId)
|
||||
{
|
||||
return (await conn.QueryAsync<WebPublicSession>(
|
||||
"""
|
||||
SELECT s.id AS Id,
|
||||
s.group_id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
|
||||
g.public_slug AS GroupSlug,
|
||||
s.title AS Title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.status AS Status,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT recent.title
|
||||
FROM sessions recent
|
||||
WHERE recent.group_id = g.id
|
||||
ORDER BY recent.scheduled_at DESC
|
||||
LIMIT 1
|
||||
) latest_session ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
) active_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
WHERE s.group_id = @GroupId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
ORDER BY s.scheduled_at
|
||||
""",
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
private static async Task<WebBatchInfo?> GetBatchInfoAsync(
|
||||
Npgsql.NpgsqlConnection conn,
|
||||
Guid batchId,
|
||||
@@ -1327,7 +1649,7 @@ public sealed class SessionService(
|
||||
s.group_id AS GroupId,
|
||||
(array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title,
|
||||
(array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
g.external_group_id::BIGINT AS TelegramChatId,
|
||||
(array_agg(s.batch_message_id ORDER BY s.scheduled_at))[1] AS BatchMessageId,
|
||||
(array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId,
|
||||
(array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
|
||||
@@ -1335,7 +1657,7 @@ public sealed class SessionService(
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.batch_id = @BatchId
|
||||
AND s.group_id = @GroupId
|
||||
GROUP BY s.batch_id, s.group_id, g.telegram_chat_id
|
||||
GROUP BY s.batch_id, s.group_id, g.external_group_id
|
||||
""",
|
||||
new { BatchId = batchId, GroupId = groupId },
|
||||
transaction);
|
||||
@@ -1561,6 +1883,23 @@ public sealed class SessionService(
|
||||
return primaryId ?? playerId;
|
||||
}
|
||||
|
||||
private static async Task<Guid[]> _ResolveLinkedPlayerIdsAsync(NpgsqlConnection conn, string platform, string externalUserId)
|
||||
{
|
||||
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||||
if (effectiveId is null)
|
||||
return [];
|
||||
|
||||
return (await conn.QueryAsync<Guid>(
|
||||
"""
|
||||
SELECT @EffectiveId
|
||||
UNION
|
||||
SELECT secondary_player_id
|
||||
FROM player_links
|
||||
WHERE primary_player_id = @EffectiveId
|
||||
""",
|
||||
new { EffectiveId = effectiveId.Value })).ToArray();
|
||||
}
|
||||
|
||||
private static async Task<Guid> _UpsertPlayerAndGetIdAsync(
|
||||
NpgsqlConnection conn, string platform, string externalUserId,
|
||||
string displayName, string? avatarUrl, NpgsqlTransaction? transaction)
|
||||
|
||||
@@ -785,6 +785,62 @@ select option {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.batch-publish-row,
|
||||
.public-settings-actions,
|
||||
.public-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.batch-publish-row {
|
||||
justify-content: space-between;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.public-settings-panel {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.public-toggle-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gm-checkbox-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gm-checkbox-label input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.public-settings-actions {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.public-link-row {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.public-link-row a {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* === Campaign templates === */
|
||||
.campaign-template-panel {
|
||||
margin-bottom: 1.5rem;
|
||||
@@ -1620,6 +1676,151 @@ body.telegram-mini-app .session-card-mobile {
|
||||
}
|
||||
}
|
||||
|
||||
/* === Public pages === */
|
||||
.public-shell {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.public-topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: rgba(5, 8, 16, 0.82);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.public-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.public-brand img {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.public-content {
|
||||
width: min(960px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
.public-hero {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 2rem 0 1rem;
|
||||
}
|
||||
|
||||
.public-hero-compact {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.public-hero h1 {
|
||||
font-size: 2rem;
|
||||
margin: 0.75rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.public-hero p {
|
||||
max-width: 640px;
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.public-session-list {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.public-session-card,
|
||||
.public-session-detail {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.public-session-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.public-session-main h2 {
|
||||
font-size: 1.125rem;
|
||||
margin: 0.625rem 0 0.375rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.public-session-meta,
|
||||
.public-detail-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.public-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.public-detail-grid div {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.public-detail-grid span {
|
||||
display: block;
|
||||
margin-bottom: 0.375rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.public-detail-grid strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.public-empty-state h2 {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.public-empty-state p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.public-topbar {
|
||||
padding: 0.875rem 1rem;
|
||||
}
|
||||
|
||||
.public-session-card,
|
||||
.public-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.public-session-card .btn-gm {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Discord Login Button === */
|
||||
.login-btn-discord {
|
||||
display: flex;
|
||||
|
||||
@@ -33,7 +33,17 @@ public sealed class DiscordListSessionsHandlerTests
|
||||
|
||||
Assert.Contains("platform = 'Discord'", handler, StringComparison.Ordinal);
|
||||
Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal);
|
||||
Assert.Contains("scheduled_at > NOW()", handler, StringComparison.Ordinal);
|
||||
Assert.Contains("scheduled_at > now() - interval '4 hours'", handler, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldIncludeRecentlyStartedSessionsForCleanup()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs");
|
||||
var handler = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.Contains("now() - interval '4 hours'", handler, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -47,6 +57,17 @@ public sealed class DiscordListSessionsHandlerTests
|
||||
Assert.DoesNotContain("telegram_id", handler, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldExposeDeleteActionForManagers()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs");
|
||||
var handler = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.Contains("delete_session", handler, StringComparison.Ordinal);
|
||||
Assert.Contains("CanManageSchedule", handler, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Command_ShouldExist()
|
||||
{
|
||||
@@ -66,4 +87,18 @@ public sealed class DiscordListSessionsHandlerTests
|
||||
Assert.Contains("SlashCommand", command, StringComparison.Ordinal);
|
||||
Assert.Contains("listsessions", command, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteHandler_ShouldDeleteOnlySessionsFromTheInteractionGuild()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordDeleteSessionHandler.cs");
|
||||
|
||||
Assert.True(File.Exists(handlerPath), "DiscordDeleteSessionHandler should exist.");
|
||||
|
||||
var handler = File.ReadAllText(handlerPath);
|
||||
Assert.Contains("DELETE FROM sessions", handler, StringComparison.Ordinal);
|
||||
Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal);
|
||||
Assert.Contains("CanManageSchedule", handler, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,17 @@ public sealed class DiscordNewSessionHandlerTests
|
||||
|
||||
// --- Runtime tests for ParseTimeInput (static, no DB) ---
|
||||
|
||||
[Fact]
|
||||
public void ParseTimeInput_ShouldTreatInputAsMoscowTime()
|
||||
{
|
||||
var result = DiscordNewSessionHandler.ParseTimeInput("2026-06-01 15:00");
|
||||
Assert.True(result.IsSuccess);
|
||||
// 15:00 MSK = 12:00 UTC
|
||||
Assert.Equal(12, result.Value.Hour);
|
||||
Assert.Equal(0, result.Value.Minute);
|
||||
Assert.Equal(TimeSpan.Zero, result.Value.Offset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTimeInput_ShouldParseDiscordDateFormat()
|
||||
{
|
||||
@@ -28,7 +39,8 @@ public sealed class DiscordNewSessionHandlerTests
|
||||
Assert.Equal(expected.Year, result.Value.Year);
|
||||
Assert.Equal(expected.Month, result.Value.Month);
|
||||
Assert.Equal(expected.Day, result.Value.Day);
|
||||
Assert.Equal(19, result.Value.Hour);
|
||||
// Input is treated as Moscow time; 19:30 MSK = 16:30 UTC
|
||||
Assert.Equal(16, result.Value.Hour);
|
||||
Assert.Equal(30, result.Value.Minute);
|
||||
}
|
||||
|
||||
@@ -103,6 +115,18 @@ public sealed class DiscordNewSessionHandlerTests
|
||||
Assert.Contains("UnauthorizedAccessException", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldLoadCoGmPermissionsFromDiscordPlayers()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
var source = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.Matches(
|
||||
@"QueryAsync<ulong>[\s\S]*JOIN players p ON p\.id = gm\.player_id[\s\S]*p\.platform = 'Discord'[\s\S]*g\.external_group_id = @GuildId",
|
||||
source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldBePlatformNeutral()
|
||||
{
|
||||
@@ -127,6 +151,18 @@ public sealed class DiscordNewSessionHandlerTests
|
||||
Assert.Contains("RollbackAsync", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldNotRollbackCommittedTransactionAfterPostCommitFailure()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
var source = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.Contains("transactionCommitted = false", source, StringComparison.Ordinal);
|
||||
Assert.Contains("transactionCommitted = true", source, StringComparison.Ordinal);
|
||||
Assert.Contains("if (!transactionCommitted)", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldRespectCancellationToken()
|
||||
{
|
||||
@@ -148,6 +184,29 @@ public sealed class DiscordNewSessionHandlerTests
|
||||
Assert.Contains("message.Embeds = embeds", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldLeaveScheduleMessageCreationToInteractionResponse()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
var source = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.DoesNotContain("SendScheduleAsync", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("PlatformScheduleMessage", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_ShouldStoreReadableDiscordGroupNameForWebCards()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||
var source = File.ReadAllText(handlerPath);
|
||||
|
||||
Assert.Contains("groupName", source, StringComparison.Ordinal);
|
||||
Assert.Contains("displayGroupName", source, StringComparison.Ordinal);
|
||||
Assert.Contains("VALUES (@GroupName, 'Discord'", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static DateTimeOffset FutureDateAt1930()
|
||||
{
|
||||
var future = DateTimeOffset.UtcNow.AddDays(7);
|
||||
|
||||
@@ -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.0.10", compose);
|
||||
Assert.Contains("gmrelay-discord-bot:3.3.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.0.10</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 3.0.10", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:3.0.10", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-web:3.0.10", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-discord-bot:3.0.10", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("<Version>3.3.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 3.3.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-web:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-discord-bot:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains(
|
||||
"v3.0.10",
|
||||
"v3.3.0",
|
||||
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,26 @@ public sealed class DiscordSessionInteractionModuleSourceTests
|
||||
Assert.Contains("MessageFlags.Ephemeral", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Module_ShouldUpdateSourceScheduleMessageThroughComponentInteraction()
|
||||
{
|
||||
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
|
||||
|
||||
Assert.Contains("InteractionCallback.DeferredModifyMessage", source, StringComparison.Ordinal);
|
||||
Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal);
|
||||
Assert.Contains("FollowupAsync", source, StringComparison.Ordinal);
|
||||
Assert.Contains("CompleteScheduleUpdateResponseAsync", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Module_ShouldRouteDeleteSessionButtons()
|
||||
{
|
||||
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
|
||||
|
||||
Assert.Contains("[ComponentInteraction(\"delete_session\")]", source, StringComparison.Ordinal);
|
||||
Assert.Contains("DiscordDeleteSessionHandler", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Module_ShouldRouteRsvpButtonsToNeutralHandler()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
+36
@@ -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}'.");
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -13,6 +13,7 @@ public sealed class PlatformNeutralSessionInteractionCommandTests
|
||||
AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
|
||||
AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
|
||||
AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
||||
AssertProperty<JoinSessionCommand>("DeferScheduleUpdate", typeof(bool));
|
||||
AssertNoTelegramSpecificProperties<JoinSessionCommand>();
|
||||
}
|
||||
|
||||
@@ -24,12 +25,29 @@ public sealed class PlatformNeutralSessionInteractionCommandTests
|
||||
AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
|
||||
AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
|
||||
AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
||||
AssertProperty<LeaveSessionCommand>("DeferScheduleUpdate", typeof(bool));
|
||||
AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionInteractionResult_ShouldExposeReplyTextAndUpdatedView()
|
||||
{
|
||||
var resultType = typeof(JoinSessionCommand).Assembly.GetType(
|
||||
"GmRelay.Shared.Features.Sessions.CreateSession.SessionInteractionResult");
|
||||
|
||||
Assert.NotNull(resultType);
|
||||
AssertProperty(resultType, "ReplyText", typeof(string));
|
||||
AssertProperty(resultType, "UpdatedView", typeof(GmRelay.Shared.Rendering.SessionBatchViewModel));
|
||||
}
|
||||
|
||||
private static void AssertProperty<T>(string name, Type expectedType)
|
||||
{
|
||||
var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name);
|
||||
AssertProperty(typeof(T), name, expectedType);
|
||||
}
|
||||
|
||||
private static void AssertProperty(Type type, string name, Type expectedType)
|
||||
{
|
||||
var property = Assert.Single(type.GetProperties(), property => property.Name == name);
|
||||
|
||||
Assert.Equal(expectedType, property.PropertyType);
|
||||
}
|
||||
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
-13
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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(
|
||||
|
||||
+43
@@ -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);
|
||||
}
|
||||
@@ -91,6 +91,15 @@ public sealed class PlatformIdentityMigrationTests
|
||||
Assert.Contains("platform", service, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSessionService_ShouldAuthorizeGroupsAcrossLinkedIdentities()
|
||||
{
|
||||
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
||||
|
||||
Assert.Contains("_ResolveLinkedPlayerIdsAsync", service, StringComparison.Ordinal);
|
||||
Assert.Contains("player_id = ANY(@PlayerIds)", service, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttendanceStatsFunction_ShouldReferenceExternalUsername()
|
||||
{
|
||||
@@ -108,6 +117,28 @@ public sealed class PlatformIdentityMigrationTests
|
||||
Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrationV024_ShouldDeprecateTelegramColumns()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V024__deprecate_telegram_columns.sql");
|
||||
|
||||
Assert.Contains("UPDATE players", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("UPDATE game_groups", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("DEPRECATED", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("calendar_subscriptions", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("user_platform", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("user_external_id", migration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrationV025_ShouldBackfillRescheduleProposals()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V025__reschedule_proposals_telegram_external.sql");
|
||||
|
||||
Assert.Contains("reschedule_proposals", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("proposed_by_external_user_id", migration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
|
||||
@@ -149,6 +149,68 @@ public sealed class SessionSchedulerServiceTests
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickAsync_WhenHandlerThrows_BackoffsForDuration()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||
_clock.UtcNow = now;
|
||||
_store.SessionsNeedingConfirmation = [sessionId];
|
||||
_confirmationHandler.ThrowFor.Add(sessionId);
|
||||
|
||||
var sut = CreateSut();
|
||||
await sut.TickAsync(CancellationToken.None);
|
||||
Assert.Single(_confirmationHandler.Calls);
|
||||
|
||||
// Second tick immediately — should be backed off
|
||||
await sut.TickAsync(CancellationToken.None);
|
||||
Assert.Single(_confirmationHandler.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickAsync_WhenHandlerThrows_AfterBackoffRetriesAgain()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||
_clock.UtcNow = now;
|
||||
_store.SessionsNeedingConfirmation = [sessionId];
|
||||
_confirmationHandler.ThrowFor.Add(sessionId);
|
||||
|
||||
var sut = CreateSut();
|
||||
await sut.TickAsync(CancellationToken.None);
|
||||
Assert.Single(_confirmationHandler.Calls);
|
||||
|
||||
// Advance clock past backoff duration (15 min)
|
||||
_clock.UtcNow = now.AddMinutes(16);
|
||||
await sut.TickAsync(CancellationToken.None);
|
||||
Assert.Equal(2, _confirmationHandler.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickAsync_WhenHandlerSucceedsAfterBackoff_ClearsBackoff()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||
_clock.UtcNow = now;
|
||||
_store.SessionsNeedingConfirmation = [sessionId];
|
||||
_confirmationHandler.ThrowFor.Add(sessionId);
|
||||
|
||||
var sut = CreateSut();
|
||||
await sut.TickAsync(CancellationToken.None);
|
||||
Assert.Single(_confirmationHandler.Calls);
|
||||
|
||||
// Remove throw condition, advance past backoff
|
||||
_confirmationHandler.ThrowFor.Remove(sessionId);
|
||||
_clock.UtcNow = now.AddMinutes(16);
|
||||
await sut.TickAsync(CancellationToken.None);
|
||||
Assert.Equal(2, _confirmationHandler.Calls.Count);
|
||||
|
||||
// Next tick should still call because backoff was cleared on success
|
||||
_clock.UtcNow = now.AddMinutes(17);
|
||||
await sut.TickAsync(CancellationToken.None);
|
||||
Assert.Equal(3, _confirmationHandler.Calls.Count);
|
||||
}
|
||||
|
||||
private sealed class FakeSendConfirmationHandler : ISendConfirmationHandler
|
||||
{
|
||||
public List<Guid> Calls { get; } = [];
|
||||
|
||||
+4
-3
@@ -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);
|
||||
}
|
||||
|
||||
+19
-5
@@ -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);
|
||||
|
||||
@@ -176,6 +176,32 @@ public sealed class DiscordSessionBatchRendererTests
|
||||
Assert.Equal("https://example.com/game", embeds[0].Url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldNormalizeBareDomainJoinLinkForEmbedUrl()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "mobaxterm.mobatek.net/game") };
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
var (embeds, _) = DiscordSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Equal("https://mobaxterm.mobatek.net/game", embeds[0].Url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldNotSetEmbedUrlWhenJoinLinkIsNotHttpUrl()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "test") };
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
var (embeds, _) = DiscordSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Null(embeds[0].Url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldEmbedCorrectFieldValues()
|
||||
{
|
||||
|
||||
@@ -786,6 +786,9 @@ public sealed class AuthorizedSessionServiceTests
|
||||
public bool CreateBatchFromTemplateCalled { get; private set; }
|
||||
public bool AddCoGmCalled { get; private set; }
|
||||
public bool RemoveCoGmCalled { get; private set; }
|
||||
public bool UpdatePublicGroupSettingsCalled { get; private set; }
|
||||
public bool SetSessionPublicCalled { get; private set; }
|
||||
public bool SetBatchPublicCalled { get; private set; }
|
||||
public Guid? LastUpdatedSessionId { get; private set; }
|
||||
public Guid? LastUpdatedGroupId { get; private set; }
|
||||
public string? LastUpdatedTitle { get; private set; }
|
||||
@@ -821,6 +824,15 @@ public sealed class AuthorizedSessionServiceTests
|
||||
public string? LastAddedCoGmUsername { get; private set; }
|
||||
public Guid? LastRemovedCoGmGroupId { get; private set; }
|
||||
public long? LastRemovedCoGmTelegramId { get; private set; }
|
||||
public Guid? LastUpdatedPublicGroupId { get; private set; }
|
||||
public string? LastUpdatedPublicSlug { get; private set; }
|
||||
public bool? LastUpdatedPublicScheduleEnabled { get; private set; }
|
||||
public Guid? LastPublicSessionId { get; private set; }
|
||||
public Guid? LastPublicSessionGroupId { get; private set; }
|
||||
public bool? LastSessionPublicValue { get; private set; }
|
||||
public Guid? LastPublicBatchId { get; private set; }
|
||||
public Guid? LastPublicBatchGroupId { get; private set; }
|
||||
public bool? LastBatchPublicValue { get; private set; }
|
||||
public bool RemovePlayerCalled { get; private set; }
|
||||
public Guid? LastRemovedPlayerSessionId { get; private set; }
|
||||
public Guid? LastRemovedPlayerGroupId { get; private set; }
|
||||
@@ -842,6 +854,67 @@ public sealed class AuthorizedSessionServiceTests
|
||||
return Task.FromResult(group);
|
||||
}
|
||||
|
||||
public Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId)
|
||||
{
|
||||
if (!groupsById.TryGetValue(groupId, out var group))
|
||||
{
|
||||
return Task.FromResult<WebPublicGroupSettings?>(null);
|
||||
}
|
||||
|
||||
var publicSessionCount = sessionsById.Values.Count(session => session.GroupId == groupId && session.IsPublic);
|
||||
return Task.FromResult<WebPublicGroupSettings?>(new(
|
||||
groupId,
|
||||
group.Name,
|
||||
"alpha",
|
||||
false,
|
||||
publicSessionCount));
|
||||
}
|
||||
|
||||
public Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled)
|
||||
{
|
||||
UpdatePublicGroupSettingsCalled = true;
|
||||
LastUpdatedPublicGroupId = groupId;
|
||||
LastUpdatedPublicSlug = publicSlug;
|
||||
LastUpdatedPublicScheduleEnabled = publicScheduleEnabled;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
|
||||
{
|
||||
SetSessionPublicCalled = true;
|
||||
LastPublicSessionId = sessionId;
|
||||
LastPublicSessionGroupId = groupId;
|
||||
LastSessionPublicValue = isPublic;
|
||||
|
||||
if (sessionsById.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
sessionsById[sessionId] = session with { IsPublic = isPublic };
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
|
||||
{
|
||||
SetBatchPublicCalled = true;
|
||||
LastPublicBatchId = batchId;
|
||||
LastPublicBatchGroupId = groupId;
|
||||
LastBatchPublicValue = isPublic;
|
||||
|
||||
foreach (var session in sessionsById.Values.Where(session => session.BatchId == batchId && session.GroupId == groupId).ToList())
|
||||
{
|
||||
sessionsById[session.Id] = session with { IsPublic = isPublic };
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug) =>
|
||||
Task.FromResult<WebPublicClub?>(null);
|
||||
|
||||
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId) =>
|
||||
Task.FromResult<WebPublicSession?>(null);
|
||||
|
||||
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
|
||||
Task.FromResult(IsManager(groupId, telegramId));
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class GroupDetailsSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GroupDetails_ShouldManageCoGmUsingGroupPlatform()
|
||||
{
|
||||
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor");
|
||||
|
||||
Assert.Contains("CoGmPlatform", source, StringComparison.Ordinal);
|
||||
Assert.Contains("@CoGmIdLabel", source, StringComparison.Ordinal);
|
||||
Assert.Contains("coGmModel.ExternalUserId", source, StringComparison.Ordinal);
|
||||
Assert.Matches(@"SessionService\.AddCoGmForOwnerAsync\(\s*GroupId,\s*CoGmPlatform,\s*coGmExternalUserId", source);
|
||||
Assert.Matches(@"SessionService\.RemoveCoGmForOwnerAsync\(GroupId,\s*platform,\s*coGmExternalUserId\)", source);
|
||||
Assert.DoesNotContain("Telegram ID co-GM", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("SessionService.RemoveCoGmForOwnerAsync(GroupId, \"Telegram\"", source, 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,35 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class HomePageSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HomePage_ShouldShowPlatformBadgeForGroups()
|
||||
{
|
||||
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Home.razor");
|
||||
|
||||
Assert.Contains("platform-badge", source, StringComparison.Ordinal);
|
||||
Assert.Contains("FormatPlatform", source, StringComparison.Ordinal);
|
||||
Assert.Contains("group.Platform", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionService_ShouldUseSessionTitleWhenDiscordGroupNameIsOnlyId()
|
||||
{
|
||||
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
||||
|
||||
Assert.Contains("latest_session.title", source, StringComparison.Ordinal);
|
||||
Assert.Contains("NULLIF(g.name, g.external_group_id)", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
|
||||
{
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
|
||||
var repoRoot = dir ?? throw new InvalidOperationException("Could not find repo root");
|
||||
return await File.ReadAllTextAsync(Path.Combine(repoRoot, relativePath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PublicClubPagesTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MigrationV026_ShouldAddPublicationControls()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V026__add_public_club_pages.sql");
|
||||
|
||||
Assert.Contains("public_slug", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("public_schedule_enabled", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("is_public", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("ux_game_groups_public_slug", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("ix_sessions_public_schedule", migration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicPages_ShouldExposeReadOnlyRoutesWithoutPrivateSessionData()
|
||||
{
|
||||
var publicClubPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
|
||||
var publicSessionPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicSession.razor");
|
||||
|
||||
Assert.Contains("@page \"/club/{Slug}\"", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.Contains("@page \"/s/{SessionId:guid}\"", publicSessionPage, StringComparison.Ordinal);
|
||||
Assert.Contains("@layout PublicLayout", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.Contains("@layout PublicLayout", publicSessionPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("@attribute [Authorize]", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("@attribute [Authorize]", publicSessionPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("JoinLink", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("JoinLink", publicSessionPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("WebParticipant", publicClubPage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("WebParticipant", publicSessionPage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionStore_ShouldFilterPublicPagesByGroupAndSessionPublication()
|
||||
{
|
||||
var sessionStore = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/ISessionStore.cs");
|
||||
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
||||
|
||||
Assert.Contains("GetPublicClubBySlugAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("GetPublicSessionAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("SetSessionPublicAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("SetBatchPublicAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("g.public_schedule_enabled = true", service, StringComparison.Ordinal);
|
||||
Assert.Contains("s.is_public = true", service, StringComparison.Ordinal);
|
||||
Assert.Contains("s.status <> @Cancelled", service, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("p.display_name AS DisplayName,\r\n p.external_username", PublicQuerySection(service), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dashboard_ShouldManagePublicationSettings()
|
||||
{
|
||||
var groupDetailsPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor");
|
||||
var authorizedService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedSessionService.cs");
|
||||
|
||||
Assert.Contains("UpdatePublicGroupSettingsForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("SetSessionPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("SetBatchPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("PublicSessionUrl", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("NormalizePublicSlug", authorizedService, StringComparison.Ordinal);
|
||||
Assert.Contains("IsGroupManagerAsync", authorizedService, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string PublicQuerySection(string source)
|
||||
{
|
||||
var start = source.IndexOf("GetPublicClubBySlugAsync", StringComparison.Ordinal);
|
||||
var end = source.IndexOf("public async Task<bool> IsGroupManagerAsync", StringComparison.Ordinal);
|
||||
return source[start..end];
|
||||
}
|
||||
|
||||
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}'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user