Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2ccc35e50 | |||
| 3418d1a46c | |||
| fac5d75c7e | |||
| 7a2965b43f | |||
| a0df94fc91 | |||
| 79694f7de8 | |||
| 542f15f2d6 | |||
| 64216f5a26 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 3.1.1
|
VERSION: 3.3.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>3.1.1</Version>
|
<Version>3.3.0</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру 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.
|
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
|
||||||
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
|
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
|
||||||
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
||||||
|
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
|
||||||
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
|
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
|
||||||
- **📦 Bulk-операции для Batch Sessions**:
|
- **📦 Bulk-операции для Batch Sessions**:
|
||||||
- обновить общий `title`/`link` у всей пачки;
|
- обновить общий `title`/`link` у всей пачки;
|
||||||
|
|||||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
|||||||
crond -f
|
crond -f
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.1.1
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.3.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.1.1
|
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.3.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -84,7 +84,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.1.1
|
image: git.codeanddice.ru/toutsu/gmrelay-web:3.3.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ C4Context
|
|||||||
|
|
||||||
Person(gm, "Game Master", "Creates sessions and manages schedules")
|
Person(gm, "Game Master", "Creates sessions and manages schedules")
|
||||||
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
|
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(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")
|
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(gm, discord, "Uses /newsession and /listsessions")
|
||||||
Rel(player, telegram, "Uses inline buttons")
|
Rel(player, telegram, "Uses inline buttons")
|
||||||
Rel(player, discord, "Uses Join/Leave and RSVP 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(telegram, gmrelay, "Updates via long polling")
|
||||||
Rel(discord, gmrelay, "Gateway events and component interactions")
|
Rel(discord, gmrelay, "Gateway events and component interactions")
|
||||||
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
||||||
@@ -34,13 +36,14 @@ C4Container
|
|||||||
|
|
||||||
Person(gm, "Game Master")
|
Person(gm, "Game Master")
|
||||||
Person(player, "Player")
|
Person(player, "Player")
|
||||||
|
Person(visitor, "Public visitor")
|
||||||
|
|
||||||
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
|
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
|
||||||
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
|
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(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")
|
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")
|
System_Ext(telegram, "Telegram Bot API")
|
||||||
@@ -50,6 +53,7 @@ C4Container
|
|||||||
Rel(gm, discord, "Slash commands")
|
Rel(gm, discord, "Slash commands")
|
||||||
Rel(player, telegram, "Callback queries")
|
Rel(player, telegram, "Callback queries")
|
||||||
Rel(player, discord, "Button interactions")
|
Rel(player, discord, "Button interactions")
|
||||||
|
Rel(visitor, web, "Read-only public schedule pages")
|
||||||
Rel(telegram, bot, "GetUpdates")
|
Rel(telegram, bot, "GetUpdates")
|
||||||
Rel(discord, discordBot, "Gateway events")
|
Rel(discord, discordBot, "Gateway events")
|
||||||
Rel(bot, telegram, "Bot API calls")
|
Rel(bot, telegram, "Bot API calls")
|
||||||
|
|||||||
@@ -1,294 +1,178 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Platform;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
|
|
||||||
|
|
||||||
public sealed class CreateSessionHandler(
|
public sealed class CreateSessionHandler(
|
||||||
|
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler sharedHandler,
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient botClient,
|
IPlatformMessenger messenger,
|
||||||
ILogger<CreateSessionHandler> logger)
|
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);
|
var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
foreach (var timeInput in parseResult.PastTimeInputs)
|
foreach (var timeInput in parseResult.PastTimeInputs)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
message.Chat.Id,
|
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||||
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
|
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
|
||||||
cancellationToken: cancellationToken);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var timeInput in parseResult.InvalidTimeInputs)
|
foreach (var timeInput in parseResult.InvalidTimeInputs)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
message.Chat.Id,
|
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||||
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
|
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
|
||||||
cancellationToken: cancellationToken);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
|
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
message.Chat.Id,
|
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||||
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
|
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
|
||||||
cancellationToken: cancellationToken);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
|
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
message.Chat.Id,
|
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||||
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
|
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
|
||||||
cancellationToken: cancellationToken);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parseResult.IsValid)
|
if (!parseResult.IsValid)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
chatId: message.Chat.Id,
|
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||||
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);
|
❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:
|
||||||
|
|
||||||
|
/newsession
|
||||||
|
Название: My Game
|
||||||
|
Время: 15.05.2026 19:30
|
||||||
|
Время: 22.05.2026 19:30
|
||||||
|
Мест: 4
|
||||||
|
Ссылка: https://link
|
||||||
|
Картинка: https://cover
|
||||||
|
|
||||||
|
Для повтора можно указать одну дату и строки:
|
||||||
|
Игр: 4
|
||||||
|
Интервал: 7
|
||||||
|
""",
|
||||||
|
ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var title = parseResult.Title!;
|
|
||||||
var link = parseResult.Link!;
|
|
||||||
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
|
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
|
||||||
var gmId = message.From!.Id;
|
var gmId = message.From!.Id;
|
||||||
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
|
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
|
||||||
var gmUsername = message.From.Username;
|
var gmUsername = message.From.Username;
|
||||||
|
|
||||||
var chatId = message.Chat.Id;
|
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
|
||||||
var chatTitle = message.Chat.Title ?? "Private Chat";
|
message.Chat.IsForum,
|
||||||
|
message.MessageThreadId);
|
||||||
|
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
|
||||||
|
var messageThreadId = topicDestination.MessageThreadId;
|
||||||
|
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
if (topicDestination.ShouldCreateForumTopic)
|
||||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
|
||||||
INSERT INTO players (display_name, platform, external_user_id, external_username)
|
|
||||||
VALUES (@Name, 'Telegram', @ExternalId, @Username)
|
|
||||||
ON CONFLICT (platform, external_user_id)
|
|
||||||
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
|
||||||
DO UPDATE
|
|
||||||
SET display_name = EXCLUDED.display_name,
|
|
||||||
external_username = EXCLUDED.external_username;
|
|
||||||
""",
|
|
||||||
new { ExternalId = gmId.ToString(), Name = gmName, Username = gmUsername },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
|
||||||
"""
|
|
||||||
SELECT g.id AS GroupId,
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM group_managers gm
|
|
||||||
JOIN players p ON p.id = gm.player_id
|
|
||||||
WHERE gm.group_id = g.id
|
|
||||||
AND p.platform = 'Telegram'
|
|
||||||
AND p.external_user_id = @ExternalGmId
|
|
||||||
) AS CanManage
|
|
||||||
FROM game_groups g
|
|
||||||
WHERE g.platform = 'Telegram'
|
|
||||||
AND g.external_group_id = @ExternalChatId
|
|
||||||
""",
|
|
||||||
new { ExternalChatId = chatId.ToString(), ExternalGmId = gmId.ToString() },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
Guid groupId;
|
|
||||||
if (existingGroup is null)
|
|
||||||
{
|
|
||||||
groupId = await connection.ExecuteScalarAsync<Guid>(
|
|
||||||
"""
|
|
||||||
INSERT INTO game_groups (name, platform, external_group_id)
|
|
||||||
VALUES (@ChatName, 'Telegram', @ExternalChatId)
|
|
||||||
RETURNING id;
|
|
||||||
""",
|
|
||||||
new { ExternalChatId = chatId.ToString(), ChatName = chatTitle },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
|
||||||
INSERT INTO group_managers (group_id, player_id, role)
|
|
||||||
SELECT @GroupId, p.id, @OwnerRole
|
|
||||||
FROM players p
|
|
||||||
WHERE p.platform = 'Telegram'
|
|
||||||
AND p.external_user_id = @ExternalGmId
|
|
||||||
ON CONFLICT (group_id, player_id) DO NOTHING
|
|
||||||
""",
|
|
||||||
new { GroupId = groupId, ExternalGmId = gmId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
|
||||||
transaction);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (!existingGroup.CanManage)
|
|
||||||
{
|
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
|
||||||
await botClient.SendMessage(
|
|
||||||
chatId,
|
|
||||||
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
groupId = existingGroup.GroupId;
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"UPDATE game_groups SET name = @ChatName WHERE id = @GroupId",
|
|
||||||
new { ChatName = chatTitle, GroupId = groupId },
|
|
||||||
transaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
|
|
||||||
message.Chat.IsForum,
|
|
||||||
message.MessageThreadId);
|
|
||||||
var messageThreadId = topicDestination.MessageThreadId;
|
|
||||||
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
|
|
||||||
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
|
try
|
||||||
{
|
{
|
||||||
await botClient.DeleteMessage(
|
var topicRef = await messenger.CreateThreadAsync(
|
||||||
chatId: chatId,
|
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||||
messageId: message.MessageId,
|
$"🎲 Игры: {parseResult.Title}",
|
||||||
cancellationToken: cancellationToken);
|
ct);
|
||||||
|
messageThreadId = int.Parse(topicRef.ExternalThreadId!, System.Globalization.CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Ошибка при создании сессии");
|
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, message.Chat.Id);
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
|
||||||
await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,114 +1,25 @@
|
|||||||
using System.Text;
|
|
||||||
using Dapper;
|
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Npgsql;
|
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
|
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||||||
|
|
||||||
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
|
||||||
|
|
||||||
public sealed class ExportCalendarHandler(
|
public sealed class ExportCalendarHandler(
|
||||||
NpgsqlDataSource dataSource,
|
GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler sharedHandler)
|
||||||
IPlatformMessenger messenger,
|
|
||||||
IConfiguration configuration)
|
|
||||||
{
|
{
|
||||||
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>(
|
return sharedHandler.HandleAsync(command, cancellationToken);
|
||||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
|
|
||||||
+ " FROM sessions s"
|
|
||||||
+ " JOIN game_groups g ON s.group_id = g.id"
|
|
||||||
+ " WHERE g.platform = 'Telegram'"
|
|
||||||
+ " AND g.external_group_id = @ExternalChatId"
|
|
||||||
+ " AND s.status = @Planned"
|
|
||||||
+ " AND s.scheduled_at > NOW()"
|
|
||||||
+ " ORDER BY s.scheduled_at ASC",
|
|
||||||
new { ExternalChatId = message.Chat.Id.ToString(), Planned = SessionStatus.Planned });
|
|
||||||
|
|
||||||
var sessionsList = sessions.ToList();
|
|
||||||
|
|
||||||
if (sessionsList.Count == 0)
|
|
||||||
{
|
|
||||||
await messenger.SendGroupMessageAsync(
|
|
||||||
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
|
|
||||||
"📭 У этой группы нет запланированных сессий для экспорта.",
|
|
||||||
cancellationToken);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine("BEGIN:VCALENDAR");
|
|
||||||
sb.AppendLine("VERSION:2.0");
|
|
||||||
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
|
|
||||||
|
|
||||||
foreach (var s in sessionsList)
|
|
||||||
{
|
|
||||||
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
|
|
||||||
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
|
|
||||||
|
|
||||||
sb.AppendLine("BEGIN:VEVENT");
|
|
||||||
sb.AppendLine($"UID:{s.Id}@gmrelay");
|
|
||||||
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
|
|
||||||
sb.AppendLine($"DTSTART:{dtStart}");
|
|
||||||
sb.AppendLine($"DTEND:{dtEnd}");
|
|
||||||
sb.AppendLine($"SUMMARY:{s.Title}");
|
|
||||||
sb.AppendLine("END:VEVENT");
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.AppendLine("END:VCALENDAR");
|
|
||||||
|
|
||||||
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
|
||||||
|
|
||||||
|
|
||||||
// Create calendar subscription
|
|
||||||
string? subscriptionUrl = null;
|
|
||||||
var baseUrl = configuration["Web:BaseUrl"];
|
|
||||||
var senderId = message.From?.Id;
|
|
||||||
if (!string.IsNullOrWhiteSpace(baseUrl) && senderId.HasValue)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var token = Guid.NewGuid().ToString("N");
|
|
||||||
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
|
|
||||||
@"SELECT id FROM game_groups WHERE platform = 'Telegram' AND external_group_id = @ExternalChatId",
|
|
||||||
new { ExternalChatId = message.Chat.Id.ToString() });
|
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
@"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at)
|
|
||||||
VALUES (gen_random_uuid(), @token, 'Telegram', @userExternalId, @groupId, @filterType, now(), NULL)",
|
|
||||||
new { token, userExternalId = senderId.Value.ToString(), groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
|
|
||||||
|
|
||||||
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Non-critical: if subscription creation fails, still send the file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var actions = subscriptionUrl is not null
|
|
||||||
? new[]
|
|
||||||
{
|
|
||||||
new PlatformMessageAction(
|
|
||||||
"calendar-subscription",
|
|
||||||
"🔗 Подписаться на календарь",
|
|
||||||
subscriptionUrl)
|
|
||||||
}
|
|
||||||
: Array.Empty<PlatformMessageAction>();
|
|
||||||
|
|
||||||
await messenger.SendCalendarFileAsync(
|
|
||||||
new PlatformCalendarFile(
|
|
||||||
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
|
|
||||||
"schedule.ics",
|
|
||||||
bytes,
|
|
||||||
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
|
||||||
actions),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
using Dapper;
|
|
||||||
using Npgsql;
|
|
||||||
using Telegram.Bot;
|
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
@@ -13,143 +10,88 @@ public sealed record DeleteSessionCommand(
|
|||||||
long ChatId,
|
long ChatId,
|
||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
internal sealed record DeleteSessionInfoDto(
|
|
||||||
string Title,
|
|
||||||
Guid BatchId,
|
|
||||||
Guid GroupId,
|
|
||||||
bool CanManage,
|
|
||||||
int? ThreadId,
|
|
||||||
bool TopicCreatedByBot);
|
|
||||||
|
|
||||||
public sealed class DeleteSessionHandler(
|
public sealed class DeleteSessionHandler(
|
||||||
NpgsqlDataSource dataSource,
|
GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler sharedHandler,
|
||||||
ITelegramBotClient bot,
|
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler listSessionsHandler,
|
||||||
|
IPlatformMessenger messenger,
|
||||||
ILogger<DeleteSessionHandler> logger)
|
ILogger<DeleteSessionHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct)
|
public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
var platformUser = new PlatformUser(
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
PlatformKind.Telegram,
|
||||||
|
command.TelegramUserId.ToString(),
|
||||||
|
string.Empty,
|
||||||
|
null);
|
||||||
|
|
||||||
// 1. Fetch session and verify group manager.
|
var platformGroup = new PlatformGroup(
|
||||||
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
|
PlatformKind.Telegram,
|
||||||
"""
|
command.ChatId.ToString(),
|
||||||
SELECT s.title AS Title,
|
string.Empty);
|
||||||
s.batch_id AS BatchId,
|
|
||||||
s.group_id AS GroupId,
|
|
||||||
s.thread_id AS ThreadId,
|
|
||||||
s.topic_created_by_bot AS TopicCreatedByBot,
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM group_managers gm
|
|
||||||
JOIN players p ON p.id = gm.player_id
|
|
||||||
WHERE gm.group_id = s.group_id
|
|
||||||
AND p.platform = 'Telegram'
|
|
||||||
AND p.external_user_id = @ExternalUserId
|
|
||||||
) AS CanManage
|
|
||||||
FROM sessions s
|
|
||||||
WHERE s.id = @SessionId
|
|
||||||
""",
|
|
||||||
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() }, transaction);
|
|
||||||
|
|
||||||
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;
|
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.
|
// 4. If no sessions are left in a bot-owned forum topic, delete the topic.
|
||||||
if (session.ThreadId.HasValue &&
|
if (result.ThreadId.HasValue &&
|
||||||
TelegramTopicRouting.ShouldDeleteForumTopic(session.TopicCreatedByBot, remainingInTopic))
|
TelegramTopicRouting.ShouldDeleteForumTopic(result.TopicCreatedByBot, result.RemainingInTopic))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.DeleteForumTopic(command.ChatId, session.ThreadId.Value, cancellationToken: ct);
|
await messenger.DeleteThreadAsync(
|
||||||
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", session.ThreadId.Value, session.BatchId);
|
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)
|
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)
|
// 5. Update the /listsessions message
|
||||||
// A simple way is to re-render the list:
|
var listCommand = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(platformGroup, platformUser);
|
||||||
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
|
var listResult = await listSessionsHandler.HandleAsync(listCommand, ct);
|
||||||
var sessions = await readConnection.QueryAsync<SessionListItemDto>(
|
|
||||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
|
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM group_managers gm
|
|
||||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
|
||||||
WHERE gm.group_id = s.group_id
|
|
||||||
AND manager_player.platform = 'Telegram'
|
|
||||||
AND manager_player.external_user_id = @ExternalUserId
|
|
||||||
) AS CanManage
|
|
||||||
FROM sessions s
|
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
|
||||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
|
||||||
WHERE g.platform = 'Telegram'
|
|
||||||
AND g.external_group_id = @ExternalChatId
|
|
||||||
AND s.status != @Cancelled
|
|
||||||
AND s.scheduled_at > NOW()
|
|
||||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
|
|
||||||
ORDER BY s.scheduled_at ASC",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
ExternalChatId = command.ChatId.ToString(),
|
|
||||||
ExternalUserId = command.TelegramUserId.ToString(),
|
|
||||||
Cancelled = SessionStatus.Cancelled,
|
|
||||||
Active = ParticipantRegistrationStatus.Active,
|
|
||||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
|
||||||
});
|
|
||||||
|
|
||||||
var sessionsList = sessions.ToList();
|
if (listResult.Sessions.Count == 0)
|
||||||
|
|
||||||
if (sessionsList.Count == 0)
|
|
||||||
{
|
{
|
||||||
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch { }
|
try
|
||||||
|
{
|
||||||
|
await messenger.UpdateGroupMessageAsync(
|
||||||
|
scheduleMessage,
|
||||||
|
"📭 В этой группе нет предстоящих игр.",
|
||||||
|
[],
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var renderResult = SessionListMessageRenderer.Render(sessionsList);
|
var text = SessionListMessageRenderer.RenderText(listResult.Sessions);
|
||||||
|
var actions = listResult.CanManage ? SessionListMessageRenderer.RenderActions(listResult.Sessions) : [];
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.EditMessageText(
|
await messenger.UpdateGroupMessageAsync(scheduleMessage, text, actions, ct);
|
||||||
command.ChatId,
|
|
||||||
command.MessageId,
|
|
||||||
renderResult.Text,
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
cancellationToken: ct);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,118 +1,37 @@
|
|||||||
using Dapper;
|
using GmRelay.Shared.Platform;
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using Npgsql;
|
|
||||||
using Telegram.Bot;
|
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
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(
|
public sealed class ListSessionsHandler(
|
||||||
NpgsqlDataSource dataSource,
|
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler sharedHandler,
|
||||||
ITelegramBotClient botClient)
|
IPlatformMessenger messenger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
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>(
|
var result = await sharedHandler.HandleAsync(command, cancellationToken);
|
||||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
|
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
|
||||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM group_managers gm
|
|
||||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
|
||||||
WHERE gm.group_id = s.group_id
|
|
||||||
AND manager_player.platform = 'Telegram'
|
|
||||||
AND manager_player.external_user_id = @ExternalUserId
|
|
||||||
) AS CanManage
|
|
||||||
FROM sessions s
|
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
|
||||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
|
||||||
WHERE g.platform = 'Telegram'
|
|
||||||
AND g.external_group_id = @ExternalChatId
|
|
||||||
AND s.status != @Cancelled
|
|
||||||
AND s.scheduled_at > NOW()
|
|
||||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
|
|
||||||
ORDER BY s.scheduled_at ASC",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
ExternalChatId = message.Chat.Id.ToString(),
|
|
||||||
ExternalUserId = message.From?.Id.ToString(),
|
|
||||||
Cancelled = SessionStatus.Cancelled,
|
|
||||||
Active = ParticipantRegistrationStatus.Active,
|
|
||||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
|
||||||
});
|
|
||||||
|
|
||||||
var sessionsList = sessions.ToList();
|
if (result.Sessions.Count == 0)
|
||||||
|
|
||||||
if (sessionsList.Count == 0)
|
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(command.Group, "📭 В этой группе нет предстоящих игр.", cancellationToken);
|
||||||
chatId: message.Chat.Id,
|
|
||||||
text: "📭 В этой группе нет предстоящих игр.",
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var renderResult = SessionListMessageRenderer.Render(sessionsList);
|
var text = SessionListMessageRenderer.RenderText(result.Sessions);
|
||||||
|
var actions = result.CanManage ? SessionListMessageRenderer.RenderActions(result.Sessions) : [];
|
||||||
|
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(command.Group, text, actions, cancellationToken);
|
||||||
chatId: message.Chat.Id,
|
|
||||||
text: renderResult.Text,
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
cancellationToken: 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
-213
@@ -12,243 +12,156 @@ using GmRelay.Bot.Infrastructure.Telegram;
|
|||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
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>
|
/// <summary>
|
||||||
/// Handles text input from the GM who has an AwaitingTime proposal.
|
/// Telegram adapter for reschedule time input.
|
||||||
/// Parses reschedule options with a voting deadline, creates a voting message,
|
/// Delegates core logic to the shared handler, then performs Telegram-specific
|
||||||
/// and tags all participants.
|
/// message sending, DM notifications, vote_message_id storage, and cleanup.
|
||||||
/// If no participants are registered, reschedules immediately.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HandleRescheduleTimeInputHandler(
|
public sealed class HandleRescheduleTimeInputHandler(
|
||||||
|
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler sharedHandler,
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
IPlatformMessenger messenger,
|
IPlatformMessenger messenger,
|
||||||
DirectSessionNotificationSender directSender,
|
DirectSessionNotificationSender directSender,
|
||||||
ILogger<HandleRescheduleTimeInputHandler> logger)
|
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)
|
public async Task<bool> TryHandleAsync(Message message, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
|
if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var gmTelegramId = message.From.Id;
|
var command = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputCommand(
|
||||||
var chatId = message.Chat.Id;
|
new PlatformUser(
|
||||||
var text = message.Text.Trim();
|
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);
|
var result = await sharedHandler.HandleAsync(command, ct);
|
||||||
|
if (!result.Handled)
|
||||||
// 1. Check if this GM has an AwaitingTime proposal in this chat
|
|
||||||
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
|
|
||||||
"""
|
|
||||||
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
|
||||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
|
||||||
g.external_group_id::BIGINT AS TelegramChatId,
|
|
||||||
s.thread_id AS ThreadId,
|
|
||||||
s.notification_mode AS NotificationMode
|
|
||||||
FROM reschedule_proposals rp
|
|
||||||
JOIN sessions s ON s.id = rp.session_id
|
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
|
||||||
WHERE rp.proposed_by_external_user_id = @ExternalGmId
|
|
||||||
AND rp.status = 'AwaitingTime'
|
|
||||||
AND g.platform = 'Telegram'
|
|
||||||
AND g.external_group_id = @ExternalChatId
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM group_managers gm
|
|
||||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
|
||||||
WHERE gm.group_id = s.group_id
|
|
||||||
AND manager_player.platform = 'Telegram'
|
|
||||||
AND manager_player.external_user_id = @ExternalGmId
|
|
||||||
)
|
|
||||||
ORDER BY rp.created_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
new { ExternalGmId = gmTelegramId.ToString(), ExternalChatId = chatId.ToString() });
|
|
||||||
|
|
||||||
if (proposal is null)
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// 2. Parse voting input
|
if (!string.IsNullOrEmpty(result.ReplyText) && !result.IsRescheduledImmediately)
|
||||||
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
|
|
||||||
{
|
{
|
||||||
await messenger.SendGroupMessageAsync(
|
await messenger.SendGroupMessageAsync(
|
||||||
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
|
command.Group,
|
||||||
$"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
|
$"""⚠️ {result.ReplyText}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>""",
|
||||||
ct);
|
ct);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Load participants (non-GM) signed up for this session
|
if (result.IsRescheduledImmediately)
|
||||||
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
|
||||||
"""
|
|
||||||
SELECT p.id AS PlayerId,
|
|
||||||
p.display_name AS DisplayName,
|
|
||||||
p.external_username AS TelegramUsername,
|
|
||||||
p.external_user_id::BIGINT AS TelegramId
|
|
||||||
FROM session_participants sp
|
|
||||||
JOIN players p ON p.id = sp.player_id
|
|
||||||
WHERE sp.session_id = @SessionId
|
|
||||||
AND sp.is_gm = false
|
|
||||||
AND sp.registration_status = @Active
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
|
||||||
|
|
||||||
// 4. If no participants — reschedule immediately
|
|
||||||
if (participants.Count == 0)
|
|
||||||
{
|
{
|
||||||
await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct);
|
if (result.UpdatedView is not null && result.BatchMessageId.HasValue)
|
||||||
await TryDeleteMessage(chatId, message.MessageId, ct);
|
{
|
||||||
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Create voting message
|
// Voting mode
|
||||||
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);
|
|
||||||
|
|
||||||
var voteText = BuildVotingMessage(
|
var voteText = BuildVotingMessage(
|
||||||
proposal.Title,
|
result.Title!,
|
||||||
proposal.CurrentScheduledAt,
|
result.CurrentScheduledAt,
|
||||||
votingInput.Deadline,
|
result.VotingDeadlineAt!.Value,
|
||||||
options,
|
result.Options,
|
||||||
participants,
|
result.Participants,
|
||||||
[]);
|
[]);
|
||||||
var keyboard = BuildVotingKeyboard(options);
|
|
||||||
|
var keyboard = BuildVotingKeyboard(result.Options);
|
||||||
|
|
||||||
var voteMsg = await bot.SendMessage(
|
var voteMsg = await bot.SendMessage(
|
||||||
chatId: chatId,
|
chatId: message.Chat.Id,
|
||||||
messageThreadId: proposal.ThreadId,
|
messageThreadId: message.MessageThreadId,
|
||||||
text: voteText,
|
text: voteText,
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
replyMarkup: keyboard,
|
replyMarkup: keyboard,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
|
|
||||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
|
var mode = await GetNotificationModeAsync(result.ProposalId!.Value, ct);
|
||||||
if (mode.ShouldSendDirectMessages())
|
if (mode.ShouldSendDirectMessages())
|
||||||
{
|
{
|
||||||
var optionsText = string.Join(
|
var optionsText = string.Join(
|
||||||
"\n",
|
"\n",
|
||||||
options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
|
result.Options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
|
||||||
var directText = $"""
|
var directText = $"""
|
||||||
🔄 <b>Голосование за перенос сессии</b>
|
🔄 <b>Голосование за перенос сессии</b>
|
||||||
|
|
||||||
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
|
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
|
||||||
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
📅 Текущее время: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
||||||
🗳 Варианты:
|
🗳 Варианты:
|
||||||
{optionsText}
|
{optionsText}
|
||||||
|
|
||||||
⏳ Дедлайн: <b>{votingInput.Deadline.FormatMoscow()}</b> (МСК)
|
⏳ Дедлайн: <b>{result.VotingDeadlineAt.Value.FormatMoscow()}</b> (МСК)
|
||||||
|
|
||||||
Проголосуйте кнопкой в групповом сообщении.
|
Проголосуйте кнопкой в групповом сообщении.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await directSender.SendAsync(
|
await directSender.SendAsync(
|
||||||
participants.Select(p => new DirectNotificationRecipient(
|
result.Participants.Select(p => new DirectNotificationRecipient(
|
||||||
p.TelegramId,
|
p.TelegramId,
|
||||||
p.DisplayName)),
|
p.DisplayName)),
|
||||||
directText,
|
directText,
|
||||||
"reschedule-vote",
|
"reschedule-vote",
|
||||||
proposal.SessionId,
|
result.ProposalId.Value,
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store vote message ID
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
|
"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(
|
logger.LogInformation(
|
||||||
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
|
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
|
||||||
proposal.SessionId,
|
result.ProposalId.Value,
|
||||||
proposal.Id,
|
result.ProposalId.Value,
|
||||||
options.Count,
|
result.Options.Count,
|
||||||
votingInput.Deadline);
|
result.VotingDeadlineAt.Value);
|
||||||
|
|
||||||
// Delete GM's time input message
|
|
||||||
await TryDeleteMessage(chatId, message.MessageId, ct);
|
|
||||||
|
|
||||||
|
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RescheduleImmediately(
|
private async Task<SessionNotificationMode> GetNotificationModeAsync(Guid proposalId, CancellationToken ct)
|
||||||
NpgsqlConnection connection, AwaitingProposalDto proposal,
|
|
||||||
DateTimeOffset newTime, long chatId, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
var raw = await connection.QuerySingleOrDefaultAsync<string?>(
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
"""
|
||||||
UPDATE sessions
|
SELECT s.notification_mode
|
||||||
SET scheduled_at = @NewTime,
|
FROM sessions s
|
||||||
status = @Status,
|
JOIN reschedule_proposals rp ON rp.session_id = s.id
|
||||||
confirmation_message_id = NULL,
|
WHERE rp.id = @Id
|
||||||
confirmation_sent_at = NULL,
|
|
||||||
one_hour_reminder_processed_at = NULL,
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = @SessionId
|
|
||||||
""",
|
""",
|
||||||
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
|
new { Id = proposalId });
|
||||||
transaction);
|
return SessionNotificationModeExtensions.FromDatabaseValue(raw ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
private async Task TryUpdateBatchMessage(
|
||||||
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
|
PlatformGroup group,
|
||||||
new { NewTime = newTime, Id = proposal.Id },
|
SessionBatchViewModel view,
|
||||||
transaction);
|
PlatformMessageRef scheduleMessage,
|
||||||
|
CancellationToken ct)
|
||||||
await transaction.CommitAsync(ct);
|
{
|
||||||
|
try
|
||||||
await messenger.SendGroupMessageAsync(
|
{
|
||||||
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
|
await messenger.UpdateScheduleAsync(
|
||||||
$"✅ Сессия «{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>",
|
new PlatformScheduleMessage(group, view, scheduleMessage),
|
||||||
ct);
|
ct);
|
||||||
|
}
|
||||||
// Re-render batch message with updated time
|
catch (Exception ex)
|
||||||
await TryUpdateBatchMessage(proposal, ct);
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule");
|
||||||
logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static string BuildVotingMessage(
|
internal static string BuildVotingMessage(
|
||||||
@@ -270,7 +183,7 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
|
|
||||||
var lines = new List<string>
|
var lines = new List<string>
|
||||||
{
|
{
|
||||||
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
|
$"""🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>""",
|
||||||
"",
|
"",
|
||||||
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
|
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
|
||||||
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
|
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
|
||||||
@@ -351,52 +264,6 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
"dd.MM HH:mm",
|
"dd.MM HH:mm",
|
||||||
System.Globalization.CultureInfo.InvariantCulture);
|
System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
|
||||||
|
|
||||||
var batchSessions = (await conn.QueryAsync<SessionBatchDto>(
|
|
||||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
|
||||||
new { proposal.BatchId })).ToList();
|
|
||||||
|
|
||||||
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
|
|
||||||
"""
|
|
||||||
SELECT sp.session_id AS SessionId,
|
|
||||||
p.display_name AS DisplayName,
|
|
||||||
p.external_username AS TelegramUsername,
|
|
||||||
sp.registration_status AS RegistrationStatus
|
|
||||||
FROM session_participants sp
|
|
||||||
JOIN players p ON sp.player_id = p.id
|
|
||||||
JOIN sessions s ON sp.session_id = s.id
|
|
||||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
|
||||||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
|
|
||||||
""",
|
|
||||||
new { proposal.BatchId })).ToList();
|
|
||||||
|
|
||||||
if (proposal.BatchMessageId.HasValue)
|
|
||||||
{
|
|
||||||
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
|
||||||
|
|
||||||
await messenger.UpdateScheduleAsync(
|
|
||||||
new PlatformScheduleMessage(
|
|
||||||
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
|
|
||||||
view,
|
|
||||||
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
|
|
||||||
ct);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
logger.LogWarning("No batch_message_id stored for session {SessionId}, cannot edit batch message in-place", proposal.SessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule for session {SessionId}", proposal.SessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
|
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
+35
-119
@@ -1,8 +1,7 @@
|
|||||||
using Dapper;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
using Npgsql;
|
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
@@ -15,131 +14,49 @@ public sealed record HandleRescheduleVoteCommand(
|
|||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
public sealed class HandleRescheduleVoteHandler(
|
public sealed class HandleRescheduleVoteHandler(
|
||||||
NpgsqlDataSource dataSource,
|
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
IPlatformMessenger messenger,
|
IPlatformMessenger messenger,
|
||||||
ILogger<HandleRescheduleVoteHandler> logger)
|
ILogger<HandleRescheduleVoteHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
var platformUser = new PlatformUser(
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
PlatformKind.Telegram,
|
||||||
|
command.TelegramUserId.ToString(),
|
||||||
|
string.Empty,
|
||||||
|
null);
|
||||||
|
|
||||||
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
|
var platformGroup = new PlatformGroup(
|
||||||
"""
|
PlatformKind.Telegram,
|
||||||
SELECT rp.id AS Id,
|
command.ChatId.ToString(),
|
||||||
rp.session_id AS SessionId,
|
string.Empty);
|
||||||
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)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
|
|
||||||
{
|
|
||||||
await AnswerAsync(command.CallbackQueryId, "Дедлайн уже прошёл. Результаты скоро будут применены.", ct, showAlert: true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var playerId = await connection.ExecuteScalarAsync<Guid?>(
|
|
||||||
"""
|
|
||||||
SELECT p.id
|
|
||||||
FROM session_participants sp
|
|
||||||
JOIN players p ON p.id = sp.player_id
|
|
||||||
WHERE sp.session_id = @SessionId
|
|
||||||
AND p.platform = 'Telegram'
|
|
||||||
AND p.external_user_id = @ExternalUserId
|
|
||||||
AND sp.is_gm = false
|
|
||||||
AND sp.registration_status = @Active
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId, ExternalUserId = command.TelegramUserId.ToString(), Active = ParticipantRegistrationStatus.Active },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
if (playerId is null)
|
|
||||||
{
|
|
||||||
await AnswerAsync(command.CallbackQueryId, "Вы не являетесь участником этой сессии.", ct);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
|
||||||
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
|
|
||||||
VALUES (@ProposalId, @PlayerId, @OptionId)
|
|
||||||
ON CONFLICT (proposal_id, player_id) DO UPDATE
|
|
||||||
SET option_id = EXCLUDED.option_id,
|
|
||||||
voted_at = now()
|
|
||||||
""",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
ProposalId = proposal.Id,
|
|
||||||
PlayerId = playerId.Value,
|
|
||||||
command.OptionId
|
|
||||||
},
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
|
||||||
"""
|
|
||||||
SELECT p.id AS PlayerId,
|
|
||||||
p.display_name AS DisplayName,
|
|
||||||
p.external_username AS TelegramUsername,
|
|
||||||
p.external_user_id::BIGINT AS TelegramId
|
|
||||||
FROM session_participants sp
|
|
||||||
JOIN players p ON p.id = sp.player_id
|
|
||||||
WHERE sp.session_id = @SessionId
|
|
||||||
AND sp.is_gm = false
|
|
||||||
AND sp.registration_status = @Active
|
|
||||||
ORDER BY p.display_name
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
|
||||||
transaction)).ToList();
|
|
||||||
|
|
||||||
var options = (await connection.QueryAsync<RescheduleOptionDto>(
|
|
||||||
"""
|
|
||||||
SELECT id AS OptionId,
|
|
||||||
display_order AS DisplayOrder,
|
|
||||||
proposed_at AS ProposedAt
|
|
||||||
FROM reschedule_options
|
|
||||||
WHERE proposal_id = @ProposalId
|
|
||||||
ORDER BY display_order
|
|
||||||
""",
|
|
||||||
new { ProposalId = proposal.Id },
|
|
||||||
transaction)).ToList();
|
|
||||||
|
|
||||||
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
|
|
||||||
"""
|
|
||||||
SELECT rov.option_id AS OptionId,
|
|
||||||
p.id AS PlayerId,
|
|
||||||
p.display_name AS DisplayName,
|
|
||||||
p.external_username AS TelegramUsername
|
|
||||||
FROM reschedule_option_votes rov
|
|
||||||
JOIN players p ON p.id = rov.player_id
|
|
||||||
WHERE rov.proposal_id = @ProposalId
|
|
||||||
ORDER BY rov.voted_at, p.display_name
|
|
||||||
""",
|
|
||||||
new { ProposalId = proposal.Id },
|
|
||||||
transaction)).ToList();
|
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
|
||||||
|
|
||||||
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||||
proposal.Title,
|
result.Title!,
|
||||||
proposal.CurrentScheduledAt,
|
result.CurrentScheduledAt,
|
||||||
proposal.VotingDeadlineAt,
|
result.VotingDeadlineAt,
|
||||||
options,
|
result.Options,
|
||||||
participants,
|
result.Participants,
|
||||||
votes);
|
result.Votes);
|
||||||
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
|
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(result.Options);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -153,12 +70,11 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
|
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", result.ProposalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await AnswerAsync(command.CallbackQueryId, "Ваш голос учтён. До дедлайна его можно изменить.", ct);
|
await messenger.AnswerInteractionAsync(
|
||||||
|
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
|
||||||
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
|
|
||||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,68 @@ public sealed class TelegramPlatformMessenger(
|
|||||||
cancellationToken: ct);
|
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)
|
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
|
||||||
{
|
{
|
||||||
EnsureTelegram(message.Recipient.Platform);
|
EnsureTelegram(message.Recipient.Platform);
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ using GmRelay.Shared.Features.Sessions.CreateSession;
|
|||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Bot.Features.Sessions.ListSessions;
|
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.ExportCalendar;
|
||||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
@@ -20,7 +23,7 @@ namespace GmRelay.Bot.Infrastructure.Telegram;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UpdateRouter(
|
public sealed class UpdateRouter(
|
||||||
HandleRsvpHandler rsvpHandler,
|
HandleRsvpHandler rsvpHandler,
|
||||||
CreateSessionHandler createSessionHandler,
|
BotCreateSessionHandler createSessionHandler,
|
||||||
JoinSessionHandler joinSessionHandler,
|
JoinSessionHandler joinSessionHandler,
|
||||||
LeaveSessionHandler leaveSessionHandler,
|
LeaveSessionHandler leaveSessionHandler,
|
||||||
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
|
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
|
||||||
@@ -29,8 +32,8 @@ public sealed class UpdateRouter(
|
|||||||
ListSessionsHandler listSessionsHandler,
|
ListSessionsHandler listSessionsHandler,
|
||||||
ExportCalendarHandler exportCalendarHandler,
|
ExportCalendarHandler exportCalendarHandler,
|
||||||
InitiateRescheduleHandler initiateRescheduleHandler,
|
InitiateRescheduleHandler initiateRescheduleHandler,
|
||||||
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||||||
HandleRescheduleVoteHandler rescheduleVoteHandler,
|
BotRescheduleVoteHandler rescheduleVoteHandler,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
|
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
|
||||||
|
|||||||
@@ -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<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
|
||||||
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
||||||
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<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<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||||
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
|
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
|
||||||
builder.Services.AddSingleton<CancelSessionHandler>();
|
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.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.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<GmRelay.Bot.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
|
||||||
builder.Services.AddSingleton<InitiateRescheduleHandler>();
|
builder.Services.AddSingleton<InitiateRescheduleHandler>();
|
||||||
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
|
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
|
||||||
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
|
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<RescheduleVotingFinalizer>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ public sealed class DiscordNewSessionHandler(
|
|||||||
FROM group_managers gm
|
FROM group_managers gm
|
||||||
JOIN players p ON p.id = gm.player_id
|
JOIN players p ON p.id = gm.player_id
|
||||||
JOIN game_groups g ON g.id = gm.group_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 });
|
new { GuildId = guildId });
|
||||||
|
|
||||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||||
|
|||||||
@@ -1,114 +1,46 @@
|
|||||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
|
||||||
|
|
||||||
using Dapper;
|
|
||||||
using GmRelay.DiscordBot.Rendering;
|
using GmRelay.DiscordBot.Rendering;
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
using Npgsql;
|
|
||||||
using NetCord.Rest;
|
using NetCord.Rest;
|
||||||
|
|
||||||
|
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||||
|
|
||||||
public sealed record DiscordRescheduleVoteInput(
|
public sealed record DiscordRescheduleVoteInput(
|
||||||
Guid OptionId, ulong UserId, string InteractionId,
|
Guid OptionId,
|
||||||
string GuildId, string ChannelId, string MessageId);
|
ulong UserId,
|
||||||
|
string InteractionId,
|
||||||
|
string GuildId,
|
||||||
|
string ChannelId,
|
||||||
|
string MessageId);
|
||||||
|
|
||||||
public sealed class DiscordRescheduleVoteHandler(
|
public sealed class DiscordRescheduleVoteHandler(
|
||||||
NpgsqlDataSource dataSource,
|
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
|
||||||
RestClient restClient,
|
RestClient restClient,
|
||||||
ILogger<DiscordRescheduleVoteHandler> logger)
|
ILogger<DiscordRescheduleVoteHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task<string> HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct)
|
public async Task<string> HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
var command = new HandleRescheduleVoteCommand(
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
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 result = await sharedHandler.HandleAsync(command, 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 { input.OptionId },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
if (proposal is null)
|
if (!result.Success)
|
||||||
return "Голосование уже завершено или не найдено.";
|
{
|
||||||
|
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(
|
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
|
||||||
proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt,
|
result.Title!,
|
||||||
options, participants, votes);
|
result.CurrentScheduledAt,
|
||||||
|
result.VotingDeadlineAt,
|
||||||
|
result.Options,
|
||||||
|
result.Participants,
|
||||||
|
result.Votes);
|
||||||
|
|
||||||
var channelIdUlong = ulong.Parse(input.ChannelId);
|
var channelIdUlong = ulong.Parse(input.ChannelId);
|
||||||
var messageIdUlong = ulong.Parse(input.MessageId);
|
var messageIdUlong = ulong.Parse(input.MessageId);
|
||||||
@@ -123,9 +55,9 @@ public sealed class DiscordRescheduleVoteHandler(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id);
|
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", result.ProposalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return "Ваш голос учтён. До дедлайна его можно изменить.";
|
return result.ReplyText!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,38 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
|||||||
await restClient.SendMessageAsync(GetChannelId(group), htmlText);
|
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)
|
public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await SendDirectContentAsync(message.Recipient, message.HtmlText, ct);
|
await SendDirectContentAsync(message.Recipient, message.HtmlText, ct);
|
||||||
@@ -403,6 +435,30 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
|||||||
return ParseSnowflake(channelId);
|
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) =>
|
private static ulong ParseSnowflake(string value) =>
|
||||||
ulong.Parse(value, CultureInfo.InvariantCulture);
|
ulong.Parse(value, CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
|||||||
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
|
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
|
||||||
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
||||||
builder.Services.AddSingleton<DiscordRescheduleHandler>();
|
builder.Services.AddSingleton<DiscordRescheduleHandler>();
|
||||||
|
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
|
||||||
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
|
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
|
||||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed record CreateSessionCommand(
|
||||||
|
PlatformUser User,
|
||||||
|
PlatformGroup Group,
|
||||||
|
string Title,
|
||||||
|
string Link,
|
||||||
|
IReadOnlyList<DateTimeOffset> ScheduledTimes,
|
||||||
|
int? MaxPlayers,
|
||||||
|
string? ImageReference);
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
|
||||||
|
|
||||||
|
public sealed class CreateSessionHandler(
|
||||||
|
NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
public async Task<CreateSessionResult> HandleAsync(CreateSessionCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var platform = command.User.Platform.ToString();
|
||||||
|
var externalUserId = command.User.ExternalUserId;
|
||||||
|
var displayName = command.User.DisplayName;
|
||||||
|
var externalUsername = command.User.ExternalUsername;
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO players (display_name, platform, external_user_id, external_username)
|
||||||
|
VALUES (@Name, @Platform, @ExternalId, @Username)
|
||||||
|
ON CONFLICT (platform, external_user_id)
|
||||||
|
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
||||||
|
DO UPDATE
|
||||||
|
SET display_name = EXCLUDED.display_name,
|
||||||
|
external_username = EXCLUDED.external_username;
|
||||||
|
""",
|
||||||
|
new { ExternalId = externalUserId, Name = displayName, Username = externalUsername },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
||||||
|
"""
|
||||||
|
SELECT g.id AS GroupId,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
WHERE gm.group_id = g.id
|
||||||
|
AND p.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalGmId
|
||||||
|
) AS CanManage
|
||||||
|
FROM game_groups g
|
||||||
|
WHERE g.platform = @Platform
|
||||||
|
AND g.external_group_id = @ExternalGroupId
|
||||||
|
""",
|
||||||
|
new { Platform = platform, ExternalGroupId = command.Group.ExternalGroupId, ExternalGmId = externalUserId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
Guid groupId;
|
||||||
|
if (existingGroup is null)
|
||||||
|
{
|
||||||
|
groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
|
"""
|
||||||
|
INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
|
||||||
|
VALUES (@ChatName, @Platform, @ExternalGroupId, @ExternalChannelId)
|
||||||
|
RETURNING id;
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Platform = platform,
|
||||||
|
ExternalGroupId = command.Group.ExternalGroupId,
|
||||||
|
ExternalChannelId = command.Group.ExternalChannelId,
|
||||||
|
ChatName = command.Group.DisplayName
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO group_managers (group_id, player_id, role)
|
||||||
|
SELECT @GroupId, p.id, @OwnerRole
|
||||||
|
FROM players p
|
||||||
|
WHERE p.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalGmId
|
||||||
|
ON CONFLICT (group_id, player_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
new { GroupId = groupId, ExternalGmId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!existingGroup.CanManage)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
return new CreateSessionResult(
|
||||||
|
false,
|
||||||
|
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
Array.Empty<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
groupId = existingGroup.GroupId;
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE game_groups
|
||||||
|
SET name = @ChatName
|
||||||
|
WHERE id = @GroupId
|
||||||
|
""",
|
||||||
|
new { ChatName = command.Group.DisplayName, GroupId = groupId },
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
var batchId = Guid.NewGuid();
|
||||||
|
var sessions = new List<SessionBatchDto>();
|
||||||
|
var orderedTimes = command.ScheduledTimes.OrderBy(v => v).ToList();
|
||||||
|
|
||||||
|
foreach (var scheduledAt in orderedTimes)
|
||||||
|
{
|
||||||
|
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
|
"""
|
||||||
|
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
|
||||||
|
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
|
||||||
|
RETURNING id;
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
BatchId = batchId,
|
||||||
|
GroupId = groupId,
|
||||||
|
command.Title,
|
||||||
|
Link = command.Link,
|
||||||
|
ScheduledAt = scheduledAt,
|
||||||
|
Status = SessionStatus.Planned,
|
||||||
|
MaxPlayers = command.MaxPlayers
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, command.MaxPlayers, command.Link));
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||||
|
|
||||||
|
return new CreateSessionResult(
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
view,
|
||||||
|
batchId,
|
||||||
|
groupId,
|
||||||
|
Array.Empty<string>());
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed record CreateSessionResult(
|
||||||
|
bool Success,
|
||||||
|
string? ErrorMessage,
|
||||||
|
SessionBatchViewModel? View,
|
||||||
|
Guid? BatchId,
|
||||||
|
Guid? GroupId,
|
||||||
|
IReadOnlyList<string> Warnings);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
|
||||||
|
|
||||||
|
public sealed record ExportCalendarCommand(
|
||||||
|
PlatformGroup Group,
|
||||||
|
PlatformUser User);
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
|
||||||
|
|
||||||
|
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
||||||
|
|
||||||
|
public sealed class ExportCalendarHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
IPlatformMessenger messenger,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(ExportCalendarCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
|
||||||
|
var sessions = await connection.QueryAsync<CalendarSessionDto>(
|
||||||
|
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
|
||||||
|
+ " FROM sessions s"
|
||||||
|
+ " JOIN game_groups g ON s.group_id = g.id"
|
||||||
|
+ " WHERE g.platform = @Platform"
|
||||||
|
+ " AND g.external_group_id = @ExternalGroupId"
|
||||||
|
+ " AND s.status = @Planned"
|
||||||
|
+ " AND s.scheduled_at > NOW()"
|
||||||
|
+ " ORDER BY s.scheduled_at ASC",
|
||||||
|
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId, Planned = SessionStatus.Planned });
|
||||||
|
|
||||||
|
var sessionsList = sessions.ToList();
|
||||||
|
|
||||||
|
if (sessionsList.Count == 0)
|
||||||
|
{
|
||||||
|
await messenger.SendGroupMessageAsync(
|
||||||
|
command.Group,
|
||||||
|
"📭 У этой группы нет запланированных сессий для экспорта.",
|
||||||
|
cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("BEGIN:VCALENDAR");
|
||||||
|
sb.AppendLine("VERSION:2.0");
|
||||||
|
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
|
||||||
|
|
||||||
|
foreach (var s in sessionsList)
|
||||||
|
{
|
||||||
|
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
|
||||||
|
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
|
||||||
|
|
||||||
|
sb.AppendLine("BEGIN:VEVENT");
|
||||||
|
sb.AppendLine($"UID:{s.Id}@gmrelay");
|
||||||
|
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
|
||||||
|
sb.AppendLine($"DTSTART:{dtStart}");
|
||||||
|
sb.AppendLine($"DTEND:{dtEnd}");
|
||||||
|
sb.AppendLine($"SUMMARY:{s.Title}");
|
||||||
|
sb.AppendLine("END:VEVENT");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine("END:VCALENDAR");
|
||||||
|
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||||
|
|
||||||
|
// Create calendar subscription
|
||||||
|
string? subscriptionUrl = null;
|
||||||
|
var baseUrl = configuration["Web:BaseUrl"];
|
||||||
|
var senderId = command.User.ExternalUserId;
|
||||||
|
if (!string.IsNullOrWhiteSpace(baseUrl) && !string.IsNullOrWhiteSpace(senderId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = Guid.NewGuid().ToString("N");
|
||||||
|
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
|
||||||
|
@"SELECT id FROM game_groups WHERE platform = @Platform AND external_group_id = @ExternalGroupId",
|
||||||
|
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId });
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
@"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at)
|
||||||
|
VALUES (gen_random_uuid(), @token, @userPlatform, @userExternalId, @groupId, @filterType, now(), NULL)",
|
||||||
|
new { token, userPlatform = command.Group.Platform.ToString(), userExternalId = senderId, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
|
||||||
|
|
||||||
|
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Non-critical: if subscription creation fails, still send the file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var actions = subscriptionUrl is not null
|
||||||
|
? new[]
|
||||||
|
{
|
||||||
|
new PlatformMessageAction(
|
||||||
|
"calendar-subscription",
|
||||||
|
"🔗 Подписаться на календарь",
|
||||||
|
subscriptionUrl)
|
||||||
|
}
|
||||||
|
: Array.Empty<PlatformMessageAction>();
|
||||||
|
|
||||||
|
await messenger.SendCalendarFileAsync(
|
||||||
|
new PlatformCalendarFile(
|
||||||
|
command.Group,
|
||||||
|
"schedule.ics",
|
||||||
|
bytes,
|
||||||
|
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
||||||
|
actions),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
|
internal sealed record DeleteSessionInfoDto(
|
||||||
|
string Title,
|
||||||
|
Guid BatchId,
|
||||||
|
Guid GroupId,
|
||||||
|
bool CanManage,
|
||||||
|
int? ThreadId,
|
||||||
|
bool TopicCreatedByBot);
|
||||||
|
|
||||||
|
public sealed record DeleteSessionResult(
|
||||||
|
bool Success,
|
||||||
|
string? ReplyText,
|
||||||
|
string? Title,
|
||||||
|
Guid? GroupId,
|
||||||
|
int? ThreadId,
|
||||||
|
bool TopicCreatedByBot,
|
||||||
|
int RemainingInTopic);
|
||||||
|
|
||||||
|
public sealed class DeleteSessionHandler(
|
||||||
|
NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
public async Task<DeleteSessionResult> HandleAsync(DeleteSessionCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
// 1. Fetch session and verify group manager.
|
||||||
|
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
|
||||||
|
"""
|
||||||
|
SELECT s.title AS Title,
|
||||||
|
s.batch_id AS BatchId,
|
||||||
|
s.group_id AS GroupId,
|
||||||
|
s.thread_id AS ThreadId,
|
||||||
|
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
WHERE gm.group_id = s.group_id
|
||||||
|
AND p.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalUserId
|
||||||
|
) AS CanManage
|
||||||
|
FROM sessions s
|
||||||
|
WHERE s.id = @SessionId
|
||||||
|
""",
|
||||||
|
new { command.SessionId, Platform = command.User.Platform.ToString(), ExternalUserId = command.User.ExternalUserId }, transaction);
|
||||||
|
|
||||||
|
if (session == null)
|
||||||
|
{
|
||||||
|
return new DeleteSessionResult(false, "Сессия не найдена.", null, null, null, false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.CanManage)
|
||||||
|
{
|
||||||
|
return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete session
|
||||||
|
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
||||||
|
|
||||||
|
var remainingInTopic = session.ThreadId.HasValue
|
||||||
|
? await connection.ExecuteScalarAsync<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM sessions
|
||||||
|
WHERE group_id = @GroupId
|
||||||
|
AND thread_id = @ThreadId
|
||||||
|
""",
|
||||||
|
new { session.GroupId, ThreadId = session.ThreadId.Value },
|
||||||
|
transaction)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
return new DeleteSessionResult(
|
||||||
|
true,
|
||||||
|
"Сессия удалена!",
|
||||||
|
session.Title,
|
||||||
|
session.GroupId,
|
||||||
|
session.ThreadId,
|
||||||
|
session.TopicCreatedByBot,
|
||||||
|
remainingInTopic);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
|
public sealed record ListSessionsCommand(
|
||||||
|
PlatformGroup Group,
|
||||||
|
PlatformUser User);
|
||||||
|
|
||||||
|
public sealed record DeleteSessionCommand(
|
||||||
|
Guid SessionId,
|
||||||
|
PlatformUser User,
|
||||||
|
PlatformGroup Group,
|
||||||
|
PlatformMessageRef ScheduleMessage);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
|
public sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
|
||||||
|
|
||||||
|
public sealed record SessionListResult(
|
||||||
|
IReadOnlyList<SessionListItemDto> Sessions,
|
||||||
|
bool CanManage);
|
||||||
|
|
||||||
|
public sealed class ListSessionsHandler(
|
||||||
|
NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
public async Task<SessionListResult> HandleAsync(ListSessionsCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
|
||||||
|
var sessions = await connection.QueryAsync<SessionListItemDto>(
|
||||||
|
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
|
||||||
|
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
||||||
|
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||||
|
WHERE gm.group_id = s.group_id
|
||||||
|
AND manager_player.platform = @Platform
|
||||||
|
AND manager_player.external_user_id = @ExternalUserId
|
||||||
|
) AS CanManage
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON s.group_id = g.id
|
||||||
|
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||||
|
WHERE g.platform = @Platform
|
||||||
|
AND g.external_group_id = @ExternalGroupId
|
||||||
|
AND s.status != @Cancelled
|
||||||
|
AND s.scheduled_at > NOW()
|
||||||
|
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
|
||||||
|
ORDER BY s.scheduled_at ASC",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Platform = command.Group.Platform.ToString(),
|
||||||
|
ExternalGroupId = command.Group.ExternalGroupId,
|
||||||
|
ExternalUserId = command.User.ExternalUserId,
|
||||||
|
Cancelled = SessionStatus.Cancelled,
|
||||||
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
|
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||||
|
});
|
||||||
|
|
||||||
|
var sessionsList = sessions.ToList();
|
||||||
|
var canManage = sessionsList.Count > 0 && sessionsList.First().CanManage;
|
||||||
|
|
||||||
|
return new SessionListResult(sessionsList, canManage);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
internal sealed record AwaitingProposalDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid SessionId,
|
||||||
|
string Title,
|
||||||
|
DateTime CurrentScheduledAt,
|
||||||
|
Guid BatchId,
|
||||||
|
int? BatchMessageId,
|
||||||
|
string ExternalGroupId,
|
||||||
|
int? ThreadId,
|
||||||
|
string NotificationMode);
|
||||||
+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);
|
||||||
@@ -2,7 +2,23 @@ namespace GmRelay.Shared.Platform;
|
|||||||
|
|
||||||
public interface IPlatformMessenger
|
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);
|
Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v3.1.1</div>
|
<div class="nav-version">v3.3.0</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<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>
|
</span>
|
||||||
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
|
@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())">
|
<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 == manager.ExternalUserId ? "⏳ Удаляем..." : "Убрать")
|
@(removingCoGmId == ManagerKey(manager) ? "⏳ Удаляем..." : "Убрать")
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,8 +52,8 @@
|
|||||||
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
|
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
|
||||||
<div class="batch-bulk-fields">
|
<div class="batch-bulk-fields">
|
||||||
<div class="gm-form-group">
|
<div class="gm-form-group">
|
||||||
<label class="gm-form-label">Telegram ID co-GM</label>
|
<label class="gm-form-label">@CoGmIdLabel</label>
|
||||||
<InputNumber @bind-Value="coGmModel.TelegramId" class="gm-form-control" min="1" />
|
<InputText @bind-Value="coGmModel.ExternalUserId" class="gm-form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="gm-form-group">
|
<div class="gm-form-group">
|
||||||
<label class="gm-form-label">Имя</label>
|
<label class="gm-form-label">Имя</label>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="gm-form-group">
|
<div class="gm-form-group">
|
||||||
<label class="gm-form-label">Username</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
|
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
|
||||||
@@ -72,6 +72,58 @@
|
|||||||
</div>
|
</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))
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
{
|
{
|
||||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||||
@@ -201,6 +253,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</EditForm>
|
</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">
|
<div class="batch-clone-row">
|
||||||
<select @bind="batch.CloneInterval" class="gm-form-control">
|
<select @bind="batch.CloneInterval" class="gm-form-control">
|
||||||
<option value="week">Следующая неделя</option>
|
<option value="week">Следующая неделя</option>
|
||||||
@@ -249,6 +312,16 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="session-table-actions">
|
<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 href="/session/edit/@session.Id" class="btn-gm btn-gm-outline">
|
||||||
✏️ Изменить
|
✏️ Изменить
|
||||||
</a>
|
</a>
|
||||||
@@ -337,6 +410,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-card-actions">
|
<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 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>
|
</a>
|
||||||
@@ -398,17 +480,23 @@
|
|||||||
private List<WebSession>? sessions;
|
private List<WebSession>? sessions;
|
||||||
private List<WebCampaignTemplate>? campaignTemplates;
|
private List<WebCampaignTemplate>? campaignTemplates;
|
||||||
private WebGroupManagement? groupManagement;
|
private WebGroupManagement? groupManagement;
|
||||||
|
private WebPublicGroupSettings? publicSettings;
|
||||||
private List<BatchBulkEditModel> batchModels = [];
|
private List<BatchBulkEditModel> batchModels = [];
|
||||||
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
||||||
private Guid? promotingSessionId;
|
private Guid? promotingSessionId;
|
||||||
private Guid? processingBatchId;
|
private Guid? processingBatchId;
|
||||||
private Guid? processingTemplateId;
|
private Guid? processingTemplateId;
|
||||||
|
private Guid? publishingBatchId;
|
||||||
|
private Guid? publishingSessionId;
|
||||||
private string? removingCoGmId;
|
private string? removingCoGmId;
|
||||||
private bool isAddingCoGm;
|
private bool isAddingCoGm;
|
||||||
|
private bool savingPublicSettings;
|
||||||
|
private string? currentPlatform;
|
||||||
private string? externalUserId;
|
private string? externalUserId;
|
||||||
private string? errorMessage;
|
private string? errorMessage;
|
||||||
private string? successMessage;
|
private string? successMessage;
|
||||||
private CoGmEditModel coGmModel = new();
|
private CoGmEditModel coGmModel = new();
|
||||||
|
private PublicSettingsEditModel publicSettingsModel = new();
|
||||||
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
|
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
|
||||||
private HashSet<Guid> expandedSessions = new();
|
private HashSet<Guid> expandedSessions = new();
|
||||||
private Guid? kickingParticipantId;
|
private Guid? kickingParticipantId;
|
||||||
@@ -423,6 +511,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentPlatform = platform;
|
||||||
await LoadSessions();
|
await LoadSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +531,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
publicSettings = await SessionService.GetPublicGroupSettingsForCurrentUserAsync(GroupId);
|
||||||
|
if (publicSettings is null)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
|
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
|
||||||
if (campaignTemplates is null)
|
if (campaignTemplates is null)
|
||||||
{
|
{
|
||||||
@@ -451,6 +547,92 @@
|
|||||||
|
|
||||||
RebuildBatchModels();
|
RebuildBatchModels();
|
||||||
RebuildCampaignTemplateModels();
|
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()
|
private async Task AddCoGm()
|
||||||
@@ -458,9 +640,16 @@
|
|||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
successMessage = 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,10 +659,10 @@
|
|||||||
{
|
{
|
||||||
await SessionService.AddCoGmForOwnerAsync(
|
await SessionService.AddCoGmForOwnerAsync(
|
||||||
GroupId,
|
GroupId,
|
||||||
"Telegram",
|
CoGmPlatform,
|
||||||
coGmModel.TelegramId.Value.ToString(),
|
coGmExternalUserId,
|
||||||
coGmModel.DisplayName,
|
coGmModel.DisplayName,
|
||||||
coGmModel.TelegramUsername);
|
coGmModel.ExternalUsername);
|
||||||
|
|
||||||
coGmModel = new();
|
coGmModel = new();
|
||||||
successMessage = "Co-GM добавлен.";
|
successMessage = "Co-GM добавлен.";
|
||||||
@@ -493,15 +682,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RemoveCoGm(string coGmExternalUserId)
|
private async Task RemoveCoGm(WebGroupManager manager)
|
||||||
{
|
{
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
successMessage = null;
|
successMessage = null;
|
||||||
removingCoGmId = coGmExternalUserId;
|
removingCoGmId = ManagerKey(manager);
|
||||||
|
var platform = ManagerPlatform(manager);
|
||||||
|
var coGmExternalUserId = ManagerExternalUserId(manager);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SessionService.RemoveCoGmForOwnerAsync(GroupId, "Telegram", coGmExternalUserId);
|
await SessionService.RemoveCoGmForOwnerAsync(GroupId, platform, coGmExternalUserId);
|
||||||
successMessage = "Co-GM удалён.";
|
successMessage = "Co-GM удалён.";
|
||||||
await LoadSessions();
|
await LoadSessions();
|
||||||
}
|
}
|
||||||
@@ -795,7 +986,9 @@
|
|||||||
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
|
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
|
||||||
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
|
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
|
||||||
IntervalDays = InferIntervalDays(orderedSessions),
|
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)
|
.OrderBy(batch => batch.FirstScheduledAtLocal)
|
||||||
@@ -823,6 +1016,20 @@
|
|||||||
.ToList() ?? [];
|
.ToList() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RebuildPublicSettingsModel()
|
||||||
|
{
|
||||||
|
if (publicSettings is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
publicSettingsModel = new PublicSettingsEditModel
|
||||||
|
{
|
||||||
|
PublicScheduleEnabled = publicSettings.PublicScheduleEnabled,
|
||||||
|
PublicSlug = publicSettings.PublicSlug ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
|
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
|
||||||
{
|
{
|
||||||
batch.Title = batch.Title.Trim();
|
batch.Title = batch.Title.Trim();
|
||||||
@@ -832,24 +1039,76 @@
|
|||||||
|
|
||||||
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
|
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 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 =>
|
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;
|
?? GroupManagerRoleExtensions.CoGmValue;
|
||||||
|
|
||||||
private static string FormatRole(string role) =>
|
private static string FormatRole(string role) =>
|
||||||
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
||||||
|
|
||||||
private static string FormatManager(WebGroupManager manager)
|
private string FormatManager(WebGroupManager manager)
|
||||||
{
|
{
|
||||||
var username = string.IsNullOrWhiteSpace(manager.TelegramUsername)
|
var username = string.IsNullOrWhiteSpace(manager.ExternalUsername)
|
||||||
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
? manager.TelegramUsername
|
||||||
: "@" + 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)
|
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
|
||||||
{
|
{
|
||||||
if (orderedSessions.Count < 2)
|
if (orderedSessions.Count < 2)
|
||||||
@@ -926,6 +1185,8 @@
|
|||||||
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
|
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
|
||||||
public int IntervalDays { get; set; } = 7;
|
public int IntervalDays { get; set; } = 7;
|
||||||
public int SessionCount { get; init; }
|
public int SessionCount { get; init; }
|
||||||
|
public int PublicSessionCount { get; init; }
|
||||||
|
public bool AllSessionsPublic { get; init; }
|
||||||
public string CloneInterval { get; set; } = "week";
|
public string CloneInterval { get; set; } = "week";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -944,8 +1205,14 @@
|
|||||||
|
|
||||||
private sealed class CoGmEditModel
|
private sealed class CoGmEditModel
|
||||||
{
|
{
|
||||||
public long? TelegramId { get; set; }
|
public string ExternalUserId { get; set; } = "";
|
||||||
public string DisplayName { 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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Security.Claims;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
namespace GmRelay.Web.Services;
|
namespace GmRelay.Web.Services;
|
||||||
@@ -54,6 +55,71 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
|||||||
return await sessionStore.GetUpcomingSessionsAsync(groupId);
|
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)
|
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
|
||||||
{
|
{
|
||||||
var identity = GetCurrentIdentity();
|
var identity = GetCurrentIdentity();
|
||||||
@@ -390,4 +456,20 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
|||||||
JoinLink = joinLink
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,41 @@ public sealed record SessionAuditLogEntry(
|
|||||||
string? NewValue,
|
string? NewValue,
|
||||||
DateTime ChangedAt);
|
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
|
public interface ISessionStore
|
||||||
{
|
{
|
||||||
Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId);
|
Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId);
|
||||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
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> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
|
||||||
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
|
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
|
||||||
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public sealed record WebGameGroup(
|
|||||||
public sealed record WebGroupManager(
|
public sealed record WebGroupManager(
|
||||||
long TelegramId,
|
long TelegramId,
|
||||||
string? ExternalUserId,
|
string? ExternalUserId,
|
||||||
|
string? Platform,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string? TelegramUsername,
|
string? TelegramUsername,
|
||||||
string? ExternalUsername,
|
string? ExternalUsername,
|
||||||
@@ -35,7 +36,17 @@ public sealed record WebGroupManager(
|
|||||||
DateTime AddedAt)
|
DateTime AddedAt)
|
||||||
{
|
{
|
||||||
public WebGroupManager(long telegramId, string displayName, string? telegramUsername, string role, 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(
|
public sealed record WebGroupManagement(
|
||||||
@@ -56,7 +67,8 @@ public sealed record WebSession(
|
|||||||
int ActivePlayerCount,
|
int ActivePlayerCount,
|
||||||
int WaitlistedPlayerCount,
|
int WaitlistedPlayerCount,
|
||||||
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
|
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
|
||||||
int? ThreadId = null);
|
int? ThreadId = null,
|
||||||
|
bool IsPublic = false);
|
||||||
|
|
||||||
public sealed record WebParticipant(
|
public sealed record WebParticipant(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -97,6 +109,7 @@ internal sealed record WebBatchSessionRow(
|
|||||||
bool TopicCreatedByBot = false);
|
bool TopicCreatedByBot = false);
|
||||||
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
||||||
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
||||||
|
internal sealed record WebPublicGroupRow(Guid GroupId, string Name, string Slug);
|
||||||
|
|
||||||
public sealed class SessionService(
|
public sealed class SessionService(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
@@ -171,6 +184,184 @@ public sealed class SessionService(
|
|||||||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
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)
|
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
@@ -215,8 +406,13 @@ public sealed class SessionService(
|
|||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
return (await conn.QueryAsync<WebGroupManager>(
|
return (await conn.QueryAsync<WebGroupManager>(
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(p.external_user_id::BIGINT, 0) AS TelegramId,
|
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.external_user_id AS ExternalUserId,
|
||||||
|
p.platform AS Platform,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.external_username AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
p.external_username AS ExternalUsername,
|
p.external_username AS ExternalUsername,
|
||||||
@@ -363,7 +559,8 @@ public sealed class SessionService(
|
|||||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
s.thread_id AS ThreadId
|
s.thread_id AS ThreadId,
|
||||||
|
s.is_public AS IsPublic
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -401,7 +598,8 @@ public sealed class SessionService(
|
|||||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
s.thread_id AS ThreadId
|
s.thread_id AS ThreadId,
|
||||||
|
s.is_public AS IsPublic
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -460,7 +658,8 @@ public sealed class SessionService(
|
|||||||
0 AS ActivePlayerCount,
|
0 AS ActivePlayerCount,
|
||||||
0 AS WaitlistedPlayerCount,
|
0 AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
s.thread_id AS ThreadId
|
s.thread_id AS ThreadId,
|
||||||
|
s.is_public AS IsPublic
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @Id AND s.group_id = @GroupId",
|
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||||||
@@ -546,7 +745,8 @@ public sealed class SessionService(
|
|||||||
0 AS ActivePlayerCount,
|
0 AS ActivePlayerCount,
|
||||||
0 AS WaitlistedPlayerCount,
|
0 AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
s.thread_id AS ThreadId
|
s.thread_id AS ThreadId,
|
||||||
|
s.is_public AS IsPublic
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||||
@@ -672,7 +872,8 @@ public sealed class SessionService(
|
|||||||
0 AS ActivePlayerCount,
|
0 AS ActivePlayerCount,
|
||||||
0 AS WaitlistedPlayerCount,
|
0 AS WaitlistedPlayerCount,
|
||||||
s.notification_mode AS NotificationMode,
|
s.notification_mode AS NotificationMode,
|
||||||
s.thread_id AS ThreadId
|
s.thread_id AS ThreadId,
|
||||||
|
s.is_public AS IsPublic
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||||
@@ -1380,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(
|
private static async Task<WebBatchInfo?> GetBatchInfoAsync(
|
||||||
Npgsql.NpgsqlConnection conn,
|
Npgsql.NpgsqlConnection conn,
|
||||||
Guid batchId,
|
Guid batchId,
|
||||||
|
|||||||
@@ -785,6 +785,62 @@ select option {
|
|||||||
white-space: nowrap;
|
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 templates === */
|
||||||
.campaign-template-panel {
|
.campaign-template-panel {
|
||||||
margin-bottom: 1.5rem;
|
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 === */
|
/* === Discord Login Button === */
|
||||||
.login-btn-discord {
|
.login-btn-discord {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -115,6 +115,18 @@ public sealed class DiscordNewSessionHandlerTests
|
|||||||
Assert.Contains("UnauthorizedAccessException", source, StringComparison.Ordinal);
|
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]
|
[Fact]
|
||||||
public void Handler_ShouldBePlatformNeutral()
|
public void Handler_ShouldBePlatformNeutral()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public sealed class DiscordProjectStructureTests
|
|||||||
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
||||||
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
||||||
|
|
||||||
Assert.Contains("gmrelay-discord-bot:3.1.1", 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("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
||||||
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
||||||
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
||||||
@@ -76,13 +76,13 @@ public sealed class DiscordProjectStructureTests
|
|||||||
{
|
{
|
||||||
var repoRoot = GetRepoRoot();
|
var repoRoot = GetRepoRoot();
|
||||||
|
|
||||||
Assert.Contains("<Version>3.1.1</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
Assert.Contains("<Version>3.3.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||||
Assert.Contains("VERSION: 3.1.1", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
Assert.Contains("VERSION: 3.3.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||||
Assert.Contains("gmrelay-bot:3.1.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
Assert.Contains("gmrelay-bot:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||||
Assert.Contains("gmrelay-web:3.1.1", 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.1.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
Assert.Contains("gmrelay-discord-bot:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
"v3.1.1",
|
"v3.3.0",
|
||||||
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
|
||||||
using GmRelay.DiscordBot.Rendering;
|
using GmRelay.DiscordBot.Rendering;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
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 deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero);
|
||||||
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
|
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
|
||||||
var voteMessage = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
var voteMessage = BotRescheduleHandler.BuildVotingMessage(
|
||||||
scenario.Title,
|
scenario.Title,
|
||||||
scenario.Sessions[0].ScheduledAt,
|
scenario.Sessions[0].ScheduledAt,
|
||||||
deadline,
|
deadline,
|
||||||
options,
|
options,
|
||||||
voteParticipants,
|
voteParticipants,
|
||||||
[]);
|
[]);
|
||||||
var voteKeyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
|
var voteKeyboard = BotRescheduleHandler.BuildVotingKeyboard(options);
|
||||||
|
|
||||||
Assert.Contains("Landing Promise Smoke", voteMessage);
|
Assert.Contains("Landing Promise Smoke", voteMessage);
|
||||||
Assert.Contains("0/2", voteMessage);
|
Assert.Contains("0/2", voteMessage);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
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.Domain;
|
||||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
using GmRelay.Shared.Rendering;
|
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 deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero);
|
||||||
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
|
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
|
||||||
var voteMessage = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
var voteMessage = BotRescheduleHandler.BuildVotingMessage(
|
||||||
scenario.Title,
|
scenario.Title,
|
||||||
scenario.Sessions[0].ScheduledAt,
|
scenario.Sessions[0].ScheduledAt,
|
||||||
deadline,
|
deadline,
|
||||||
options,
|
options,
|
||||||
voteParticipants,
|
voteParticipants,
|
||||||
[]);
|
[]);
|
||||||
var voteKeyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
|
var voteKeyboard = BotRescheduleHandler.BuildVotingKeyboard(options);
|
||||||
|
|
||||||
Assert.Contains("Landing Promise Smoke", voteMessage);
|
Assert.Contains("Landing Promise Smoke", voteMessage);
|
||||||
Assert.Contains("0/2", 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}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.Bot.Features.Sessions.ListSessions;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Features.Sessions.ListSessions;
|
namespace GmRelay.Bot.Tests.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
@@ -22,17 +23,15 @@ public sealed class SessionListMessageRendererTests
|
|||||||
true)
|
true)
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = SessionListMessageRenderer.Render(sessions);
|
var text = SessionListMessageRenderer.RenderText(sessions);
|
||||||
Assert.NotNull(result.Markup);
|
var actions = SessionListMessageRenderer.RenderActions(sessions);
|
||||||
var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList();
|
|
||||||
|
|
||||||
Assert.Contains("Ravenloft", result.Text);
|
Assert.Contains("Ravenloft", text);
|
||||||
Assert.Collection(
|
Assert.Equal(4, actions.Count);
|
||||||
buttons.Select(button => button.CallbackData),
|
Assert.Contains(actions, a => a.Payload == $"cancel_session:{sessionId}");
|
||||||
callbackData => Assert.Equal($"cancel_session:{sessionId}", callbackData),
|
Assert.Contains(actions, a => a.Payload == $"reschedule_session:{sessionId}");
|
||||||
callbackData => Assert.Equal($"reschedule_session:{sessionId}", callbackData),
|
Assert.Contains(actions, a => a.Payload == $"promote_waitlist:{sessionId}");
|
||||||
callbackData => Assert.Equal($"promote_waitlist:{sessionId}", callbackData),
|
Assert.Contains(actions, a => a.Payload == $"delete_session:{sessionId}");
|
||||||
callbackData => Assert.Equal($"delete_session:{sessionId}", callbackData));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -51,8 +50,7 @@ public sealed class SessionListMessageRendererTests
|
|||||||
false)
|
false)
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = SessionListMessageRenderer.Render(sessions);
|
var actions = SessionListMessageRenderer.RenderActions(sessions);
|
||||||
|
Assert.Empty(actions);
|
||||||
Assert.Null(result.Markup);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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;
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
|
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
|
||||||
@@ -72,7 +72,7 @@ public sealed class HandleRescheduleTimeInputHandlerTests
|
|||||||
new(secondOptionId, bobId, "Bob", null)
|
new(secondOptionId, bobId, "Bob", null)
|
||||||
};
|
};
|
||||||
|
|
||||||
var text = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
var text = BotHandler.BuildVotingMessage(
|
||||||
"Shadowrun",
|
"Shadowrun",
|
||||||
currentTime,
|
currentTime,
|
||||||
deadline,
|
deadline,
|
||||||
@@ -101,7 +101,7 @@ public sealed class HandleRescheduleTimeInputHandlerTests
|
|||||||
new(secondOptionId, 2, new DateTimeOffset(2026, 4, 27, 17, 0, 0, TimeSpan.Zero))
|
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();
|
var buttons = keyboard.InlineKeyboard.SelectMany(row => row).ToList();
|
||||||
|
|
||||||
Assert.Collection(
|
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]
|
[Fact]
|
||||||
public async Task Code_ShouldQueryPlayersUsingExternalUserIdFallback()
|
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);
|
Assert.Contains("external_user_id", createHandler, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ public sealed class PlatformIdentityMigrationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Code_ShouldQueryGroupsUsingExternalGroupIdFallback()
|
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);
|
Assert.Contains("external_group_id", createHandler, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|||||||
+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/HandleRescheduleTimeInputHandler.cs")]
|
||||||
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleVoteHandler.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/RescheduleSession/RescheduleVotingDeadlineService.cs")]
|
||||||
[InlineData("src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs")]
|
[InlineData("src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs", "src/GmRelay.Shared/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs")]
|
||||||
public async Task SessionFlows_ShouldUsePlatformMessengerForOutboundTelegramWork(string relativePath)
|
public async Task SessionFlows_ShouldUsePlatformMessengerForOutboundTelegramWork(string relativePath, string? sharedPath = null)
|
||||||
{
|
{
|
||||||
var source = await ReadRepositoryFileAsync(relativePath);
|
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("BatchMessageEditor.EditBatchMessageAsync", source, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain(".AnswerCallbackQuery(", source, StringComparison.Ordinal);
|
Assert.DoesNotContain(".AnswerCallbackQuery(", source, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -12,11 +12,11 @@ public sealed class TelegramTopicIntegrationSmokeTests
|
|||||||
Assert.Contains("topic_created_by_bot", migration, StringComparison.Ordinal);
|
Assert.Contains("topic_created_by_bot", migration, StringComparison.Ordinal);
|
||||||
Assert.Contains("ResolveNewScheduleDestination", createHandler, StringComparison.Ordinal);
|
Assert.Contains("ResolveNewScheduleDestination", createHandler, StringComparison.Ordinal);
|
||||||
Assert.Contains("message.MessageThreadId", 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("MissingForumTopicRightsMessage", createHandler, StringComparison.Ordinal);
|
||||||
Assert.Contains("TopicCreatedByBot", deleteHandler, StringComparison.Ordinal);
|
Assert.Contains("TopicCreatedByBot", deleteHandler, StringComparison.Ordinal);
|
||||||
Assert.Contains("ShouldDeleteForumTopic", deleteHandler, StringComparison.Ordinal);
|
Assert.Contains("ShouldDeleteForumTopic", deleteHandler, StringComparison.Ordinal);
|
||||||
Assert.Contains("remainingInTopic", deleteHandler, StringComparison.Ordinal);
|
Assert.Contains("RemainingInTopic", deleteHandler, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -28,6 +28,7 @@ public sealed class TelegramTopicIntegrationSmokeTests
|
|||||||
var cancelHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CancelSessionHandler.cs");
|
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 initiateRescheduleHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/InitiateRescheduleHandler.cs");
|
||||||
var rescheduleInputHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.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 rescheduleDeadlineService = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs");
|
||||||
var telegramMessenger = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramPlatformMessenger.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("int? MessageThreadId", initiateRescheduleHandler, StringComparison.Ordinal);
|
||||||
Assert.Contains("TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId)", initiateRescheduleHandler, StringComparison.Ordinal);
|
Assert.Contains("TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId)", initiateRescheduleHandler, StringComparison.Ordinal);
|
||||||
|
|
||||||
Assert.Contains("int? ThreadId", rescheduleInputHandler, StringComparison.Ordinal);
|
Assert.Contains("message.MessageThreadId", rescheduleInputHandler, StringComparison.Ordinal);
|
||||||
Assert.Contains("s.thread_id AS ThreadId", rescheduleInputHandler, StringComparison.Ordinal);
|
Assert.Contains("s.thread_id AS ThreadId", sharedRescheduleInputHandler, StringComparison.Ordinal);
|
||||||
Assert.Contains("TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId)", rescheduleInputHandler, StringComparison.Ordinal);
|
Assert.Contains("TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId", rescheduleInputHandler, StringComparison.Ordinal);
|
||||||
|
|
||||||
Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
||||||
Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
|
||||||
|
|||||||
@@ -786,6 +786,9 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
public bool CreateBatchFromTemplateCalled { get; private set; }
|
public bool CreateBatchFromTemplateCalled { get; private set; }
|
||||||
public bool AddCoGmCalled { get; private set; }
|
public bool AddCoGmCalled { get; private set; }
|
||||||
public bool RemoveCoGmCalled { 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? LastUpdatedSessionId { get; private set; }
|
||||||
public Guid? LastUpdatedGroupId { get; private set; }
|
public Guid? LastUpdatedGroupId { get; private set; }
|
||||||
public string? LastUpdatedTitle { get; private set; }
|
public string? LastUpdatedTitle { get; private set; }
|
||||||
@@ -821,6 +824,15 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
public string? LastAddedCoGmUsername { get; private set; }
|
public string? LastAddedCoGmUsername { get; private set; }
|
||||||
public Guid? LastRemovedCoGmGroupId { get; private set; }
|
public Guid? LastRemovedCoGmGroupId { get; private set; }
|
||||||
public long? LastRemovedCoGmTelegramId { 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 bool RemovePlayerCalled { get; private set; }
|
||||||
public Guid? LastRemovedPlayerSessionId { get; private set; }
|
public Guid? LastRemovedPlayerSessionId { get; private set; }
|
||||||
public Guid? LastRemovedPlayerGroupId { get; private set; }
|
public Guid? LastRemovedPlayerGroupId { get; private set; }
|
||||||
@@ -842,6 +854,67 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
return Task.FromResult(group);
|
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) =>
|
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
|
||||||
Task.FromResult(IsManager(groupId, 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,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