Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a2965b43f | |||
| a0df94fc91 | |||
| 79694f7de8 | |||
| 542f15f2d6 | |||
| 64216f5a26 | |||
| 383e2c1d8d | |||
| bfa979a224 | |||
| c69ebf6c03 | |||
| 040b0a3cdb | |||
| a5aed14dd2 | |||
| 9fc434b42b | |||
| c2cc7fd9a8 | |||
| 3447acd8c4 | |||
| 56aeca5288 | |||
| 6ed0a120a0 | |||
| 682dd3fdec | |||
| c955e1572f |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 3.0.9
|
VERSION: 3.2.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>3.0.9</Version>
|
<Version>3.2.0</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
|||||||
crond -f
|
crond -f
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.0.9
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.2.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.0.9
|
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.2.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.0.9
|
image: git.codeanddice.ru/toutsu/gmrelay-web:3.2.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -42,12 +42,13 @@ public sealed class CancelSessionHandler(
|
|||||||
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
|
||||||
WHERE gm.group_id = s.group_id
|
WHERE gm.group_id = s.group_id
|
||||||
AND p.telegram_id = @TelegramUserId
|
AND p.platform = 'Telegram'
|
||||||
|
AND p.external_user_id = @ExternalUserId
|
||||||
) AS CanManage
|
) AS CanManage
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
WHERE s.id = @SessionId
|
WHERE s.id = @SessionId
|
||||||
""",
|
""",
|
||||||
new { command.SessionId, command.TelegramUserId }, transaction);
|
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() }, transaction);
|
||||||
|
|
||||||
if (session == null)
|
if (session == null)
|
||||||
{
|
{
|
||||||
@@ -89,7 +90,7 @@ public sealed class CancelSessionHandler(
|
|||||||
|
|
||||||
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
|
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
|
||||||
"""
|
"""
|
||||||
SELECT p.telegram_id AS TelegramId,
|
SELECT p.external_user_id::BIGINT AS TelegramId,
|
||||||
p.display_name AS DisplayName
|
p.display_name AS DisplayName
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
|
|||||||
@@ -1,292 +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 (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
|
||||||
VALUES (@TgId, @Name, @Username, 'Telegram', @TgId::TEXT, @Username)
|
|
||||||
ON CONFLICT (telegram_id) DO UPDATE
|
|
||||||
SET display_name = EXCLUDED.display_name,
|
|
||||||
telegram_username = EXCLUDED.telegram_username,
|
|
||||||
platform = COALESCE(players.platform, 'Telegram'),
|
|
||||||
external_user_id = COALESCE(players.external_user_id, EXCLUDED.telegram_id::TEXT),
|
|
||||||
external_username = COALESCE(players.external_username, EXCLUDED.telegram_username);
|
|
||||||
""",
|
|
||||||
new { TgId = gmId, Name = gmName, Username = gmUsername },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
|
||||||
"""
|
|
||||||
SELECT g.id AS GroupId,
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM group_managers gm
|
|
||||||
JOIN players p ON p.id = gm.player_id
|
|
||||||
WHERE gm.group_id = g.id
|
|
||||||
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT
|
|
||||||
) AS CanManage
|
|
||||||
FROM game_groups g
|
|
||||||
WHERE COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) = @ChatId::TEXT
|
|
||||||
""",
|
|
||||||
new { ChatId = chatId, GmId = gmId },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
Guid groupId;
|
|
||||||
if (existingGroup is null)
|
|
||||||
{
|
|
||||||
groupId = await connection.ExecuteScalarAsync<Guid>(
|
|
||||||
"""
|
|
||||||
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
|
|
||||||
VALUES (@ChatId, @ChatName, @GmId, 'Telegram', @ChatId::TEXT)
|
|
||||||
RETURNING id;
|
|
||||||
""",
|
|
||||||
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
|
||||||
INSERT INTO group_managers (group_id, player_id, role)
|
|
||||||
SELECT @GroupId, p.id, @OwnerRole
|
|
||||||
FROM players p
|
|
||||||
WHERE COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT
|
|
||||||
ON CONFLICT (group_id, player_id) DO NOTHING
|
|
||||||
""",
|
|
||||||
new { GroupId = groupId, GmId = gmId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
|
||||||
transaction);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (!existingGroup.CanManage)
|
|
||||||
{
|
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
|
||||||
await botClient.SendMessage(
|
|
||||||
chatId,
|
|
||||||
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
groupId = existingGroup.GroupId;
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"UPDATE game_groups SET name = @ChatName WHERE id = @GroupId",
|
|
||||||
new { ChatName = chatTitle, GroupId = groupId },
|
|
||||||
transaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
|
|
||||||
message.Chat.IsForum,
|
|
||||||
message.MessageThreadId);
|
|
||||||
var messageThreadId = topicDestination.MessageThreadId;
|
|
||||||
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
|
|
||||||
if (topicDestination.ShouldCreateForumTopic)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var topic = await botClient.CreateForumTopic(
|
|
||||||
chatId: chatId,
|
|
||||||
name: $"🎲 Игры: {title}",
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
messageThreadId = topic.MessageThreadId;
|
|
||||||
}
|
|
||||||
catch (Telegram.Bot.Exceptions.ApiRequestException ex)
|
|
||||||
when (TelegramTopicRouting.IsMissingForumTopicRightsError(ex.Message))
|
|
||||||
{
|
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
|
||||||
await botClient.SendMessage(
|
|
||||||
chatId,
|
|
||||||
TelegramTopicRouting.MissingForumTopicRightsMessage,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var batchId = Guid.NewGuid();
|
|
||||||
var sessions = new List<SessionBatchDto>();
|
|
||||||
|
|
||||||
foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value))
|
|
||||||
{
|
|
||||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
|
||||||
"""
|
|
||||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players)
|
|
||||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers)
|
|
||||||
RETURNING id;
|
|
||||||
""",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
BatchId = batchId,
|
|
||||||
GroupId = groupId,
|
|
||||||
Title = title,
|
|
||||||
Link = link,
|
|
||||||
ScheduledAt = scheduledAt,
|
|
||||||
ThreadId = messageThreadId,
|
|
||||||
TopicCreatedByBot = topicCreatedByBot,
|
|
||||||
MaxPlayers = parseResult.MaxPlayers,
|
|
||||||
Status = SessionStatus.Planned
|
|
||||||
},
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers, link));
|
|
||||||
}
|
|
||||||
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
|
|
||||||
|
|
||||||
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
|
|
||||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
|
||||||
|
|
||||||
Message batchMessage;
|
|
||||||
|
|
||||||
if (imageReference is not null && renderResult.Text.Length <= 1024)
|
|
||||||
{
|
|
||||||
// Картинка + расписание умещаются в одном Telegram-фото с подписью
|
|
||||||
try
|
|
||||||
{
|
|
||||||
batchMessage = await botClient.SendPhoto(
|
|
||||||
chatId: chatId,
|
|
||||||
messageThreadId: messageThreadId,
|
|
||||||
photo: InputFile.FromString(imageReference),
|
|
||||||
caption: renderResult.Text,
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}, отправляем текстом", batchId);
|
|
||||||
batchMessage = await botClient.SendMessage(
|
|
||||||
chatId: chatId,
|
|
||||||
messageThreadId: messageThreadId,
|
|
||||||
text: renderResult.Text,
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Текст слишком длинный для caption — fallback на два сообщения
|
|
||||||
if (imageReference is not null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await botClient.SendPhoto(
|
|
||||||
chatId: chatId,
|
|
||||||
messageThreadId: messageThreadId,
|
|
||||||
photo: InputFile.FromString(imageReference),
|
|
||||||
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}", batchId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
batchMessage = await botClient.SendMessage(
|
|
||||||
chatId: chatId,
|
|
||||||
messageThreadId: messageThreadId,
|
|
||||||
text: renderResult.Text,
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: renderResult.Markup,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
|
|
||||||
new { MsgId = batchMessage.MessageId, BatchId = batchId });
|
|
||||||
|
|
||||||
try
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,13 +41,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
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
|
||||||
WHERE gm.group_id = s.group_id
|
WHERE gm.group_id = s.group_id
|
||||||
AND p.telegram_id = @TelegramUserId
|
AND p.platform = 'Telegram'
|
||||||
|
AND p.external_user_id = @ExternalUserId
|
||||||
) AS CanManage
|
) AS CanManage
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
WHERE s.id = @SessionId
|
WHERE s.id = @SessionId
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
""",
|
""",
|
||||||
new { command.SessionId, command.TelegramUserId },
|
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
if (session is null)
|
if (session is null)
|
||||||
@@ -150,7 +151,7 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
"""
|
"""
|
||||||
SELECT sp.session_id AS SessionId,
|
SELECT sp.session_id AS SessionId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
sp.registration_status AS RegistrationStatus
|
sp.registration_status AS RegistrationStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
|
|||||||
@@ -1,113 +1,25 @@
|
|||||||
using System.Text;
|
|
||||||
using Dapper;
|
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using GmRelay.Shared.Platform;
|
using 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.telegram_chat_id = @ChatId"
|
|
||||||
+ " AND s.status = @Planned"
|
|
||||||
+ " AND s.scheduled_at > NOW()"
|
|
||||||
+ " ORDER BY s.scheduled_at ASC",
|
|
||||||
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
|
|
||||||
|
|
||||||
var sessionsList = sessions.ToList();
|
|
||||||
|
|
||||||
if (sessionsList.Count == 0)
|
|
||||||
{
|
|
||||||
await messenger.SendGroupMessageAsync(
|
|
||||||
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
|
|
||||||
"📭 У этой группы нет запланированных сессий для экспорта.",
|
|
||||||
cancellationToken);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine("BEGIN:VCALENDAR");
|
|
||||||
sb.AppendLine("VERSION:2.0");
|
|
||||||
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
|
|
||||||
|
|
||||||
foreach (var s in sessionsList)
|
|
||||||
{
|
|
||||||
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
|
|
||||||
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
|
|
||||||
|
|
||||||
sb.AppendLine("BEGIN:VEVENT");
|
|
||||||
sb.AppendLine($"UID:{s.Id}@gmrelay");
|
|
||||||
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
|
|
||||||
sb.AppendLine($"DTSTART:{dtStart}");
|
|
||||||
sb.AppendLine($"DTEND:{dtEnd}");
|
|
||||||
sb.AppendLine($"SUMMARY:{s.Title}");
|
|
||||||
sb.AppendLine("END:VEVENT");
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.AppendLine("END:VCALENDAR");
|
|
||||||
|
|
||||||
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
|
||||||
|
|
||||||
|
|
||||||
// Create calendar subscription
|
|
||||||
string? subscriptionUrl = null;
|
|
||||||
var baseUrl = configuration["Web:BaseUrl"];
|
|
||||||
var senderId = message.From?.Id;
|
|
||||||
if (!string.IsNullOrWhiteSpace(baseUrl) && senderId.HasValue)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var token = Guid.NewGuid().ToString("N");
|
|
||||||
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
|
|
||||||
@"SELECT id FROM game_groups WHERE telegram_chat_id = @ChatId",
|
|
||||||
new { ChatId = message.Chat.Id });
|
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
|
|
||||||
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
|
|
||||||
new { token, userTelegramId = senderId.Value, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
|
|
||||||
|
|
||||||
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Non-critical: if subscription creation fails, still send the file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var actions = subscriptionUrl is not null
|
|
||||||
? new[]
|
|
||||||
{
|
|
||||||
new PlatformMessageAction(
|
|
||||||
"calendar-subscription",
|
|
||||||
"🔗 Подписаться на календарь",
|
|
||||||
subscriptionUrl)
|
|
||||||
}
|
|
||||||
: Array.Empty<PlatformMessageAction>();
|
|
||||||
|
|
||||||
await messenger.SendCalendarFileAsync(
|
|
||||||
new PlatformCalendarFile(
|
|
||||||
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
|
|
||||||
"schedule.ics",
|
|
||||||
bytes,
|
|
||||||
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
|
||||||
actions),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,138 +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.telegram_id = @TelegramUserId
|
|
||||||
) AS CanManage
|
|
||||||
FROM sessions s
|
|
||||||
WHERE s.id = @SessionId
|
|
||||||
""",
|
|
||||||
new { command.SessionId, command.TelegramUserId }, 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.telegram_id = @TelegramUserId
|
|
||||||
) AS CanManage
|
|
||||||
FROM sessions s
|
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
|
||||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
|
||||||
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
|
|
||||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
|
|
||||||
ORDER BY s.scheduled_at ASC",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
ChatId = command.ChatId,
|
|
||||||
command.TelegramUserId,
|
|
||||||
Cancelled = SessionStatus.Cancelled,
|
|
||||||
Active = ParticipantRegistrationStatus.Active,
|
|
||||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
|
||||||
});
|
|
||||||
|
|
||||||
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,114 +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.telegram_id = @TelegramUserId
|
|
||||||
) AS CanManage
|
|
||||||
FROM sessions s
|
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
|
||||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
|
||||||
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
|
|
||||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
|
|
||||||
ORDER BY s.scheduled_at ASC",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
ChatId = message.Chat.Id,
|
|
||||||
TelegramUserId = message.From?.Id,
|
|
||||||
Cancelled = SessionStatus.Cancelled,
|
|
||||||
Active = ParticipantRegistrationStatus.Active,
|
|
||||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
|
||||||
});
|
|
||||||
|
|
||||||
var 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
-211
@@ -12,241 +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.telegram_chat_id AS TelegramChatId,
|
|
||||||
s.thread_id AS ThreadId,
|
|
||||||
s.notification_mode AS NotificationMode
|
|
||||||
FROM reschedule_proposals rp
|
|
||||||
JOIN sessions s ON s.id = rp.session_id
|
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
|
||||||
WHERE rp.proposed_by = @GmId
|
|
||||||
AND rp.status = 'AwaitingTime'
|
|
||||||
AND g.telegram_chat_id = @ChatId
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM group_managers gm
|
|
||||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
|
||||||
WHERE gm.group_id = s.group_id
|
|
||||||
AND manager_player.telegram_id = @GmId
|
|
||||||
)
|
|
||||||
ORDER BY rp.created_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
new { GmId = gmTelegramId, ChatId = chatId });
|
|
||||||
|
|
||||||
if (proposal is null)
|
|
||||||
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.telegram_username AS TelegramUsername,
|
|
||||||
p.telegram_id AS TelegramId
|
|
||||||
FROM session_participants sp
|
|
||||||
JOIN players p ON p.id = sp.player_id
|
|
||||||
WHERE sp.session_id = @SessionId
|
|
||||||
AND sp.is_gm = false
|
|
||||||
AND sp.registration_status = @Active
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
|
||||||
|
|
||||||
// 4. If no participants — reschedule immediately
|
|
||||||
if (participants.Count == 0)
|
|
||||||
{
|
{
|
||||||
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(
|
||||||
@@ -268,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> (МСК)",
|
||||||
@@ -349,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.telegram_username AS TelegramUsername,
|
|
||||||
sp.registration_status AS RegistrationStatus
|
|
||||||
FROM session_participants sp
|
|
||||||
JOIN players p ON sp.player_id = p.id
|
|
||||||
JOIN sessions s ON sp.session_id = s.id
|
|
||||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
|
||||||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
|
|
||||||
""",
|
|
||||||
new { proposal.BatchId })).ToList();
|
|
||||||
|
|
||||||
if (proposal.BatchMessageId.HasValue)
|
|
||||||
{
|
|
||||||
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
|
||||||
|
|
||||||
await messenger.UpdateScheduleAsync(
|
|
||||||
new PlatformScheduleMessage(
|
|
||||||
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
|
|
||||||
view,
|
|
||||||
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
|
|
||||||
ct);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
logger.LogWarning("No batch_message_id stored for session {SessionId}, cannot edit batch message in-place", proposal.SessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule for session {SessionId}", proposal.SessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
|
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
+35
-118
@@ -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,130 +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.telegram_id = @TelegramUserId
|
|
||||||
AND sp.is_gm = false
|
|
||||||
AND sp.registration_status = @Active
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
if (playerId is null)
|
|
||||||
{
|
|
||||||
await AnswerAsync(command.CallbackQueryId, "Вы не являетесь участником этой сессии.", ct);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
|
||||||
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
|
|
||||||
VALUES (@ProposalId, @PlayerId, @OptionId)
|
|
||||||
ON CONFLICT (proposal_id, player_id) DO UPDATE
|
|
||||||
SET option_id = EXCLUDED.option_id,
|
|
||||||
voted_at = now()
|
|
||||||
""",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
ProposalId = proposal.Id,
|
|
||||||
PlayerId = playerId.Value,
|
|
||||||
command.OptionId
|
|
||||||
},
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
|
||||||
"""
|
|
||||||
SELECT p.id AS PlayerId,
|
|
||||||
p.display_name AS DisplayName,
|
|
||||||
p.telegram_username AS TelegramUsername,
|
|
||||||
p.telegram_id AS TelegramId
|
|
||||||
FROM session_participants sp
|
|
||||||
JOIN players p ON p.id = sp.player_id
|
|
||||||
WHERE sp.session_id = @SessionId
|
|
||||||
AND sp.is_gm = false
|
|
||||||
AND sp.registration_status = @Active
|
|
||||||
ORDER BY p.display_name
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
|
||||||
transaction)).ToList();
|
|
||||||
|
|
||||||
var options = (await connection.QueryAsync<RescheduleOptionDto>(
|
|
||||||
"""
|
|
||||||
SELECT id AS OptionId,
|
|
||||||
display_order AS DisplayOrder,
|
|
||||||
proposed_at AS ProposedAt
|
|
||||||
FROM reschedule_options
|
|
||||||
WHERE proposal_id = @ProposalId
|
|
||||||
ORDER BY display_order
|
|
||||||
""",
|
|
||||||
new { ProposalId = proposal.Id },
|
|
||||||
transaction)).ToList();
|
|
||||||
|
|
||||||
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
|
|
||||||
"""
|
|
||||||
SELECT rov.option_id AS OptionId,
|
|
||||||
p.id AS PlayerId,
|
|
||||||
p.display_name AS DisplayName,
|
|
||||||
p.telegram_username AS TelegramUsername
|
|
||||||
FROM reschedule_option_votes rov
|
|
||||||
JOIN players p ON p.id = rov.player_id
|
|
||||||
WHERE rov.proposal_id = @ProposalId
|
|
||||||
ORDER BY rov.voted_at, p.display_name
|
|
||||||
""",
|
|
||||||
new { ProposalId = proposal.Id },
|
|
||||||
transaction)).ToList();
|
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
|
||||||
|
|
||||||
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
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
|
||||||
{
|
{
|
||||||
@@ -152,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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,12 +45,13 @@ public sealed class InitiateRescheduleHandler(
|
|||||||
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
|
||||||
WHERE gm.group_id = s.group_id
|
WHERE gm.group_id = s.group_id
|
||||||
AND p.telegram_id = @TelegramUserId
|
AND p.platform = 'Telegram'
|
||||||
|
AND p.external_user_id = @ExternalUserId
|
||||||
) AS CanManage
|
) AS CanManage
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
WHERE s.id = @SessionId AND s.status != @Cancelled
|
WHERE s.id = @SessionId AND s.status != @Cancelled
|
||||||
""",
|
""",
|
||||||
new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled });
|
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString(), Cancelled = SessionStatus.Cancelled });
|
||||||
|
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
@@ -83,10 +84,10 @@ public sealed class InitiateRescheduleHandler(
|
|||||||
// 3. Create proposal in AwaitingTime status
|
// 3. Create proposal in AwaitingTime status
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
INSERT INTO reschedule_proposals (session_id, proposed_by, source_platform, status)
|
INSERT INTO reschedule_proposals (session_id, proposed_by_external_user_id, source_platform, status)
|
||||||
VALUES (@SessionId, @GmId, 'Telegram', 'AwaitingTime')
|
VALUES (@SessionId, @ProposedBy, 'Telegram', 'AwaitingTime')
|
||||||
""",
|
""",
|
||||||
new { command.SessionId, GmId = command.TelegramUserId });
|
new { command.SessionId, ProposedBy = command.TelegramUserId.ToString() });
|
||||||
|
|
||||||
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
|
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -79,7 +79,7 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
"""
|
"""
|
||||||
SELECT rp.vote_message_id AS VoteMessageId,
|
SELECT rp.vote_message_id AS VoteMessageId,
|
||||||
s.batch_message_id AS BatchMessageId,
|
s.batch_message_id AS BatchMessageId,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.external_group_id::BIGINT AS TelegramChatId,
|
||||||
s.thread_id AS ThreadId
|
s.thread_id AS ThreadId
|
||||||
FROM reschedule_proposals rp
|
FROM reschedule_proposals rp
|
||||||
JOIN sessions s ON s.id = rp.session_id
|
JOIN sessions s ON s.id = rp.session_id
|
||||||
@@ -169,7 +169,7 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
"""
|
"""
|
||||||
SELECT sp.session_id AS SessionId,
|
SELECT sp.session_id AS SessionId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
sp.registration_status AS RegistrationStatus
|
sp.registration_status AS RegistrationStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
|
|||||||
@@ -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,14 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- V023: Make legacy Telegram columns nullable for multi-platform
|
||||||
|
-- =============================================================
|
||||||
|
-- Scope: Allow Discord (and future platforms) to create players
|
||||||
|
-- and game_groups without legacy telegram_* values.
|
||||||
|
-- Existing Telegram data was backfilled in V016.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
ALTER TABLE game_groups
|
||||||
|
ALTER COLUMN telegram_chat_id DROP NOT NULL,
|
||||||
|
ALTER COLUMN gm_telegram_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE players
|
||||||
|
ALTER COLUMN telegram_id DROP NOT NULL;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- V024: Deprecate legacy Telegram-specific columns
|
||||||
|
-- =============================================================
|
||||||
|
-- Scope: Complete platform migration by backfilling any remaining
|
||||||
|
-- external_* gaps and officially deprecating telegram_* columns.
|
||||||
|
-- No columns are dropped — rollback-safe.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- 1. Backfill players platform identity (safeguard for any rows missed in V016)
|
||||||
|
UPDATE players
|
||||||
|
SET platform = 'Telegram',
|
||||||
|
external_user_id = telegram_id::TEXT,
|
||||||
|
external_username = telegram_username
|
||||||
|
WHERE platform IS NULL;
|
||||||
|
|
||||||
|
-- 2. Backfill game_groups platform identity (safeguard for any rows missed in V016)
|
||||||
|
UPDATE game_groups
|
||||||
|
SET platform = 'Telegram',
|
||||||
|
external_group_id = telegram_chat_id::TEXT
|
||||||
|
WHERE platform IS NULL;
|
||||||
|
|
||||||
|
-- 3. Add platform identity to calendar_subscriptions
|
||||||
|
ALTER TABLE calendar_subscriptions
|
||||||
|
ADD COLUMN user_platform VARCHAR(50),
|
||||||
|
ADD COLUMN user_external_id VARCHAR(255);
|
||||||
|
|
||||||
|
UPDATE calendar_subscriptions
|
||||||
|
SET user_external_id = user_telegram_id::TEXT,
|
||||||
|
user_platform = 'Telegram'
|
||||||
|
WHERE user_platform IS NULL;
|
||||||
|
|
||||||
|
-- 4. Migrate calendar subscription index
|
||||||
|
DROP INDEX IF EXISTS ix_calendar_subscriptions_user_telegram_id;
|
||||||
|
CREATE INDEX ix_calendar_subscriptions_user_external_id ON calendar_subscriptions (user_external_id);
|
||||||
|
|
||||||
|
-- 5. Deprecation comments on legacy columns
|
||||||
|
COMMENT ON COLUMN players.telegram_id IS 'DEPRECATED: use platform + external_user_id';
|
||||||
|
COMMENT ON COLUMN players.telegram_username IS 'DEPRECATED: use external_username';
|
||||||
|
COMMENT ON COLUMN game_groups.telegram_chat_id IS 'DEPRECATED: use platform + external_group_id';
|
||||||
|
COMMENT ON COLUMN game_groups.gm_telegram_id IS 'DEPRECATED: group ownership is tracked in group_managers';
|
||||||
|
COMMENT ON COLUMN calendar_subscriptions.user_telegram_id IS 'DEPRECATED: use user_platform + user_external_id';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- V025: Backfill proposed_by_external_user_id for Telegram proposals
|
||||||
|
-- =============================================================
|
||||||
|
-- Scope: Ensure all reschedule_proposals have proposed_by_external_user_id
|
||||||
|
-- populated so that InitiateRescheduleHandler can stop writing proposed_by.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
UPDATE reschedule_proposals
|
||||||
|
SET proposed_by_external_user_id = proposed_by::TEXT
|
||||||
|
WHERE proposed_by_external_user_id IS NULL
|
||||||
|
AND proposed_by IS NOT NULL;
|
||||||
@@ -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>();
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||||
|
|
||||||
|
public sealed record DiscordDeleteSessionResult(
|
||||||
|
string ReplyText,
|
||||||
|
SessionBatchViewModel? UpdatedView,
|
||||||
|
string? EmptyMessage = null);
|
||||||
|
|
||||||
|
public sealed class DiscordDeleteSessionHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
DiscordPermissionChecker permissionChecker,
|
||||||
|
DiscordListSessionsHandler listSessionsHandler,
|
||||||
|
ILogger<DiscordDeleteSessionHandler> logger)
|
||||||
|
{
|
||||||
|
public async Task<DiscordDeleteSessionResult> HandleAsync(
|
||||||
|
string guildId,
|
||||||
|
string channelId,
|
||||||
|
ulong userId,
|
||||||
|
ulong resolvedPermissions,
|
||||||
|
ulong guildOwnerId,
|
||||||
|
Guid sessionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
|
||||||
|
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
||||||
|
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
JOIN game_groups g ON g.id = gm.group_id
|
||||||
|
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
||||||
|
new { GuildId = guildId });
|
||||||
|
|
||||||
|
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||||
|
{
|
||||||
|
return new DiscordDeleteSessionResult(
|
||||||
|
"Только owner, администратор или manager могут удалять сессии.",
|
||||||
|
UpdatedView: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
var deletedRows = await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
DELETE FROM sessions s
|
||||||
|
USING game_groups g
|
||||||
|
WHERE s.group_id = g.id
|
||||||
|
AND s.id = @SessionId
|
||||||
|
AND g.platform = 'Discord'
|
||||||
|
AND g.external_group_id = @GuildId
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId, GuildId = guildId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (deletedRows == 0)
|
||||||
|
{
|
||||||
|
return new DiscordDeleteSessionResult(
|
||||||
|
"Сессия не найдена или уже удалена.",
|
||||||
|
UpdatedView: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Deleted Discord session {SessionId} in guild {GuildId}", sessionId, guildId);
|
||||||
|
|
||||||
|
var updatedView = await listSessionsHandler.BuildScheduleAsync(
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
userId,
|
||||||
|
resolvedPermissions,
|
||||||
|
guildOwnerId,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return updatedView is null
|
||||||
|
? new DiscordDeleteSessionResult(
|
||||||
|
"Сессия удалена.",
|
||||||
|
UpdatedView: null,
|
||||||
|
EmptyMessage: "В этом сервере нет предстоящих игр.")
|
||||||
|
: new DiscordDeleteSessionResult("Сессия удалена.", updatedView);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using NetCord;
|
||||||
using NetCord.Rest;
|
using NetCord.Rest;
|
||||||
using NetCord.Services.ApplicationCommands;
|
using NetCord.Services.ApplicationCommands;
|
||||||
|
|
||||||
@@ -18,8 +19,17 @@ public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandC
|
|||||||
var guildId = Context.Interaction.GuildId?.ToString()
|
var guildId = Context.Interaction.GuildId?.ToString()
|
||||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
||||||
var channelId = Context.Channel.Id.ToString();
|
var channelId = Context.Channel.Id.ToString();
|
||||||
|
var member = Context.User as GuildInteractionUser;
|
||||||
|
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
|
||||||
|
var guildOwnerId = 0UL;
|
||||||
|
|
||||||
var view = await _handler.BuildScheduleAsync(guildId, channelId, CancellationToken.None);
|
var view = await _handler.BuildScheduleAsync(
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
Context.User.Id,
|
||||||
|
resolvedPermissions,
|
||||||
|
guildOwnerId,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
if (view is null)
|
if (view is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
@@ -9,11 +10,22 @@ internal sealed record DiscordSessionListItemDto(
|
|||||||
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
|
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
|
||||||
int PlayerCount, int WaitlistCount);
|
int PlayerCount, int WaitlistCount);
|
||||||
|
|
||||||
public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
public sealed class DiscordListSessionsHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
DiscordPermissionChecker permissionChecker)
|
||||||
{
|
{
|
||||||
|
public Task<SessionBatchViewModel?> BuildScheduleAsync(
|
||||||
|
string guildId,
|
||||||
|
string channelId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
BuildScheduleAsync(guildId, channelId, 0, 0, 0, cancellationToken);
|
||||||
|
|
||||||
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
|
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
|
||||||
string guildId,
|
string guildId,
|
||||||
string channelId,
|
string channelId,
|
||||||
|
ulong userId,
|
||||||
|
ulong resolvedPermissions,
|
||||||
|
ulong guildOwnerId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
@@ -29,7 +41,7 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
|||||||
WHERE g.platform = 'Discord'
|
WHERE g.platform = 'Discord'
|
||||||
AND g.external_group_id = @GuildId
|
AND g.external_group_id = @GuildId
|
||||||
AND s.status != @Cancelled
|
AND s.status != @Cancelled
|
||||||
AND s.scheduled_at > NOW()
|
AND s.scheduled_at > now() - interval '4 hours'
|
||||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
|
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
|
||||||
ORDER BY s.scheduled_at ASC",
|
ORDER BY s.scheduled_at ASC",
|
||||||
new
|
new
|
||||||
@@ -44,11 +56,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
|||||||
if (sessionList.Count == 0)
|
if (sessionList.Count == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
||||||
|
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
JOIN game_groups g ON g.id = gm.group_id
|
||||||
|
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
||||||
|
new { GuildId = guildId });
|
||||||
|
|
||||||
|
var canManage = permissionChecker.CanManageSchedule(
|
||||||
|
guildOwnerId,
|
||||||
|
userId,
|
||||||
|
dbManagerUserIds,
|
||||||
|
resolvedPermissions);
|
||||||
|
|
||||||
var sessionIds = sessionList.Select(s => s.Id).ToList();
|
var sessionIds = sessionList.Select(s => s.Id).ToList();
|
||||||
var participants = await connection.QueryAsync<ParticipantBatchDto>(
|
var participants = await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
@"SELECT sp.session_id as SessionId,
|
@"SELECT sp.session_id as SessionId,
|
||||||
p.display_name as DisplayName,
|
p.display_name as DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
|
p.external_username as TelegramUsername,
|
||||||
sp.registration_status as RegistrationStatus
|
sp.registration_status as RegistrationStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
@@ -60,6 +86,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
|
|||||||
var batchDtos = sessionList.Select(s => new SessionBatchDto(
|
var batchDtos = sessionList.Select(s => new SessionBatchDto(
|
||||||
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
|
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
|
||||||
|
|
||||||
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
|
var view = SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
|
||||||
|
return canManage ? AddManagerActions(view) : view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static SessionBatchViewModel AddManagerActions(SessionBatchViewModel view) =>
|
||||||
|
view with
|
||||||
|
{
|
||||||
|
Sessions = view.Sessions
|
||||||
|
.Select(session =>
|
||||||
|
{
|
||||||
|
if (SessionStatus.IsCancelled(session.Status))
|
||||||
|
return session;
|
||||||
|
|
||||||
|
var actions = session.AvailableActions
|
||||||
|
.Concat([new AvailableAction("delete_session", $"Удалить {session.ScheduledAt.FormatMoscowShort()}", session.SessionId)])
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return session with { AvailableActions = actions };
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,10 +44,12 @@ public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandCon
|
|||||||
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
|
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
|
||||||
|
|
||||||
ulong guildOwnerId = 0;
|
ulong guildOwnerId = 0;
|
||||||
|
var guildName = guildId.ToString();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
|
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
|
||||||
guildOwnerId = guild.OwnerId;
|
guildOwnerId = guild.OwnerId;
|
||||||
|
guildName = guild.Name;
|
||||||
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
|
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
|
||||||
}
|
}
|
||||||
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
@@ -80,6 +82,7 @@ public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandCon
|
|||||||
var view = await _handler.HandleAsync(
|
var view = await _handler.HandleAsync(
|
||||||
guildId: guildId.ToString(),
|
guildId: guildId.ToString(),
|
||||||
channelId: Context.Channel!.Id.ToString(),
|
channelId: Context.Channel!.Id.ToString(),
|
||||||
|
groupName: guildName,
|
||||||
userId: Context.User.Id,
|
userId: Context.User.Id,
|
||||||
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
|
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
|
||||||
resolvedPermissions: resolvedPermissions,
|
resolvedPermissions: resolvedPermissions,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Platform;
|
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||||
|
|
||||||
@@ -12,35 +12,40 @@ public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, strin
|
|||||||
public sealed class DiscordNewSessionHandler(
|
public sealed class DiscordNewSessionHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
DiscordPermissionChecker permissionChecker,
|
DiscordPermissionChecker permissionChecker,
|
||||||
IPlatformMessenger messenger,
|
|
||||||
ILogger<DiscordNewSessionHandler> logger)
|
ILogger<DiscordNewSessionHandler> logger)
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||||
|
|
||||||
public static TimeParseResult ParseTimeInput(string input)
|
public static TimeParseResult ParseTimeInput(string input)
|
||||||
{
|
{
|
||||||
if (DateTimeOffset.TryParseExact(
|
var trimmed = input.Trim();
|
||||||
input.Trim(),
|
|
||||||
|
if (DateTime.TryParseExact(
|
||||||
|
trimmed,
|
||||||
"yyyy-MM-dd HH:mm",
|
"yyyy-MM-dd HH:mm",
|
||||||
System.Globalization.CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
System.Globalization.DateTimeStyles.AssumeUniversal,
|
DateTimeStyles.None,
|
||||||
out var result))
|
out var dt1))
|
||||||
{
|
{
|
||||||
if (result < DateTimeOffset.UtcNow)
|
var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime();
|
||||||
|
if (offset < DateTimeOffset.UtcNow)
|
||||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
||||||
|
|
||||||
return new TimeParseResult(true, result.ToUniversalTime(), null);
|
return new TimeParseResult(true, offset, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DateTimeOffset.TryParseExact(
|
if (DateTime.TryParseExact(
|
||||||
input.Trim(),
|
trimmed,
|
||||||
"dd.MM.yyyy HH:mm",
|
"dd.MM.yyyy HH:mm",
|
||||||
System.Globalization.CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
System.Globalization.DateTimeStyles.AssumeUniversal,
|
DateTimeStyles.None,
|
||||||
out var altResult))
|
out var dt2))
|
||||||
{
|
{
|
||||||
if (altResult < DateTimeOffset.UtcNow)
|
var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime();
|
||||||
|
if (offset < DateTimeOffset.UtcNow)
|
||||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
||||||
|
|
||||||
return new TimeParseResult(true, altResult.ToUniversalTime(), null);
|
return new TimeParseResult(true, offset, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
|
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
|
||||||
@@ -49,6 +54,7 @@ public sealed class DiscordNewSessionHandler(
|
|||||||
public async Task<SessionBatchViewModel> HandleAsync(
|
public async Task<SessionBatchViewModel> HandleAsync(
|
||||||
string guildId,
|
string guildId,
|
||||||
string channelId,
|
string channelId,
|
||||||
|
string groupName,
|
||||||
ulong userId,
|
ulong userId,
|
||||||
string userDisplayName,
|
string userDisplayName,
|
||||||
ulong resolvedPermissions,
|
ulong resolvedPermissions,
|
||||||
@@ -60,6 +66,9 @@ public sealed class DiscordNewSessionHandler(
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
var displayGroupName = string.IsNullOrWhiteSpace(groupName) || string.Equals(groupName, guildId, StringComparison.Ordinal)
|
||||||
|
? title
|
||||||
|
: groupName.Trim();
|
||||||
|
|
||||||
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
||||||
@"SELECT CAST(p.external_user_id AS BIGINT)
|
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||||
@@ -75,6 +84,7 @@ public sealed class DiscordNewSessionHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
var transactionCommitted = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
@@ -89,13 +99,13 @@ public sealed class DiscordNewSessionHandler(
|
|||||||
|
|
||||||
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
|
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
|
||||||
VALUES (@GuildId, 'Discord', @GuildId, @ChannelId)
|
VALUES (@GroupName, 'Discord', @GuildId, @ChannelId)
|
||||||
ON CONFLICT (platform, external_group_id)
|
ON CONFLICT (platform, external_group_id)
|
||||||
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
|
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
|
||||||
DO UPDATE SET name = EXCLUDED.name,
|
DO UPDATE SET name = EXCLUDED.name,
|
||||||
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
|
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
|
||||||
RETURNING id",
|
RETURNING id",
|
||||||
new { GuildId = guildId, ChannelId = channelId },
|
new { GroupName = displayGroupName, GuildId = guildId, ChannelId = channelId },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
@@ -125,23 +135,19 @@ public sealed class DiscordNewSessionHandler(
|
|||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
transactionCommitted = true;
|
||||||
logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId);
|
logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId);
|
||||||
|
|
||||||
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
|
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
|
||||||
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
|
return SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||||
|
|
||||||
await messenger.SendScheduleAsync(
|
|
||||||
new PlatformScheduleMessage(
|
|
||||||
new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId),
|
|
||||||
view,
|
|
||||||
null),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
return view;
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
if (!transactionCommitted)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
|||||||
"""
|
"""
|
||||||
SELECT sp.session_id AS SessionId,
|
SELECT sp.session_id AS SessionId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
sp.registration_status AS RegistrationStatus
|
sp.registration_status AS RegistrationStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||||
|
using GmRelay.DiscordBot.Rendering;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
|
using System.Collections;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using NetCord;
|
using NetCord;
|
||||||
using NetCord.Rest;
|
using NetCord.Rest;
|
||||||
@@ -14,6 +16,7 @@ public sealed class DiscordSessionInteractionModule(
|
|||||||
JoinSessionHandler joinSessionHandler,
|
JoinSessionHandler joinSessionHandler,
|
||||||
LeaveSessionHandler leaveSessionHandler,
|
LeaveSessionHandler leaveSessionHandler,
|
||||||
HandleRsvpHandler rsvpHandler,
|
HandleRsvpHandler rsvpHandler,
|
||||||
|
DiscordDeleteSessionHandler deleteSessionHandler,
|
||||||
DiscordRescheduleVoteHandler voteHandler,
|
DiscordRescheduleVoteHandler voteHandler,
|
||||||
DiscordInteractionReplyCache interactionReplies,
|
DiscordInteractionReplyCache interactionReplies,
|
||||||
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
|
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
|
||||||
@@ -28,21 +31,22 @@ public sealed class DiscordSessionInteractionModule(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var input = CreateInput(parsedSessionId);
|
var input = CreateInput(parsedSessionId);
|
||||||
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||||
|
SessionInteractionResult result;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await joinSessionHandler.HandleAsync(
|
result = await joinSessionHandler.HandleAsync(
|
||||||
DiscordSessionInteractionMapper.CreateJoinCommand(input),
|
DiscordSessionInteractionMapper.CreateJoinCommand(input) with { DeferScheduleUpdate = true },
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
|
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
|
||||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await CompleteWithStoredReplyAsync(input.InteractionId);
|
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[ComponentInteraction("leave_session")]
|
[ComponentInteraction("leave_session")]
|
||||||
@@ -55,21 +59,56 @@ public sealed class DiscordSessionInteractionModule(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var input = CreateInput(parsedSessionId);
|
var input = CreateInput(parsedSessionId);
|
||||||
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||||
|
SessionInteractionResult result;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await leaveSessionHandler.HandleAsync(
|
result = await leaveSessionHandler.HandleAsync(
|
||||||
DiscordSessionInteractionMapper.CreateLeaveCommand(input),
|
DiscordSessionInteractionMapper.CreateLeaveCommand(input) with { DeferScheduleUpdate = true },
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
|
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
|
||||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await CompleteWithStoredReplyAsync(input.InteractionId);
|
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ComponentInteraction("delete_session")]
|
||||||
|
public async Task DeleteAsync(string sessionId)
|
||||||
|
{
|
||||||
|
if (!Guid.TryParse(sessionId, out var parsedSessionId))
|
||||||
|
{
|
||||||
|
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var input = CreateInput(parsedSessionId);
|
||||||
|
var member = Context.User as GuildInteractionUser;
|
||||||
|
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
|
||||||
|
|
||||||
|
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await deleteSessionHandler.HandleAsync(
|
||||||
|
guildId: input.GuildId,
|
||||||
|
channelId: input.ChannelId,
|
||||||
|
userId: input.UserId,
|
||||||
|
resolvedPermissions: resolvedPermissions,
|
||||||
|
guildOwnerId: 0,
|
||||||
|
sessionId: parsedSessionId,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
await CompleteDeleteResponseAsync(result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to handle Discord delete interaction for session {SessionId}", parsedSessionId);
|
||||||
|
await FollowupEphemeralAsync("Не удалось удалить сессию.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[ComponentInteraction("rsvp")]
|
[ComponentInteraction("rsvp")]
|
||||||
@@ -124,7 +163,7 @@ public sealed class DiscordSessionInteractionModule(
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
|
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
|
||||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,9 +229,85 @@ public sealed class DiscordSessionInteractionModule(
|
|||||||
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
|
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task CompleteScheduleUpdateResponseAsync(string interactionId, SessionInteractionResult result)
|
||||||
|
{
|
||||||
|
var updatedView = result.UpdatedView;
|
||||||
|
if (updatedView is not null && SourceMessageHasDeleteAction())
|
||||||
|
{
|
||||||
|
updatedView = DiscordListSessionsHandler.AddManagerActions(updatedView);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedView is not null)
|
||||||
|
{
|
||||||
|
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(updatedView);
|
||||||
|
await ModifyResponseAsync(options =>
|
||||||
|
{
|
||||||
|
options.Embeds = embeds;
|
||||||
|
options.Components = actionRows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var reply = interactionReplies.Take(interactionId);
|
||||||
|
await FollowupEphemeralAsync(reply?.Text ?? result.ReplyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CompleteDeleteResponseAsync(DiscordDeleteSessionResult result)
|
||||||
|
{
|
||||||
|
if (result.UpdatedView is not null)
|
||||||
|
{
|
||||||
|
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(result.UpdatedView);
|
||||||
|
await ModifyResponseAsync(options =>
|
||||||
|
{
|
||||||
|
options.Embeds = embeds;
|
||||||
|
options.Components = actionRows;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (result.EmptyMessage is not null)
|
||||||
|
{
|
||||||
|
await ModifyResponseAsync(options =>
|
||||||
|
{
|
||||||
|
options.Content = result.EmptyMessage;
|
||||||
|
options.Embeds = [];
|
||||||
|
options.Components = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await FollowupEphemeralAsync(result.ReplyText);
|
||||||
|
}
|
||||||
|
|
||||||
private Task CompleteResponseAsync(string text) =>
|
private Task CompleteResponseAsync(string text) =>
|
||||||
ModifyResponseAsync(options => options.Content = text);
|
ModifyResponseAsync(options => options.Content = text);
|
||||||
|
|
||||||
|
private Task FollowupEphemeralAsync(string text) =>
|
||||||
|
FollowupAsync(new InteractionMessageProperties()
|
||||||
|
.WithContent(text)
|
||||||
|
.WithFlags(MessageFlags.Ephemeral));
|
||||||
|
|
||||||
|
private bool SourceMessageHasDeleteAction() =>
|
||||||
|
Context.Interaction.Message?.Components.Any(ComponentContainsDeleteAction) == true;
|
||||||
|
|
||||||
|
private static bool ComponentContainsDeleteAction(object? component)
|
||||||
|
{
|
||||||
|
if (component is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (component is IInteractiveComponent interactive
|
||||||
|
&& interactive.CustomId.StartsWith("delete_session:", StringComparison.Ordinal))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var nestedComponents = component.GetType().GetProperty("Components")?.GetValue(component) as IEnumerable;
|
||||||
|
if (nestedComponents is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
foreach (var nestedComponent in nestedComponents)
|
||||||
|
{
|
||||||
|
if (ComponentContainsDeleteAction(nestedComponent))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
|
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
|
||||||
InteractionCallback.Message(
|
InteractionCallback.Message(
|
||||||
new InteractionMessageProperties()
|
new InteractionMessageProperties()
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -98,17 +130,34 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var channelId = GetChannelId(request.Group);
|
var channelId = GetChannelId(request.Group);
|
||||||
var message = await restClient.SendMessageAsync(
|
try
|
||||||
channelId,
|
{
|
||||||
new MessageProperties()
|
var message = await restClient.SendMessageAsync(
|
||||||
.WithEmbeds([BuildConfirmationEmbed(request)])
|
channelId,
|
||||||
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
|
new MessageProperties()
|
||||||
|
.WithEmbeds([BuildConfirmationEmbed(request)])
|
||||||
|
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
|
||||||
|
|
||||||
return new PlatformMessageRef(
|
logger?.LogInformation(
|
||||||
PlatformKind.Discord,
|
"Confirmation request sent to Discord channel {ChannelId}, message id {MessageId}",
|
||||||
request.Group.ExternalGroupId,
|
channelId,
|
||||||
null,
|
message.Id);
|
||||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
|
||||||
|
return new PlatformMessageRef(
|
||||||
|
PlatformKind.Discord,
|
||||||
|
request.Group.ExternalGroupId,
|
||||||
|
null,
|
||||||
|
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger?.LogError(
|
||||||
|
ex,
|
||||||
|
"Failed to send confirmation request to Discord channel {ChannelId} for session {SessionId}",
|
||||||
|
channelId,
|
||||||
|
request.SessionId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
|
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
|
||||||
@@ -135,15 +184,32 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var channelId = GetChannelId(notification.Group);
|
var channelId = GetChannelId(notification.Group);
|
||||||
var message = await restClient.SendMessageAsync(
|
try
|
||||||
channelId,
|
{
|
||||||
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
|
var message = await restClient.SendMessageAsync(
|
||||||
|
channelId,
|
||||||
|
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
|
||||||
|
|
||||||
return new PlatformMessageRef(
|
logger?.LogInformation(
|
||||||
PlatformKind.Discord,
|
"Join link sent to Discord channel {ChannelId}, message id {MessageId}",
|
||||||
notification.Group.ExternalGroupId,
|
channelId,
|
||||||
null,
|
message.Id);
|
||||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
|
||||||
|
return new PlatformMessageRef(
|
||||||
|
PlatformKind.Discord,
|
||||||
|
notification.Group.ExternalGroupId,
|
||||||
|
null,
|
||||||
|
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger?.LogError(
|
||||||
|
ex,
|
||||||
|
"Failed to send join link to Discord channel {ChannelId} for session {SessionId}",
|
||||||
|
channelId,
|
||||||
|
notification.SessionId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendDirectSessionNotificationAsync(
|
public async Task SendDirectSessionNotificationAsync(
|
||||||
@@ -272,14 +338,16 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
|||||||
? "—"
|
? "—"
|
||||||
: string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User)));
|
: string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User)));
|
||||||
|
|
||||||
return new EmbedProperties()
|
var embed = new EmbedProperties()
|
||||||
.WithTitle($"Ссылка на игру: {notification.Title}")
|
.WithTitle($"Ссылка на игру: {notification.Title}")
|
||||||
.WithDescription(
|
.WithDescription(
|
||||||
$"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" +
|
$"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" +
|
||||||
$"Ссылка: {notification.JoinLink}\n\n" +
|
$"Ссылка: {notification.JoinLink}\n\n" +
|
||||||
$"Участники: {mentions}")
|
$"Участники: {mentions}")
|
||||||
.WithUrl(notification.JoinLink)
|
|
||||||
.WithColor(new Color(0x57F287));
|
.WithColor(new Color(0x57F287));
|
||||||
|
|
||||||
|
var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(notification.JoinLink);
|
||||||
|
return embedUrl is null ? embed : embed.WithUrl(embedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<ActionRowProperties> BuildRsvpRows(Guid sessionId, bool disabled)
|
private static IReadOnlyList<ActionRowProperties> BuildRsvpRows(Guid sessionId, bool disabled)
|
||||||
@@ -367,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
|||||||
|
|
||||||
builder.Services.AddSingleton<DiscordPermissionChecker>();
|
builder.Services.AddSingleton<DiscordPermissionChecker>();
|
||||||
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
||||||
|
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,43 @@
|
|||||||
|
namespace GmRelay.DiscordBot.Rendering;
|
||||||
|
|
||||||
|
public static class DiscordEmbedUrls
|
||||||
|
{
|
||||||
|
public static string? NormalizeHttpUrl(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var candidate = value.Trim();
|
||||||
|
if (IsSupportedHttpUrl(candidate, out var normalized))
|
||||||
|
return normalized;
|
||||||
|
|
||||||
|
if (candidate.Contains("://", StringComparison.Ordinal))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return IsSupportedHttpUrl($"https://{candidate}", out normalized)
|
||||||
|
&& HasPublicHost(normalized)
|
||||||
|
? normalized
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSupportedHttpUrl(string value, out string normalized)
|
||||||
|
{
|
||||||
|
normalized = string.Empty;
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = uri.ToString();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasPublicHost(string value) =>
|
||||||
|
Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||||
|
&& uri.Host.Contains('.', StringComparison.Ordinal);
|
||||||
|
}
|
||||||
@@ -70,9 +70,10 @@ public static class DiscordSessionBatchRenderer
|
|||||||
.WithInline()
|
.WithInline()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(session.JoinLink))
|
var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(session.JoinLink);
|
||||||
|
if (embedUrl is not null)
|
||||||
{
|
{
|
||||||
embed = embed.WithUrl(session.JoinLink);
|
embed = embed.WithUrl(embedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
embed = embed.WithColor(GetColor(session));
|
embed = embed.WithColor(GetColor(session));
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ public sealed class HandleRsvpHandler(
|
|||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
WHERE sp.session_id = @SessionId
|
WHERE sp.session_id = @SessionId
|
||||||
AND COALESCE(p.platform, 'Telegram') = @Platform
|
AND p.platform = @Platform
|
||||||
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @ExternalUserId
|
AND p.external_user_id = @ExternalUserId
|
||||||
AND sp.is_gm = false
|
AND sp.is_gm = false
|
||||||
AND sp.registration_status = @Active
|
AND sp.registration_status = @Active
|
||||||
)
|
)
|
||||||
@@ -90,8 +90,8 @@ public sealed class HandleRsvpHandler(
|
|||||||
AND player_id = (
|
AND player_id = (
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM players
|
FROM players
|
||||||
WHERE COALESCE(platform, 'Telegram') = @Platform
|
WHERE platform = @Platform
|
||||||
AND COALESCE(external_user_id, telegram_id::TEXT) = @ExternalUserId
|
AND external_user_id = @ExternalUserId
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
)
|
)
|
||||||
AND registration_status = @Active
|
AND registration_status = @Active
|
||||||
@@ -265,10 +265,10 @@ public sealed class HandleRsvpHandler(
|
|||||||
|
|
||||||
var participants = (await connection.QueryAsync<ParticipantRsvpRow>(
|
var participants = (await connection.QueryAsync<ParticipantRsvpRow>(
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
SELECT p.platform AS Platform,
|
||||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
p.external_user_id AS ExternalUserId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
p.external_username AS ExternalUsername,
|
||||||
sp.rsvp_status AS RsvpStatus,
|
sp.rsvp_status AS RsvpStatus,
|
||||||
sp.registration_status AS RegistrationStatus,
|
sp.registration_status AS RegistrationStatus,
|
||||||
sp.is_gm AS IsGm
|
sp.is_gm AS IsGm
|
||||||
@@ -312,23 +312,13 @@ public sealed class HandleRsvpHandler(
|
|||||||
var rows = await connection.QueryAsync<RsvpRecipientRow>(
|
var rows = await connection.QueryAsync<RsvpRecipientRow>(
|
||||||
"""
|
"""
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
COALESCE(p.platform, 'Telegram') AS Platform,
|
p.platform AS Platform,
|
||||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
p.external_user_id AS ExternalUserId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
|
p.external_username AS ExternalUsername
|
||||||
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
|
||||||
WHERE gm.group_id = @GroupId
|
WHERE gm.group_id = @GroupId
|
||||||
UNION
|
|
||||||
SELECT DISTINCT
|
|
||||||
COALESCE(p.platform, 'Telegram') AS Platform,
|
|
||||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
|
||||||
p.display_name AS DisplayName,
|
|
||||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
|
|
||||||
FROM game_groups g
|
|
||||||
JOIN players p ON p.telegram_id = g.gm_telegram_id
|
|
||||||
WHERE g.id = @GroupId
|
|
||||||
AND g.gm_telegram_id IS NOT NULL
|
|
||||||
""",
|
""",
|
||||||
new { GroupId = groupId },
|
new { GroupId = groupId },
|
||||||
transaction);
|
transaction);
|
||||||
|
|||||||
+6
-6
@@ -45,10 +45,10 @@ public sealed class SendConfirmationHandler(
|
|||||||
s.title,
|
s.title,
|
||||||
s.scheduled_at AS ScheduledAt,
|
s.scheduled_at AS ScheduledAt,
|
||||||
s.group_id AS GroupId,
|
s.group_id AS GroupId,
|
||||||
COALESCE(g.platform, 'Telegram') AS Platform,
|
g.platform AS Platform,
|
||||||
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
|
g.external_group_id AS ExternalGroupId,
|
||||||
g.name AS DisplayName,
|
g.name AS DisplayName,
|
||||||
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
|
g.external_channel_id AS ExternalChannelId,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
@@ -65,10 +65,10 @@ public sealed class SendConfirmationHandler(
|
|||||||
|
|
||||||
var participants = (await connection.QueryAsync<ConfirmationParticipantRow>(
|
var participants = (await connection.QueryAsync<ConfirmationParticipantRow>(
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
SELECT p.platform AS Platform,
|
||||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
p.external_user_id AS ExternalUserId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
p.external_username AS ExternalUsername,
|
||||||
sp.rsvp_status AS RsvpStatus,
|
sp.rsvp_status AS RsvpStatus,
|
||||||
sp.registration_status AS RegistrationStatus,
|
sp.registration_status AS RegistrationStatus,
|
||||||
sp.is_gm AS IsGm
|
sp.is_gm AS IsGm
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ public sealed class SendJoinLinkHandler(
|
|||||||
s.title,
|
s.title,
|
||||||
s.join_link AS JoinLink,
|
s.join_link AS JoinLink,
|
||||||
s.scheduled_at AS ScheduledAt,
|
s.scheduled_at AS ScheduledAt,
|
||||||
COALESCE(g.platform, 'Telegram') AS Platform,
|
g.platform AS Platform,
|
||||||
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
|
g.external_group_id AS ExternalGroupId,
|
||||||
g.name AS DisplayName,
|
g.name AS DisplayName,
|
||||||
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
|
g.external_channel_id AS ExternalChannelId,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
@@ -58,14 +58,14 @@ public sealed class SendJoinLinkHandler(
|
|||||||
WHERE s.id = @SessionId
|
WHERE s.id = @SessionId
|
||||||
AND s.status = @Confirmed
|
AND s.status = @Confirmed
|
||||||
AND (
|
AND (
|
||||||
(COALESCE(g.platform, 'Telegram') = 'Telegram' AND s.link_message_id IS NULL)
|
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
|
||||||
OR (
|
OR (
|
||||||
COALESCE(g.platform, 'Telegram') <> 'Telegram'
|
g.platform <> 'Telegram'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM platform_messages pm
|
FROM platform_messages pm
|
||||||
WHERE pm.session_id = s.id
|
WHERE pm.session_id = s.id
|
||||||
AND pm.platform = COALESCE(g.platform, 'Telegram')
|
AND pm.platform = g.platform
|
||||||
AND pm.purpose = 'join_link'
|
AND pm.purpose = 'join_link'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -81,10 +81,10 @@ public sealed class SendJoinLinkHandler(
|
|||||||
|
|
||||||
var players = (await connection.QueryAsync<JoinLinkPlayerRow>(
|
var players = (await connection.QueryAsync<JoinLinkPlayerRow>(
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
SELECT p.platform AS Platform,
|
||||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
p.external_user_id AS ExternalUserId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
p.external_username AS ExternalUsername,
|
||||||
sp.rsvp_status AS RsvpStatus,
|
sp.rsvp_status AS RsvpStatus,
|
||||||
sp.registration_status AS RegistrationStatus,
|
sp.registration_status AS RegistrationStatus,
|
||||||
sp.is_gm AS IsGm
|
sp.is_gm AS IsGm
|
||||||
|
|||||||
+3
-3
@@ -56,10 +56,10 @@ public sealed class SendOneHourReminderHandler(
|
|||||||
|
|
||||||
var recipients = (await connection.QueryAsync<OneHourReminderRecipientRow>(
|
var recipients = (await connection.QueryAsync<OneHourReminderRecipientRow>(
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
SELECT p.platform AS Platform,
|
||||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
p.external_user_id AS ExternalUserId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
|
p.external_username AS ExternalUsername
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
WHERE sp.session_id = @SessionId
|
WHERE sp.session_id = @SessionId
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed record CreateSessionCommand(
|
||||||
|
PlatformUser User,
|
||||||
|
PlatformGroup Group,
|
||||||
|
string Title,
|
||||||
|
string Link,
|
||||||
|
IReadOnlyList<DateTimeOffset> ScheduledTimes,
|
||||||
|
int? MaxPlayers,
|
||||||
|
string? ImageReference);
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
|
||||||
|
|
||||||
|
public sealed class CreateSessionHandler(
|
||||||
|
NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
public async Task<CreateSessionResult> HandleAsync(CreateSessionCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var platform = command.User.Platform.ToString();
|
||||||
|
var externalUserId = command.User.ExternalUserId;
|
||||||
|
var displayName = command.User.DisplayName;
|
||||||
|
var externalUsername = command.User.ExternalUsername;
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO players (display_name, platform, external_user_id, external_username)
|
||||||
|
VALUES (@Name, @Platform, @ExternalId, @Username)
|
||||||
|
ON CONFLICT (platform, external_user_id)
|
||||||
|
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
||||||
|
DO UPDATE
|
||||||
|
SET display_name = EXCLUDED.display_name,
|
||||||
|
external_username = EXCLUDED.external_username;
|
||||||
|
""",
|
||||||
|
new { ExternalId = externalUserId, Name = displayName, Username = externalUsername },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
||||||
|
"""
|
||||||
|
SELECT g.id AS GroupId,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
WHERE gm.group_id = g.id
|
||||||
|
AND p.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalGmId
|
||||||
|
) AS CanManage
|
||||||
|
FROM game_groups g
|
||||||
|
WHERE g.platform = @Platform
|
||||||
|
AND g.external_group_id = @ExternalGroupId
|
||||||
|
""",
|
||||||
|
new { Platform = platform, ExternalGroupId = command.Group.ExternalGroupId, ExternalGmId = externalUserId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
Guid groupId;
|
||||||
|
if (existingGroup is null)
|
||||||
|
{
|
||||||
|
groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
|
"""
|
||||||
|
INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
|
||||||
|
VALUES (@ChatName, @Platform, @ExternalGroupId, @ExternalChannelId)
|
||||||
|
RETURNING id;
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Platform = platform,
|
||||||
|
ExternalGroupId = command.Group.ExternalGroupId,
|
||||||
|
ExternalChannelId = command.Group.ExternalChannelId,
|
||||||
|
ChatName = command.Group.DisplayName
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO group_managers (group_id, player_id, role)
|
||||||
|
SELECT @GroupId, p.id, @OwnerRole
|
||||||
|
FROM players p
|
||||||
|
WHERE p.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalGmId
|
||||||
|
ON CONFLICT (group_id, player_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
new { GroupId = groupId, ExternalGmId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!existingGroup.CanManage)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
return new CreateSessionResult(
|
||||||
|
false,
|
||||||
|
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
Array.Empty<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
groupId = existingGroup.GroupId;
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE game_groups
|
||||||
|
SET name = @ChatName
|
||||||
|
WHERE id = @GroupId
|
||||||
|
""",
|
||||||
|
new { ChatName = command.Group.DisplayName, GroupId = groupId },
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
var batchId = Guid.NewGuid();
|
||||||
|
var sessions = new List<SessionBatchDto>();
|
||||||
|
var orderedTimes = command.ScheduledTimes.OrderBy(v => v).ToList();
|
||||||
|
|
||||||
|
foreach (var scheduledAt in orderedTimes)
|
||||||
|
{
|
||||||
|
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
|
"""
|
||||||
|
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
|
||||||
|
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
|
||||||
|
RETURNING id;
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
BatchId = batchId,
|
||||||
|
GroupId = groupId,
|
||||||
|
command.Title,
|
||||||
|
Link = command.Link,
|
||||||
|
ScheduledAt = scheduledAt,
|
||||||
|
Status = SessionStatus.Planned,
|
||||||
|
MaxPlayers = command.MaxPlayers
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, command.MaxPlayers, command.Link));
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||||
|
|
||||||
|
return new CreateSessionResult(
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
view,
|
||||||
|
batchId,
|
||||||
|
groupId,
|
||||||
|
Array.Empty<string>());
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed record CreateSessionResult(
|
||||||
|
bool Success,
|
||||||
|
string? ErrorMessage,
|
||||||
|
SessionBatchViewModel? View,
|
||||||
|
Guid? BatchId,
|
||||||
|
Guid? GroupId,
|
||||||
|
IReadOnlyList<string> Warnings);
|
||||||
@@ -13,7 +13,12 @@ public sealed record JoinSessionCommand(
|
|||||||
PlatformUser User,
|
PlatformUser User,
|
||||||
string InteractionId,
|
string InteractionId,
|
||||||
PlatformGroup Group,
|
PlatformGroup Group,
|
||||||
PlatformMessageRef ScheduleMessage);
|
PlatformMessageRef ScheduleMessage,
|
||||||
|
bool DeferScheduleUpdate = false);
|
||||||
|
|
||||||
|
public sealed record SessionInteractionResult(
|
||||||
|
string ReplyText,
|
||||||
|
SessionBatchViewModel? UpdatedView = null);
|
||||||
|
|
||||||
// DTOs for AOT compilation
|
// DTOs for AOT compilation
|
||||||
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
|
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
|
||||||
@@ -24,7 +29,7 @@ public sealed class JoinSessionHandler(
|
|||||||
IScheduleMessageUpdateLock scheduleUpdateLock,
|
IScheduleMessageUpdateLock scheduleUpdateLock,
|
||||||
ILogger<JoinSessionHandler> logger)
|
ILogger<JoinSessionHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
|
public async Task<SessionInteractionResult> HandleAsync(JoinSessionCommand command, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
@@ -35,30 +40,19 @@ public sealed class JoinSessionHandler(
|
|||||||
{
|
{
|
||||||
// 1. Убеждаемся, что игрок есть в базе
|
// 1. Убеждаемся, что игрок есть в базе
|
||||||
var platform = command.User.Platform.ToString();
|
var platform = command.User.Platform.ToString();
|
||||||
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
|
|
||||||
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
|
|
||||||
: (long?)null;
|
|
||||||
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
|
|
||||||
? command.User.ExternalUsername
|
|
||||||
: null;
|
|
||||||
|
|
||||||
var playerId = await connection.ExecuteScalarAsync<Guid>(
|
var playerId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
|
||||||
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
|
VALUES (@Name, @Platform, @ExternalUserId, @ExternalUsername)
|
||||||
ON CONFLICT (platform, external_user_id)
|
ON CONFLICT (platform, external_user_id)
|
||||||
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
||||||
DO UPDATE
|
DO UPDATE
|
||||||
SET display_name = EXCLUDED.display_name,
|
SET display_name = EXCLUDED.display_name,
|
||||||
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
|
|
||||||
platform = EXCLUDED.platform,
|
|
||||||
external_user_id = EXCLUDED.external_user_id,
|
|
||||||
external_username = EXCLUDED.external_username
|
external_username = EXCLUDED.external_username
|
||||||
RETURNING id;",
|
RETURNING id;",
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
LegacyTelegramId = legacyTelegramId,
|
|
||||||
Name = command.User.DisplayName,
|
Name = command.User.DisplayName,
|
||||||
LegacyTelegramUsername = legacyTelegramUsername,
|
|
||||||
Platform = platform,
|
Platform = platform,
|
||||||
command.User.ExternalUserId,
|
command.User.ExternalUserId,
|
||||||
command.User.ExternalUsername
|
command.User.ExternalUsername
|
||||||
@@ -77,15 +71,13 @@ public sealed class JoinSessionHandler(
|
|||||||
if (batchInfo is null)
|
if (batchInfo is null)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SessionStatus.IsCancelled(batchInfo.Status))
|
if (SessionStatus.IsCancelled(batchInfo.Status))
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
|
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
|
||||||
@@ -105,8 +97,7 @@ public sealed class JoinSessionHandler(
|
|||||||
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||||
? "Вы уже в листе ожидания!"
|
? "Вы уже в листе ожидания!"
|
||||||
: "Вы уже записаны!";
|
: "Вы уже записаны!";
|
||||||
await AnswerAsync(command.InteractionId, alreadyText, ct);
|
return await AnswerAsync(command.InteractionId, alreadyText, ct);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var activeParticipants = await connection.ExecuteScalarAsync<int>(
|
var activeParticipants = await connection.ExecuteScalarAsync<int>(
|
||||||
@@ -139,8 +130,7 @@ public sealed class JoinSessionHandler(
|
|||||||
if (inserted == 0)
|
if (inserted == 0)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
|
return await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем весь батч для перерисовки
|
// Загружаем весь батч для перерисовки
|
||||||
@@ -154,7 +144,7 @@ public sealed class JoinSessionHandler(
|
|||||||
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
|
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
@"SELECT sp.session_id as SessionId,
|
@"SELECT sp.session_id as SessionId,
|
||||||
p.display_name as DisplayName,
|
p.display_name as DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
|
p.external_username as TelegramUsername,
|
||||||
sp.registration_status as RegistrationStatus
|
sp.registration_status as RegistrationStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
@@ -168,17 +158,20 @@ public sealed class JoinSessionHandler(
|
|||||||
|
|
||||||
// 4. Перерисовываем сообщение
|
// 4. Перерисовываем сообщение
|
||||||
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||||
await messenger.UpdateScheduleAsync(
|
if (!command.DeferScheduleUpdate)
|
||||||
new PlatformScheduleMessage(
|
{
|
||||||
command.Group,
|
await messenger.UpdateScheduleAsync(
|
||||||
view,
|
new PlatformScheduleMessage(
|
||||||
command.ScheduleMessage),
|
command.Group,
|
||||||
ct);
|
view,
|
||||||
|
command.ScheduleMessage),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
|
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||||
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
||||||
: "Вы успешно записаны!";
|
: "Вы успешно записаны!";
|
||||||
await AnswerAsync(command.InteractionId, callbackText, ct);
|
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -191,10 +184,17 @@ public sealed class JoinSessionHandler(
|
|||||||
var errorText = transactionCommitted
|
var errorText = transactionCommitted
|
||||||
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
|
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
|
||||||
: "Произошла ошибка при регистрации.";
|
: "Произошла ошибка при регистрации.";
|
||||||
await AnswerAsync(command.InteractionId, errorText, ct);
|
return await AnswerAsync(command.InteractionId, errorText, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
|
private async Task<SessionInteractionResult> AnswerAsync(
|
||||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
string interactionId,
|
||||||
|
string text,
|
||||||
|
CancellationToken ct,
|
||||||
|
SessionBatchViewModel? updatedView = null)
|
||||||
|
{
|
||||||
|
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||||
|
return new SessionInteractionResult(text, updatedView);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ public sealed record LeaveSessionCommand(
|
|||||||
PlatformUser User,
|
PlatformUser User,
|
||||||
string InteractionId,
|
string InteractionId,
|
||||||
PlatformGroup Group,
|
PlatformGroup Group,
|
||||||
PlatformMessageRef ScheduleMessage);
|
PlatformMessageRef ScheduleMessage,
|
||||||
|
bool DeferScheduleUpdate = false);
|
||||||
|
|
||||||
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
|
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
|
||||||
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
|
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
|
||||||
@@ -24,7 +25,7 @@ public sealed class LeaveSessionHandler(
|
|||||||
IScheduleMessageUpdateLock scheduleUpdateLock,
|
IScheduleMessageUpdateLock scheduleUpdateLock,
|
||||||
ILogger<LeaveSessionHandler> logger)
|
ILogger<LeaveSessionHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
|
public async Task<SessionInteractionResult> HandleAsync(LeaveSessionCommand command, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
@@ -49,15 +50,13 @@ public sealed class LeaveSessionHandler(
|
|||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SessionStatus.IsCancelled(session.Status))
|
if (SessionStatus.IsCancelled(session.Status))
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var platform = command.User.Platform.ToString();
|
var platform = command.User.Platform.ToString();
|
||||||
@@ -81,8 +80,7 @@ public sealed class LeaveSessionHandler(
|
|||||||
if (participant is null)
|
if (participant is null)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
|
return await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
@@ -175,7 +173,7 @@ public sealed class LeaveSessionHandler(
|
|||||||
"""
|
"""
|
||||||
SELECT sp.session_id AS SessionId,
|
SELECT sp.session_id AS SessionId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
sp.registration_status AS RegistrationStatus
|
sp.registration_status AS RegistrationStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
@@ -190,12 +188,15 @@ public sealed class LeaveSessionHandler(
|
|||||||
transactionCommitted = true;
|
transactionCommitted = true;
|
||||||
|
|
||||||
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
||||||
await messenger.UpdateScheduleAsync(
|
if (!command.DeferScheduleUpdate)
|
||||||
new PlatformScheduleMessage(
|
{
|
||||||
command.Group,
|
await messenger.UpdateScheduleAsync(
|
||||||
view,
|
new PlatformScheduleMessage(
|
||||||
command.ScheduleMessage),
|
command.Group,
|
||||||
ct);
|
view,
|
||||||
|
command.ScheduleMessage),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||||
? "Вы удалены из листа ожидания."
|
? "Вы удалены из листа ожидания."
|
||||||
@@ -203,7 +204,7 @@ public sealed class LeaveSessionHandler(
|
|||||||
? "Вы отписались от сессии."
|
? "Вы отписались от сессии."
|
||||||
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
|
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
|
||||||
|
|
||||||
await AnswerAsync(command.InteractionId, callbackText, ct);
|
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -216,10 +217,17 @@ public sealed class LeaveSessionHandler(
|
|||||||
var errorText = transactionCommitted
|
var errorText = transactionCommitted
|
||||||
? "Запись снята, но не удалось обновить сообщение расписания."
|
? "Запись снята, но не удалось обновить сообщение расписания."
|
||||||
: "Произошла ошибка при отмене записи.";
|
: "Произошла ошибка при отмене записи.";
|
||||||
await AnswerAsync(command.InteractionId, errorText, ct);
|
return await AnswerAsync(command.InteractionId, errorText, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
|
private async Task<SessionInteractionResult> AnswerAsync(
|
||||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
string interactionId,
|
||||||
|
string text,
|
||||||
|
CancellationToken ct,
|
||||||
|
SessionBatchViewModel? updatedView = null)
|
||||||
|
{
|
||||||
|
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||||
|
return new SessionInteractionResult(text, updatedView);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
|
||||||
|
|
||||||
|
public sealed record ExportCalendarCommand(
|
||||||
|
PlatformGroup Group,
|
||||||
|
PlatformUser User);
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
|
||||||
|
|
||||||
|
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
||||||
|
|
||||||
|
public sealed class ExportCalendarHandler(
|
||||||
|
NpgsqlDataSource dataSource,
|
||||||
|
IPlatformMessenger messenger,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
public async Task HandleAsync(ExportCalendarCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
|
||||||
|
var sessions = await connection.QueryAsync<CalendarSessionDto>(
|
||||||
|
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
|
||||||
|
+ " FROM sessions s"
|
||||||
|
+ " JOIN game_groups g ON s.group_id = g.id"
|
||||||
|
+ " WHERE g.platform = @Platform"
|
||||||
|
+ " AND g.external_group_id = @ExternalGroupId"
|
||||||
|
+ " AND s.status = @Planned"
|
||||||
|
+ " AND s.scheduled_at > NOW()"
|
||||||
|
+ " ORDER BY s.scheduled_at ASC",
|
||||||
|
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId, Planned = SessionStatus.Planned });
|
||||||
|
|
||||||
|
var sessionsList = sessions.ToList();
|
||||||
|
|
||||||
|
if (sessionsList.Count == 0)
|
||||||
|
{
|
||||||
|
await messenger.SendGroupMessageAsync(
|
||||||
|
command.Group,
|
||||||
|
"📭 У этой группы нет запланированных сессий для экспорта.",
|
||||||
|
cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("BEGIN:VCALENDAR");
|
||||||
|
sb.AppendLine("VERSION:2.0");
|
||||||
|
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
|
||||||
|
|
||||||
|
foreach (var s in sessionsList)
|
||||||
|
{
|
||||||
|
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
|
||||||
|
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
|
||||||
|
|
||||||
|
sb.AppendLine("BEGIN:VEVENT");
|
||||||
|
sb.AppendLine($"UID:{s.Id}@gmrelay");
|
||||||
|
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
|
||||||
|
sb.AppendLine($"DTSTART:{dtStart}");
|
||||||
|
sb.AppendLine($"DTEND:{dtEnd}");
|
||||||
|
sb.AppendLine($"SUMMARY:{s.Title}");
|
||||||
|
sb.AppendLine("END:VEVENT");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine("END:VCALENDAR");
|
||||||
|
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||||
|
|
||||||
|
// Create calendar subscription
|
||||||
|
string? subscriptionUrl = null;
|
||||||
|
var baseUrl = configuration["Web:BaseUrl"];
|
||||||
|
var senderId = command.User.ExternalUserId;
|
||||||
|
if (!string.IsNullOrWhiteSpace(baseUrl) && !string.IsNullOrWhiteSpace(senderId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = Guid.NewGuid().ToString("N");
|
||||||
|
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
|
||||||
|
@"SELECT id FROM game_groups WHERE platform = @Platform AND external_group_id = @ExternalGroupId",
|
||||||
|
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId });
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
@"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at)
|
||||||
|
VALUES (gen_random_uuid(), @token, @userPlatform, @userExternalId, @groupId, @filterType, now(), NULL)",
|
||||||
|
new { token, userPlatform = command.Group.Platform.ToString(), userExternalId = senderId, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
|
||||||
|
|
||||||
|
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Non-critical: if subscription creation fails, still send the file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var actions = subscriptionUrl is not null
|
||||||
|
? new[]
|
||||||
|
{
|
||||||
|
new PlatformMessageAction(
|
||||||
|
"calendar-subscription",
|
||||||
|
"🔗 Подписаться на календарь",
|
||||||
|
subscriptionUrl)
|
||||||
|
}
|
||||||
|
: Array.Empty<PlatformMessageAction>();
|
||||||
|
|
||||||
|
await messenger.SendCalendarFileAsync(
|
||||||
|
new PlatformCalendarFile(
|
||||||
|
command.Group,
|
||||||
|
"schedule.ics",
|
||||||
|
bytes,
|
||||||
|
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
||||||
|
actions),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
|
internal sealed record DeleteSessionInfoDto(
|
||||||
|
string Title,
|
||||||
|
Guid BatchId,
|
||||||
|
Guid GroupId,
|
||||||
|
bool CanManage,
|
||||||
|
int? ThreadId,
|
||||||
|
bool TopicCreatedByBot);
|
||||||
|
|
||||||
|
public sealed record DeleteSessionResult(
|
||||||
|
bool Success,
|
||||||
|
string? ReplyText,
|
||||||
|
string? Title,
|
||||||
|
Guid? GroupId,
|
||||||
|
int? ThreadId,
|
||||||
|
bool TopicCreatedByBot,
|
||||||
|
int RemainingInTopic);
|
||||||
|
|
||||||
|
public sealed class DeleteSessionHandler(
|
||||||
|
NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
public async Task<DeleteSessionResult> HandleAsync(DeleteSessionCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
// 1. Fetch session and verify group manager.
|
||||||
|
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
|
||||||
|
"""
|
||||||
|
SELECT s.title AS Title,
|
||||||
|
s.batch_id AS BatchId,
|
||||||
|
s.group_id AS GroupId,
|
||||||
|
s.thread_id AS ThreadId,
|
||||||
|
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
WHERE gm.group_id = s.group_id
|
||||||
|
AND p.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalUserId
|
||||||
|
) AS CanManage
|
||||||
|
FROM sessions s
|
||||||
|
WHERE s.id = @SessionId
|
||||||
|
""",
|
||||||
|
new { command.SessionId, Platform = command.User.Platform.ToString(), ExternalUserId = command.User.ExternalUserId }, transaction);
|
||||||
|
|
||||||
|
if (session == null)
|
||||||
|
{
|
||||||
|
return new DeleteSessionResult(false, "Сессия не найдена.", null, null, null, false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.CanManage)
|
||||||
|
{
|
||||||
|
return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete session
|
||||||
|
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
||||||
|
|
||||||
|
var remainingInTopic = session.ThreadId.HasValue
|
||||||
|
? await connection.ExecuteScalarAsync<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM sessions
|
||||||
|
WHERE group_id = @GroupId
|
||||||
|
AND thread_id = @ThreadId
|
||||||
|
""",
|
||||||
|
new { session.GroupId, ThreadId = session.ThreadId.Value },
|
||||||
|
transaction)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
return new DeleteSessionResult(
|
||||||
|
true,
|
||||||
|
"Сессия удалена!",
|
||||||
|
session.Title,
|
||||||
|
session.GroupId,
|
||||||
|
session.ThreadId,
|
||||||
|
session.TopicCreatedByBot,
|
||||||
|
remainingInTopic);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
|
public sealed record ListSessionsCommand(
|
||||||
|
PlatformGroup Group,
|
||||||
|
PlatformUser User);
|
||||||
|
|
||||||
|
public sealed record DeleteSessionCommand(
|
||||||
|
Guid SessionId,
|
||||||
|
PlatformUser User,
|
||||||
|
PlatformGroup Group,
|
||||||
|
PlatformMessageRef ScheduleMessage);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
|
public sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
|
||||||
|
|
||||||
|
public sealed record SessionListResult(
|
||||||
|
IReadOnlyList<SessionListItemDto> Sessions,
|
||||||
|
bool CanManage);
|
||||||
|
|
||||||
|
public sealed class ListSessionsHandler(
|
||||||
|
NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
public async Task<SessionListResult> HandleAsync(ListSessionsCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
|
||||||
|
var sessions = await connection.QueryAsync<SessionListItemDto>(
|
||||||
|
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
|
||||||
|
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
||||||
|
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||||
|
WHERE gm.group_id = s.group_id
|
||||||
|
AND manager_player.platform = @Platform
|
||||||
|
AND manager_player.external_user_id = @ExternalUserId
|
||||||
|
) AS CanManage
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON s.group_id = g.id
|
||||||
|
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||||
|
WHERE g.platform = @Platform
|
||||||
|
AND g.external_group_id = @ExternalGroupId
|
||||||
|
AND s.status != @Cancelled
|
||||||
|
AND s.scheduled_at > NOW()
|
||||||
|
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
|
||||||
|
ORDER BY s.scheduled_at ASC",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Platform = command.Group.Platform.ToString(),
|
||||||
|
ExternalGroupId = command.Group.ExternalGroupId,
|
||||||
|
ExternalUserId = command.User.ExternalUserId,
|
||||||
|
Cancelled = SessionStatus.Cancelled,
|
||||||
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
|
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||||
|
});
|
||||||
|
|
||||||
|
var sessionsList = sessions.ToList();
|
||||||
|
var canManage = sessionsList.Count > 0 && sessionsList.First().CanManage;
|
||||||
|
|
||||||
|
return new SessionListResult(sessionsList, canManage);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
internal sealed record AwaitingProposalDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid SessionId,
|
||||||
|
string Title,
|
||||||
|
DateTime CurrentScheduledAt,
|
||||||
|
Guid BatchId,
|
||||||
|
int? BatchMessageId,
|
||||||
|
string ExternalGroupId,
|
||||||
|
int? ThreadId,
|
||||||
|
string NotificationMode);
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
public sealed record HandleRescheduleTimeInputCommand(
|
||||||
|
PlatformUser User,
|
||||||
|
PlatformGroup Group,
|
||||||
|
string Text);
|
||||||
|
|
||||||
|
public sealed record HandleRescheduleVoteCommand(
|
||||||
|
Guid OptionId,
|
||||||
|
PlatformUser User,
|
||||||
|
PlatformGroup Group,
|
||||||
|
string InteractionId,
|
||||||
|
PlatformMessageRef ScheduleMessage);
|
||||||
+181
@@ -0,0 +1,181 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
public sealed class HandleRescheduleTimeInputHandler(
|
||||||
|
NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
public async Task<HandleRescheduleTimeInputResult> HandleAsync(
|
||||||
|
HandleRescheduleTimeInputCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
|
var platform = command.User.Platform.ToString();
|
||||||
|
var externalGmId = command.User.ExternalUserId;
|
||||||
|
var externalGroupId = command.Group.ExternalGroupId;
|
||||||
|
|
||||||
|
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
|
||||||
|
"""
|
||||||
|
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
||||||
|
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||||
|
g.external_group_id AS ExternalGroupId,
|
||||||
|
s.thread_id AS ThreadId,
|
||||||
|
s.notification_mode AS NotificationMode
|
||||||
|
FROM reschedule_proposals rp
|
||||||
|
JOIN sessions s ON s.id = rp.session_id
|
||||||
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
|
WHERE rp.proposed_by_external_user_id = @ExternalGmId
|
||||||
|
AND rp.status = 'AwaitingTime'
|
||||||
|
AND g.platform = @Platform
|
||||||
|
AND g.external_group_id = @ExternalGroupId
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM group_managers gm
|
||||||
|
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||||
|
WHERE gm.group_id = s.group_id
|
||||||
|
AND manager_player.platform = @Platform
|
||||||
|
AND manager_player.external_user_id = @ExternalGmId
|
||||||
|
)
|
||||||
|
ORDER BY rp.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
new { ExternalGmId = externalGmId, Platform = platform, ExternalGroupId = externalGroupId });
|
||||||
|
|
||||||
|
if (proposal is null)
|
||||||
|
return new HandleRescheduleTimeInputResult(false, false, null, null, null, null, [], [], [], null, default, null);
|
||||||
|
|
||||||
|
if (!RescheduleVotingInput.TryParse(command.Text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
|
||||||
|
{
|
||||||
|
return new HandleRescheduleTimeInputResult(
|
||||||
|
true, false, parseError, null, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
||||||
|
"""
|
||||||
|
SELECT p.id AS PlayerId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.external_username AS TelegramUsername,
|
||||||
|
p.external_user_id::BIGINT AS TelegramId
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
|
""",
|
||||||
|
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||||||
|
|
||||||
|
if (participants.Count == 0)
|
||||||
|
{
|
||||||
|
var newTime = votingInput.Options[0];
|
||||||
|
var view = await RescheduleImmediatelyAsync(connection, proposal, newTime, ct);
|
||||||
|
var replyText =
|
||||||
|
$"""✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>""";
|
||||||
|
return new HandleRescheduleTimeInputResult(
|
||||||
|
true, true, replyText, view, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, proposal.BatchMessageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
var options = votingInput.Options
|
||||||
|
.Select((proposedAt, index) => new RescheduleOptionDto(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
index + 1,
|
||||||
|
proposedAt))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE reschedule_proposals
|
||||||
|
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @VoteChatId
|
||||||
|
WHERE id = @Id
|
||||||
|
""",
|
||||||
|
new { votingInput.Deadline, VoteChatId = externalGroupId, Id = proposal.Id },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
foreach (var option in options)
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
|
||||||
|
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
option.OptionId,
|
||||||
|
ProposalId = proposal.Id,
|
||||||
|
option.ProposedAt,
|
||||||
|
option.DisplayOrder
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
return new HandleRescheduleTimeInputResult(
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
proposal.Id,
|
||||||
|
votingInput.Deadline,
|
||||||
|
options,
|
||||||
|
participants,
|
||||||
|
[],
|
||||||
|
proposal.Title,
|
||||||
|
proposal.CurrentScheduledAt,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<SessionBatchViewModel?> RescheduleImmediatelyAsync(
|
||||||
|
NpgsqlConnection connection,
|
||||||
|
AwaitingProposalDto proposal,
|
||||||
|
DateTimeOffset newTime,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET scheduled_at = @NewTime,
|
||||||
|
status = @Status,
|
||||||
|
confirmation_message_id = NULL,
|
||||||
|
confirmation_sent_at = NULL,
|
||||||
|
one_hour_reminder_processed_at = NULL,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = @SessionId
|
||||||
|
""",
|
||||||
|
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
|
||||||
|
new { NewTime = newTime, Id = proposal.Id },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||||
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||||
|
new { proposal.BatchId })).ToList();
|
||||||
|
|
||||||
|
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
|
"""
|
||||||
|
SELECT sp.session_id AS SessionId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.external_username AS TelegramUsername,
|
||||||
|
sp.registration_status AS RegistrationStatus
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON sp.player_id = p.id
|
||||||
|
JOIN sessions s ON sp.session_id = s.id
|
||||||
|
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||||
|
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
|
||||||
|
""",
|
||||||
|
new { proposal.BatchId })).ToList();
|
||||||
|
|
||||||
|
return SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
public sealed record HandleRescheduleTimeInputResult(
|
||||||
|
bool Handled,
|
||||||
|
bool IsRescheduledImmediately,
|
||||||
|
string? ReplyText,
|
||||||
|
SessionBatchViewModel? UpdatedView,
|
||||||
|
Guid? ProposalId,
|
||||||
|
DateTimeOffset? VotingDeadlineAt,
|
||||||
|
IReadOnlyList<RescheduleOptionDto> Options,
|
||||||
|
IReadOnlyList<VoteParticipantDto> Participants,
|
||||||
|
IReadOnlyList<RescheduleOptionVoteDto> Votes,
|
||||||
|
string? Title,
|
||||||
|
DateTime CurrentScheduledAt,
|
||||||
|
int? BatchMessageId);
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
public sealed class HandleRescheduleVoteHandler(
|
||||||
|
NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
public async Task<HandleRescheduleVoteResult> HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
|
||||||
|
"""
|
||||||
|
SELECT rp.id AS Id,
|
||||||
|
rp.session_id AS SessionId,
|
||||||
|
rp.voting_deadline_at AS VotingDeadlineAt,
|
||||||
|
s.title AS Title,
|
||||||
|
s.scheduled_at AS CurrentScheduledAt
|
||||||
|
FROM reschedule_options ro
|
||||||
|
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
|
||||||
|
JOIN sessions s ON s.id = rp.session_id
|
||||||
|
WHERE ro.id = @OptionId AND rp.status = 'Voting'
|
||||||
|
""",
|
||||||
|
new { command.OptionId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (proposal is null)
|
||||||
|
{
|
||||||
|
return new HandleRescheduleVoteResult(
|
||||||
|
false,
|
||||||
|
"Голосование уже завершено или не найдено.",
|
||||||
|
null, null, null, default, default,
|
||||||
|
Array.Empty<VoteParticipantDto>(),
|
||||||
|
Array.Empty<RescheduleOptionDto>(),
|
||||||
|
Array.Empty<RescheduleOptionVoteDto>());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
return new HandleRescheduleVoteResult(
|
||||||
|
false,
|
||||||
|
"Дедлайн уже прошёл. Результаты скоро будут применены.",
|
||||||
|
null, null, null, default, default,
|
||||||
|
Array.Empty<VoteParticipantDto>(),
|
||||||
|
Array.Empty<RescheduleOptionDto>(),
|
||||||
|
Array.Empty<RescheduleOptionVoteDto>());
|
||||||
|
}
|
||||||
|
|
||||||
|
var playerId = await connection.ExecuteScalarAsync<Guid?>(
|
||||||
|
"""
|
||||||
|
SELECT p.id
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND p.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalUserId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
proposal.SessionId,
|
||||||
|
Platform = command.User.Platform.ToString(),
|
||||||
|
ExternalUserId = command.User.ExternalUserId,
|
||||||
|
Active = ParticipantRegistrationStatus.Active
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (playerId is null)
|
||||||
|
{
|
||||||
|
return new HandleRescheduleVoteResult(
|
||||||
|
false,
|
||||||
|
"Вы не являетесь участником этой сессии.",
|
||||||
|
null, null, null, default, default,
|
||||||
|
Array.Empty<VoteParticipantDto>(),
|
||||||
|
Array.Empty<RescheduleOptionDto>(),
|
||||||
|
Array.Empty<RescheduleOptionVoteDto>());
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
|
||||||
|
VALUES (@ProposalId, @PlayerId, @OptionId)
|
||||||
|
ON CONFLICT (proposal_id, player_id) DO UPDATE
|
||||||
|
SET option_id = EXCLUDED.option_id,
|
||||||
|
voted_at = now()
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ProposalId = proposal.Id,
|
||||||
|
PlayerId = playerId.Value,
|
||||||
|
command.OptionId
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
||||||
|
"""
|
||||||
|
SELECT p.id AS PlayerId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.external_username AS TelegramUsername,
|
||||||
|
p.external_user_id AS TelegramId
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
|
ORDER BY p.display_name
|
||||||
|
""",
|
||||||
|
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
|
var options = (await connection.QueryAsync<RescheduleOptionDto>(
|
||||||
|
"""
|
||||||
|
SELECT id AS OptionId,
|
||||||
|
display_order AS DisplayOrder,
|
||||||
|
proposed_at AS ProposedAt
|
||||||
|
FROM reschedule_options
|
||||||
|
WHERE proposal_id = @ProposalId
|
||||||
|
ORDER BY display_order
|
||||||
|
""",
|
||||||
|
new { ProposalId = proposal.Id },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
|
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
|
||||||
|
"""
|
||||||
|
SELECT rov.option_id AS OptionId,
|
||||||
|
p.id AS PlayerId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.external_username AS TelegramUsername
|
||||||
|
FROM reschedule_option_votes rov
|
||||||
|
JOIN players p ON p.id = rov.player_id
|
||||||
|
WHERE rov.proposal_id = @ProposalId
|
||||||
|
ORDER BY rov.voted_at, p.display_name
|
||||||
|
""",
|
||||||
|
new { ProposalId = proposal.Id },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
|
return new HandleRescheduleVoteResult(
|
||||||
|
true,
|
||||||
|
"Ваш голос учтён. До дедлайна его можно изменить.",
|
||||||
|
proposal.Id,
|
||||||
|
proposal.SessionId,
|
||||||
|
proposal.Title,
|
||||||
|
proposal.CurrentScheduledAt,
|
||||||
|
proposal.VotingDeadlineAt,
|
||||||
|
participants,
|
||||||
|
options,
|
||||||
|
votes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
public sealed record HandleRescheduleVoteResult(
|
||||||
|
bool Success,
|
||||||
|
string? ReplyText,
|
||||||
|
Guid? ProposalId,
|
||||||
|
Guid? SessionId,
|
||||||
|
string? Title,
|
||||||
|
DateTime CurrentScheduledAt,
|
||||||
|
DateTimeOffset VotingDeadlineAt,
|
||||||
|
IReadOnlyList<VoteParticipantDto> Participants,
|
||||||
|
IReadOnlyList<RescheduleOptionDto> Options,
|
||||||
|
IReadOnlyList<RescheduleOptionVoteDto> Votes);
|
||||||
@@ -78,8 +78,8 @@ public sealed class RescheduleVotingFinalizer(
|
|||||||
"""
|
"""
|
||||||
SELECT p.id AS PlayerId,
|
SELECT p.id AS PlayerId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
p.telegram_id AS TelegramId
|
p.external_user_id::BIGINT AS TelegramId
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
WHERE sp.session_id = @SessionId
|
WHERE sp.session_id = @SessionId
|
||||||
@@ -107,7 +107,7 @@ public sealed class RescheduleVotingFinalizer(
|
|||||||
SELECT rov.option_id AS OptionId,
|
SELECT rov.option_id AS OptionId,
|
||||||
p.id AS PlayerId,
|
p.id AS PlayerId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername
|
p.external_username AS TelegramUsername
|
||||||
FROM reschedule_option_votes rov
|
FROM reschedule_option_votes rov
|
||||||
JOIN players p ON p.id = rov.player_id
|
JOIN players p ON p.id = rov.player_id
|
||||||
WHERE rov.proposal_id = @ProposalId
|
WHERE rov.proposal_id = @ProposalId
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
|
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
|
||||||
using GmRelay.Shared.Features.Reminders.SendJoinLink;
|
using GmRelay.Shared.Features.Reminders.SendJoinLink;
|
||||||
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
||||||
@@ -20,6 +21,11 @@ public sealed class SessionSchedulerService(
|
|||||||
ILogger<SessionSchedulerService> logger) : BackgroundService
|
ILogger<SessionSchedulerService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
||||||
|
private static readonly TimeSpan BackoffDuration = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _confirmationBackoff = new();
|
||||||
|
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _oneHourBackoff = new();
|
||||||
|
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _joinLinkBackoff = new();
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
@@ -71,14 +77,30 @@ public sealed class SessionSchedulerService(
|
|||||||
|
|
||||||
foreach (var sessionId in sessionIds)
|
foreach (var sessionId in sessionIds)
|
||||||
{
|
{
|
||||||
|
if (_confirmationBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
|
||||||
|
{
|
||||||
|
logger.LogDebug(
|
||||||
|
"Skipping confirmation for session {SessionId} until {Backoff}",
|
||||||
|
sessionId,
|
||||||
|
backoffUntil);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await confirmationHandler.HandleAsync(sessionId, ct);
|
await confirmationHandler.HandleAsync(sessionId, ct);
|
||||||
|
_confirmationBackoff.TryRemove(sessionId, out _);
|
||||||
logger.LogInformation("Confirmation sent for session {SessionId}", sessionId);
|
logger.LogInformation("Confirmation sent for session {SessionId}", sessionId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to send confirmation for session {SessionId}", sessionId);
|
var nextAttempt = now.Add(BackoffDuration);
|
||||||
|
_confirmationBackoff[sessionId] = nextAttempt;
|
||||||
|
logger.LogError(
|
||||||
|
ex,
|
||||||
|
"Failed to send confirmation for session {SessionId}, backing off until {Backoff}",
|
||||||
|
sessionId,
|
||||||
|
nextAttempt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,14 +120,30 @@ public sealed class SessionSchedulerService(
|
|||||||
|
|
||||||
foreach (var sessionId in sessionIds)
|
foreach (var sessionId in sessionIds)
|
||||||
{
|
{
|
||||||
|
if (_oneHourBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
|
||||||
|
{
|
||||||
|
logger.LogDebug(
|
||||||
|
"Skipping one-hour reminder for session {SessionId} until {Backoff}",
|
||||||
|
sessionId,
|
||||||
|
backoffUntil);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await oneHourReminderHandler.HandleAsync(sessionId, ct);
|
await oneHourReminderHandler.HandleAsync(sessionId, ct);
|
||||||
|
_oneHourBackoff.TryRemove(sessionId, out _);
|
||||||
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
|
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId);
|
var nextAttempt = now.Add(BackoffDuration);
|
||||||
|
_oneHourBackoff[sessionId] = nextAttempt;
|
||||||
|
logger.LogError(
|
||||||
|
ex,
|
||||||
|
"Failed to process one-hour reminder for session {SessionId}, backing off until {Backoff}",
|
||||||
|
sessionId,
|
||||||
|
nextAttempt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,14 +163,30 @@ public sealed class SessionSchedulerService(
|
|||||||
|
|
||||||
foreach (var sessionId in sessionIds)
|
foreach (var sessionId in sessionIds)
|
||||||
{
|
{
|
||||||
|
if (_joinLinkBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
|
||||||
|
{
|
||||||
|
logger.LogDebug(
|
||||||
|
"Skipping join link for session {SessionId} until {Backoff}",
|
||||||
|
sessionId,
|
||||||
|
backoffUntil);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await joinLinkHandler.HandleAsync(sessionId, ct);
|
await joinLinkHandler.HandleAsync(sessionId, ct);
|
||||||
|
_joinLinkBackoff.TryRemove(sessionId, out _);
|
||||||
logger.LogInformation("Join link sent for session {SessionId}", sessionId);
|
logger.LogInformation("Join link sent for session {SessionId}", sessionId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to send join link for session {SessionId}", sessionId);
|
var nextAttempt = now.Add(BackoffDuration);
|
||||||
|
_joinLinkBackoff[sessionId] = nextAttempt;
|
||||||
|
logger.LogError(
|
||||||
|
ex,
|
||||||
|
"Failed to send join link for session {SessionId}, backing off until {Backoff}",
|
||||||
|
sessionId,
|
||||||
|
nextAttempt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,23 @@ namespace GmRelay.Shared.Platform;
|
|||||||
|
|
||||||
public interface IPlatformMessenger
|
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.0.9</div>
|
<div class="nav-version">v3.2.0</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
|
|||||||
@@ -44,9 +44,14 @@
|
|||||||
<div class="group-card-icon">🎮</div>
|
<div class="group-card-icon">🎮</div>
|
||||||
<h3 class="group-card-title">@group.Name</h3>
|
<h3 class="group-card-title">@group.Name</h3>
|
||||||
<p class="group-card-id">ID: @(group.Platform == "Discord" ? group.ExternalGroupId : group.TelegramChatId.ToString())</p>
|
<p class="group-card-id">ID: @(group.Platform == "Discord" ? group.ExternalGroupId : group.TelegramChatId.ToString())</p>
|
||||||
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;">
|
<div class="group-card-meta">
|
||||||
@FormatRole(group.ManagerRole)
|
<span class="status-badge platform-badge">
|
||||||
</span>
|
@FormatPlatform(group.Platform)
|
||||||
|
</span>
|
||||||
|
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
|
||||||
|
@FormatRole(group.ManagerRole)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
|
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
|
||||||
Посмотреть игры →
|
Посмотреть игры →
|
||||||
</a>
|
</a>
|
||||||
@@ -81,6 +86,20 @@
|
|||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group-card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge {
|
||||||
|
background: rgba(88, 101, 242, 0.15);
|
||||||
|
color: #9ea8ff;
|
||||||
|
border-color: rgba(88, 101, 242, 0.35);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -104,4 +123,7 @@
|
|||||||
|
|
||||||
private static string FormatRole(string role) =>
|
private static string FormatRole(string role) =>
|
||||||
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
||||||
|
|
||||||
|
private static string FormatPlatform(string? platform) =>
|
||||||
|
string.Equals(platform, "Discord", StringComparison.OrdinalIgnoreCase) ? "Discord" : "Telegram";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
|
|||||||
public string GenerateToken() => Guid.NewGuid().ToString("N");
|
public string GenerateToken() => Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
public async Task<string> CreateSubscriptionAsync(
|
public async Task<string> CreateSubscriptionAsync(
|
||||||
long userTelegramId,
|
string userPlatform,
|
||||||
|
string userExternalId,
|
||||||
Guid? groupId,
|
Guid? groupId,
|
||||||
CalendarSubscriptionFilter filter,
|
CalendarSubscriptionFilter filter,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
@@ -20,9 +21,9 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
|
|||||||
var token = GenerateToken();
|
var token = GenerateToken();
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
|
@"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at)
|
||||||
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
|
VALUES (gen_random_uuid(), @token, @userPlatform, @userExternalId, @groupId, @filterType, now(), NULL)",
|
||||||
new { token, userTelegramId, groupId, filterType = (int)filter });
|
new { token, userPlatform, userExternalId, groupId, filterType = (int)filter });
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
|
|||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
var subscription = await connection.QueryFirstOrDefaultAsync<SubscriptionRecord>(
|
var subscription = await connection.QueryFirstOrDefaultAsync<SubscriptionRecord>(
|
||||||
@"SELECT id, user_telegram_id as UserTelegramId, group_id as GroupId, filter_type as FilterType
|
@"SELECT id, group_id as GroupId, filter_type as FilterType
|
||||||
FROM calendar_subscriptions
|
FROM calendar_subscriptions
|
||||||
WHERE token = @token
|
WHERE token = @token
|
||||||
AND (expires_at IS NULL OR expires_at > now())",
|
AND (expires_at IS NULL OR expires_at > now())",
|
||||||
@@ -88,6 +89,6 @@ public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
|
|||||||
.Replace("\n", "\\n")
|
.Replace("\n", "\\n")
|
||||||
.Replace("\r", "");
|
.Replace("\r", "");
|
||||||
|
|
||||||
private sealed record SubscriptionRecord(Guid Id, long UserTelegramId, Guid? GroupId, int FilterType);
|
private sealed record SubscriptionRecord(Guid Id, Guid? GroupId, int FilterType);
|
||||||
private sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
private sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using GmRelay.Shared.Domain;
|
|||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Exceptions;
|
||||||
using GmRelay.Web.Services;
|
using GmRelay.Web.Services;
|
||||||
|
|
||||||
namespace GmRelay.Web.Services;
|
namespace GmRelay.Web.Services;
|
||||||
@@ -95,6 +96,7 @@ internal sealed record WebBatchSessionRow(
|
|||||||
string NotificationMode,
|
string NotificationMode,
|
||||||
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);
|
||||||
|
|
||||||
public sealed class SessionService(
|
public sealed class SessionService(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
@@ -104,24 +106,45 @@ public sealed class SessionService(
|
|||||||
public async Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId)
|
public async Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId)
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
|
||||||
if (effectiveId is null)
|
if (playerIds.Length == 0)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
return (await conn.QueryAsync<WebGameGroup>(
|
return (await conn.QueryAsync<WebGameGroup>(
|
||||||
"""
|
"""
|
||||||
|
WITH visible_groups AS (
|
||||||
|
SELECT gm.group_id,
|
||||||
|
CASE
|
||||||
|
WHEN bool_or(gm.role = @OwnerRole) THEN @OwnerRole
|
||||||
|
ELSE @CoGmRole
|
||||||
|
END AS ManagerRole
|
||||||
|
FROM group_managers gm
|
||||||
|
WHERE gm.player_id = ANY(@PlayerIds)
|
||||||
|
GROUP BY gm.group_id
|
||||||
|
)
|
||||||
SELECT g.id,
|
SELECT g.id,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.external_group_id::BIGINT AS TelegramChatId,
|
||||||
g.external_group_id AS ExternalGroupId,
|
g.external_group_id AS ExternalGroupId,
|
||||||
g.name,
|
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
|
||||||
g.platform AS Platform,
|
g.platform AS Platform,
|
||||||
gm.role AS ManagerRole
|
vg.ManagerRole
|
||||||
FROM group_managers gm
|
FROM visible_groups vg
|
||||||
JOIN game_groups g ON g.id = gm.group_id
|
JOIN game_groups g ON g.id = vg.group_id
|
||||||
WHERE gm.player_id = @PlayerId
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT s.title
|
||||||
|
FROM sessions s
|
||||||
|
WHERE s.group_id = g.id
|
||||||
|
ORDER BY s.scheduled_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest_session ON true
|
||||||
ORDER BY g.name
|
ORDER BY g.name
|
||||||
""",
|
""",
|
||||||
new { PlayerId = effectiveId.Value })).ToList();
|
new
|
||||||
|
{
|
||||||
|
PlayerIds = playerIds,
|
||||||
|
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||||||
|
CoGmRole = GroupManagerRoleExtensions.CoGmValue
|
||||||
|
})).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
|
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
|
||||||
@@ -130,12 +153,19 @@ public sealed class SessionService(
|
|||||||
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
|
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
|
||||||
"""
|
"""
|
||||||
SELECT g.id,
|
SELECT g.id,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.external_group_id::BIGINT AS TelegramChatId,
|
||||||
g.external_group_id AS ExternalGroupId,
|
g.external_group_id AS ExternalGroupId,
|
||||||
g.name,
|
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
|
||||||
g.platform AS Platform,
|
g.platform AS Platform,
|
||||||
@OwnerRole AS ManagerRole
|
@OwnerRole AS ManagerRole
|
||||||
FROM game_groups g
|
FROM game_groups g
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT s.title
|
||||||
|
FROM sessions s
|
||||||
|
WHERE s.group_id = g.id
|
||||||
|
ORDER BY s.scheduled_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest_session ON true
|
||||||
WHERE g.id = @GroupId
|
WHERE g.id = @GroupId
|
||||||
""",
|
""",
|
||||||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||||||
@@ -144,8 +174,8 @@ public sealed class SessionService(
|
|||||||
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();
|
||||||
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
|
||||||
if (effectiveId is null)
|
if (playerIds.Length == 0)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
return await conn.ExecuteScalarAsync<bool>(
|
return await conn.ExecuteScalarAsync<bool>(
|
||||||
@@ -154,17 +184,17 @@ public sealed class SessionService(
|
|||||||
SELECT 1
|
SELECT 1
|
||||||
FROM group_managers
|
FROM group_managers
|
||||||
WHERE group_id = @GroupId
|
WHERE group_id = @GroupId
|
||||||
AND player_id = @PlayerId
|
AND player_id = ANY(@PlayerIds)
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
new { GroupId = groupId, PlayerId = effectiveId.Value });
|
new { GroupId = groupId, PlayerIds = playerIds });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId)
|
public async Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId)
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
|
||||||
if (effectiveId is null)
|
if (playerIds.Length == 0)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
return await conn.ExecuteScalarAsync<bool>(
|
return await conn.ExecuteScalarAsync<bool>(
|
||||||
@@ -173,11 +203,11 @@ public sealed class SessionService(
|
|||||||
SELECT 1
|
SELECT 1
|
||||||
FROM group_managers
|
FROM group_managers
|
||||||
WHERE group_id = @GroupId
|
WHERE group_id = @GroupId
|
||||||
AND player_id = @PlayerId
|
AND player_id = ANY(@PlayerIds)
|
||||||
AND role = @OwnerRole
|
AND role = @OwnerRole
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
new { GroupId = groupId, PlayerId = effectiveId.Value, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
new { GroupId = groupId, PlayerIds = playerIds, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
|
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
|
||||||
@@ -185,11 +215,11 @@ 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.telegram_id, 0) AS TelegramId,
|
SELECT COALESCE(p.external_user_id::BIGINT, 0) AS TelegramId,
|
||||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
p.external_user_id AS ExternalUserId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
p.external_username AS ExternalUsername,
|
||||||
gm.role AS Role,
|
gm.role AS Role,
|
||||||
gm.created_at AS AddedAt
|
gm.created_at AS AddedAt
|
||||||
FROM group_managers gm
|
FROM group_managers gm
|
||||||
@@ -210,7 +240,7 @@ public sealed class SessionService(
|
|||||||
SELECT
|
SELECT
|
||||||
p.id AS PlayerId,
|
p.id AS PlayerId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
p.external_username AS ExternalUsername,
|
||||||
COUNT(DISTINCT s.id) AS TotalSessions,
|
COUNT(DISTINCT s.id) AS TotalSessions,
|
||||||
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount,
|
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount,
|
||||||
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount,
|
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount,
|
||||||
@@ -229,7 +259,7 @@ public sealed class SessionService(
|
|||||||
WHERE s.group_id = @GroupId
|
WHERE s.group_id = @GroupId
|
||||||
AND s.scheduled_at <= now()
|
AND s.scheduled_at <= now()
|
||||||
AND sp.is_gm = false
|
AND sp.is_gm = false
|
||||||
GROUP BY p.id, p.display_name, p.external_username, p.telegram_username
|
GROUP BY p.id, p.display_name, p.external_username
|
||||||
ORDER BY AttendanceRate DESC, ConfirmedCount DESC
|
ORDER BY AttendanceRate DESC, ConfirmedCount DESC
|
||||||
""",
|
""",
|
||||||
new { GroupId = groupId })).ToList();
|
new { GroupId = groupId })).ToList();
|
||||||
@@ -328,7 +358,7 @@ public sealed class SessionService(
|
|||||||
return (await conn.QueryAsync<WebSession>(
|
return (await conn.QueryAsync<WebSession>(
|
||||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.external_group_id::BIGINT AS TelegramChatId,
|
||||||
s.max_players AS MaxPlayers,
|
s.max_players AS MaxPlayers,
|
||||||
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,
|
||||||
@@ -366,7 +396,7 @@ public sealed class SessionService(
|
|||||||
return await conn.QuerySingleOrDefaultAsync<WebSession>(
|
return await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.external_group_id::BIGINT AS TelegramChatId,
|
||||||
s.max_players AS MaxPlayers,
|
s.max_players AS MaxPlayers,
|
||||||
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,
|
||||||
@@ -425,7 +455,7 @@ public sealed class SessionService(
|
|||||||
var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.external_group_id::BIGINT AS TelegramChatId,
|
||||||
s.max_players AS MaxPlayers,
|
s.max_players AS MaxPlayers,
|
||||||
0 AS ActivePlayerCount,
|
0 AS ActivePlayerCount,
|
||||||
0 AS WaitlistedPlayerCount,
|
0 AS WaitlistedPlayerCount,
|
||||||
@@ -511,7 +541,7 @@ public sealed class SessionService(
|
|||||||
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.external_group_id::BIGINT AS TelegramChatId,
|
||||||
s.max_players AS MaxPlayers,
|
s.max_players AS MaxPlayers,
|
||||||
0 AS ActivePlayerCount,
|
0 AS ActivePlayerCount,
|
||||||
0 AS WaitlistedPlayerCount,
|
0 AS WaitlistedPlayerCount,
|
||||||
@@ -610,11 +640,11 @@ public sealed class SessionService(
|
|||||||
return (await conn.QueryAsync<WebParticipant>(
|
return (await conn.QueryAsync<WebParticipant>(
|
||||||
"""
|
"""
|
||||||
SELECT sp.id AS Id,
|
SELECT sp.id AS Id,
|
||||||
COALESCE(p.telegram_id, 0) AS TelegramId,
|
COALESCE(p.external_user_id::BIGINT, 0) AS TelegramId,
|
||||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
p.external_user_id AS ExternalUserId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
p.external_username AS ExternalUsername,
|
||||||
sp.rsvp_status AS RsvpStatus,
|
sp.rsvp_status AS RsvpStatus,
|
||||||
sp.registration_status AS RegistrationStatus,
|
sp.registration_status AS RegistrationStatus,
|
||||||
sp.is_gm AS IsGm,
|
sp.is_gm AS IsGm,
|
||||||
@@ -637,7 +667,7 @@ public sealed class SessionService(
|
|||||||
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.external_group_id::BIGINT AS TelegramChatId,
|
||||||
s.max_players AS MaxPlayers,
|
s.max_players AS MaxPlayers,
|
||||||
0 AS ActivePlayerCount,
|
0 AS ActivePlayerCount,
|
||||||
0 AS WaitlistedPlayerCount,
|
0 AS WaitlistedPlayerCount,
|
||||||
@@ -658,9 +688,9 @@ public sealed class SessionService(
|
|||||||
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
|
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
|
||||||
"""
|
"""
|
||||||
SELECT sp.id AS Id,
|
SELECT sp.id AS Id,
|
||||||
p.telegram_id AS TelegramId,
|
p.external_user_id::BIGINT AS TelegramId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
sp.rsvp_status AS RsvpStatus,
|
sp.rsvp_status AS RsvpStatus,
|
||||||
sp.registration_status AS RegistrationStatus,
|
sp.registration_status AS RegistrationStatus,
|
||||||
sp.is_gm AS IsGm,
|
sp.is_gm AS IsGm,
|
||||||
@@ -843,7 +873,7 @@ public sealed class SessionService(
|
|||||||
s.status AS Status,
|
s.status AS Status,
|
||||||
s.max_players AS MaxPlayers,
|
s.max_players AS MaxPlayers,
|
||||||
s.batch_message_id AS BatchMessageId,
|
s.batch_message_id AS BatchMessageId,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.external_group_id::BIGINT AS TelegramChatId,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId,
|
||||||
s.topic_created_by_bot AS TopicCreatedByBot,
|
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode
|
||||||
@@ -927,7 +957,7 @@ public sealed class SessionService(
|
|||||||
s.status AS Status,
|
s.status AS Status,
|
||||||
s.max_players AS MaxPlayers,
|
s.max_players AS MaxPlayers,
|
||||||
s.batch_message_id AS BatchMessageId,
|
s.batch_message_id AS BatchMessageId,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.external_group_id::BIGINT AS TelegramChatId,
|
||||||
s.thread_id AS ThreadId,
|
s.thread_id AS ThreadId,
|
||||||
s.topic_created_by_bot AS TopicCreatedByBot,
|
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||||
s.notification_mode AS NotificationMode
|
s.notification_mode AS NotificationMode
|
||||||
@@ -1149,7 +1179,7 @@ public sealed class SessionService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
|
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
|
||||||
"SELECT telegram_chat_id AS TelegramChatId FROM game_groups WHERE id = @GroupId",
|
"SELECT external_group_id::BIGINT AS TelegramChatId FROM game_groups WHERE id = @GroupId",
|
||||||
new { GroupId = groupId },
|
new { GroupId = groupId },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
@@ -1158,6 +1188,10 @@ public sealed class SessionService(
|
|||||||
throw new SessionAccessDeniedException(groupId, "0");
|
throw new SessionAccessDeniedException(groupId, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var topicDestination = await ResolveTemplateBatchTopicAsync(group.TelegramChatId, template.Title);
|
||||||
|
var messageThreadId = topicDestination.MessageThreadId;
|
||||||
|
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
|
||||||
|
|
||||||
var schedule = BatchSchedulePlanner.BuildRecurringSchedule(
|
var schedule = BatchSchedulePlanner.BuildRecurringSchedule(
|
||||||
firstScheduledAt,
|
firstScheduledAt,
|
||||||
template.SessionCount,
|
template.SessionCount,
|
||||||
@@ -1169,8 +1203,8 @@ public sealed class SessionService(
|
|||||||
{
|
{
|
||||||
var sessionId = await conn.ExecuteScalarAsync<Guid>(
|
var sessionId = await conn.ExecuteScalarAsync<Guid>(
|
||||||
"""
|
"""
|
||||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, notification_mode)
|
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode)
|
||||||
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @MaxPlayers, @NotificationMode)
|
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
new
|
new
|
||||||
@@ -1181,6 +1215,8 @@ public sealed class SessionService(
|
|||||||
template.JoinLink,
|
template.JoinLink,
|
||||||
ScheduledAt = scheduledAt,
|
ScheduledAt = scheduledAt,
|
||||||
Status = SessionStatus.Planned,
|
Status = SessionStatus.Planned,
|
||||||
|
ThreadId = messageThreadId,
|
||||||
|
TopicCreatedByBot = topicCreatedByBot,
|
||||||
template.MaxPlayers,
|
template.MaxPlayers,
|
||||||
template.NotificationMode
|
template.NotificationMode
|
||||||
},
|
},
|
||||||
@@ -1195,6 +1231,7 @@ public sealed class SessionService(
|
|||||||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||||||
var batchMessage = await bot.SendMessage(
|
var batchMessage = await bot.SendMessage(
|
||||||
chatId: group.TelegramChatId,
|
chatId: group.TelegramChatId,
|
||||||
|
messageThreadId: messageThreadId,
|
||||||
text: renderResult.Text,
|
text: renderResult.Text,
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
replyMarkup: renderResult.Markup);
|
replyMarkup: renderResult.Markup);
|
||||||
@@ -1214,13 +1251,41 @@ public sealed class SessionService(
|
|||||||
template.NotificationMode);
|
template.NotificationMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<WebTemplateTopicDestination> ResolveTemplateBatchTopicAsync(long telegramChatId, string title)
|
||||||
|
{
|
||||||
|
var chat = await bot.GetChat(chatId: telegramChatId);
|
||||||
|
if (!chat.IsForum)
|
||||||
|
{
|
||||||
|
return new WebTemplateTopicDestination(null, TopicCreatedByBot: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var topic = await bot.CreateForumTopic(
|
||||||
|
chatId: telegramChatId,
|
||||||
|
name: $"🎲 Игры: {title}");
|
||||||
|
return new WebTemplateTopicDestination(topic.MessageThreadId, TopicCreatedByBot: true);
|
||||||
|
}
|
||||||
|
catch (ApiRequestException ex) when (IsMissingForumTopicRightsError(ex.Message))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Не удалось создать Telegram topic. Сделайте бота admin и включите право Manage Topics, затем повторите действие.",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsMissingForumTopicRightsError(string apiError) =>
|
||||||
|
apiError.Contains("not enough rights", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
apiError.Contains("CHAT_ADMIN_REQUIRED", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
apiError.Contains("not an administrator", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync(
|
private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync(
|
||||||
Npgsql.NpgsqlConnection conn,
|
Npgsql.NpgsqlConnection conn,
|
||||||
Guid sessionId)
|
Guid sessionId)
|
||||||
{
|
{
|
||||||
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
|
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
|
||||||
"""
|
"""
|
||||||
SELECT p.telegram_id AS TelegramId,
|
SELECT p.external_user_id::BIGINT AS TelegramId,
|
||||||
p.display_name AS DisplayName
|
p.display_name AS DisplayName
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
@@ -1237,7 +1302,7 @@ public sealed class SessionService(
|
|||||||
{
|
{
|
||||||
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
|
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
|
||||||
"""
|
"""
|
||||||
SELECT DISTINCT p.telegram_id AS TelegramId,
|
SELECT DISTINCT p.external_user_id::BIGINT AS TelegramId,
|
||||||
p.display_name AS DisplayName
|
p.display_name AS DisplayName
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
@@ -1290,7 +1355,7 @@ public sealed class SessionService(
|
|||||||
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
|
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
|
||||||
@"SELECT sp.session_id AS SessionId,
|
@"SELECT sp.session_id AS SessionId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
sp.registration_status AS RegistrationStatus
|
sp.registration_status AS RegistrationStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
@@ -1327,7 +1392,7 @@ public sealed class SessionService(
|
|||||||
s.group_id AS GroupId,
|
s.group_id AS GroupId,
|
||||||
(array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title,
|
(array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title,
|
||||||
(array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink,
|
(array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.external_group_id::BIGINT AS TelegramChatId,
|
||||||
(array_agg(s.batch_message_id ORDER BY s.scheduled_at))[1] AS BatchMessageId,
|
(array_agg(s.batch_message_id ORDER BY s.scheduled_at))[1] AS BatchMessageId,
|
||||||
(array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId,
|
(array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId,
|
||||||
(array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
|
(array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
|
||||||
@@ -1335,7 +1400,7 @@ public sealed class SessionService(
|
|||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.batch_id = @BatchId
|
WHERE s.batch_id = @BatchId
|
||||||
AND s.group_id = @GroupId
|
AND s.group_id = @GroupId
|
||||||
GROUP BY s.batch_id, s.group_id, g.telegram_chat_id
|
GROUP BY s.batch_id, s.group_id, g.external_group_id
|
||||||
""",
|
""",
|
||||||
new { BatchId = batchId, GroupId = groupId },
|
new { BatchId = batchId, GroupId = groupId },
|
||||||
transaction);
|
transaction);
|
||||||
@@ -1561,6 +1626,23 @@ public sealed class SessionService(
|
|||||||
return primaryId ?? playerId;
|
return primaryId ?? playerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<Guid[]> _ResolveLinkedPlayerIdsAsync(NpgsqlConnection conn, string platform, string externalUserId)
|
||||||
|
{
|
||||||
|
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||||||
|
if (effectiveId is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return (await conn.QueryAsync<Guid>(
|
||||||
|
"""
|
||||||
|
SELECT @EffectiveId
|
||||||
|
UNION
|
||||||
|
SELECT secondary_player_id
|
||||||
|
FROM player_links
|
||||||
|
WHERE primary_player_id = @EffectiveId
|
||||||
|
""",
|
||||||
|
new { EffectiveId = effectiveId.Value })).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<Guid> _UpsertPlayerAndGetIdAsync(
|
private static async Task<Guid> _UpsertPlayerAndGetIdAsync(
|
||||||
NpgsqlConnection conn, string platform, string externalUserId,
|
NpgsqlConnection conn, string platform, string externalUserId,
|
||||||
string displayName, string? avatarUrl, NpgsqlTransaction? transaction)
|
string displayName, string? avatarUrl, NpgsqlTransaction? transaction)
|
||||||
|
|||||||
@@ -33,7 +33,17 @@ public sealed class DiscordListSessionsHandlerTests
|
|||||||
|
|
||||||
Assert.Contains("platform = 'Discord'", handler, StringComparison.Ordinal);
|
Assert.Contains("platform = 'Discord'", handler, StringComparison.Ordinal);
|
||||||
Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal);
|
Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal);
|
||||||
Assert.Contains("scheduled_at > NOW()", handler, StringComparison.Ordinal);
|
Assert.Contains("scheduled_at > now() - interval '4 hours'", handler, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Handler_ShouldIncludeRecentlyStartedSessionsForCleanup()
|
||||||
|
{
|
||||||
|
var repoRoot = GetRepoRoot();
|
||||||
|
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs");
|
||||||
|
var handler = File.ReadAllText(handlerPath);
|
||||||
|
|
||||||
|
Assert.Contains("now() - interval '4 hours'", handler, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -47,6 +57,17 @@ public sealed class DiscordListSessionsHandlerTests
|
|||||||
Assert.DoesNotContain("telegram_id", handler, StringComparison.Ordinal);
|
Assert.DoesNotContain("telegram_id", handler, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Handler_ShouldExposeDeleteActionForManagers()
|
||||||
|
{
|
||||||
|
var repoRoot = GetRepoRoot();
|
||||||
|
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs");
|
||||||
|
var handler = File.ReadAllText(handlerPath);
|
||||||
|
|
||||||
|
Assert.Contains("delete_session", handler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("CanManageSchedule", handler, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Command_ShouldExist()
|
public void Command_ShouldExist()
|
||||||
{
|
{
|
||||||
@@ -66,4 +87,18 @@ public sealed class DiscordListSessionsHandlerTests
|
|||||||
Assert.Contains("SlashCommand", command, StringComparison.Ordinal);
|
Assert.Contains("SlashCommand", command, StringComparison.Ordinal);
|
||||||
Assert.Contains("listsessions", command, StringComparison.Ordinal);
|
Assert.Contains("listsessions", command, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteHandler_ShouldDeleteOnlySessionsFromTheInteractionGuild()
|
||||||
|
{
|
||||||
|
var repoRoot = GetRepoRoot();
|
||||||
|
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordDeleteSessionHandler.cs");
|
||||||
|
|
||||||
|
Assert.True(File.Exists(handlerPath), "DiscordDeleteSessionHandler should exist.");
|
||||||
|
|
||||||
|
var handler = File.ReadAllText(handlerPath);
|
||||||
|
Assert.Contains("DELETE FROM sessions", handler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("CanManageSchedule", handler, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ public sealed class DiscordNewSessionHandlerTests
|
|||||||
|
|
||||||
// --- Runtime tests for ParseTimeInput (static, no DB) ---
|
// --- Runtime tests for ParseTimeInput (static, no DB) ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseTimeInput_ShouldTreatInputAsMoscowTime()
|
||||||
|
{
|
||||||
|
var result = DiscordNewSessionHandler.ParseTimeInput("2026-06-01 15:00");
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
// 15:00 MSK = 12:00 UTC
|
||||||
|
Assert.Equal(12, result.Value.Hour);
|
||||||
|
Assert.Equal(0, result.Value.Minute);
|
||||||
|
Assert.Equal(TimeSpan.Zero, result.Value.Offset);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseTimeInput_ShouldParseDiscordDateFormat()
|
public void ParseTimeInput_ShouldParseDiscordDateFormat()
|
||||||
{
|
{
|
||||||
@@ -28,7 +39,8 @@ public sealed class DiscordNewSessionHandlerTests
|
|||||||
Assert.Equal(expected.Year, result.Value.Year);
|
Assert.Equal(expected.Year, result.Value.Year);
|
||||||
Assert.Equal(expected.Month, result.Value.Month);
|
Assert.Equal(expected.Month, result.Value.Month);
|
||||||
Assert.Equal(expected.Day, result.Value.Day);
|
Assert.Equal(expected.Day, result.Value.Day);
|
||||||
Assert.Equal(19, result.Value.Hour);
|
// Input is treated as Moscow time; 19:30 MSK = 16:30 UTC
|
||||||
|
Assert.Equal(16, result.Value.Hour);
|
||||||
Assert.Equal(30, result.Value.Minute);
|
Assert.Equal(30, result.Value.Minute);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +139,18 @@ public sealed class DiscordNewSessionHandlerTests
|
|||||||
Assert.Contains("RollbackAsync", source, StringComparison.Ordinal);
|
Assert.Contains("RollbackAsync", source, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Handler_ShouldNotRollbackCommittedTransactionAfterPostCommitFailure()
|
||||||
|
{
|
||||||
|
var repoRoot = GetRepoRoot();
|
||||||
|
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||||
|
var source = File.ReadAllText(handlerPath);
|
||||||
|
|
||||||
|
Assert.Contains("transactionCommitted = false", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("transactionCommitted = true", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("if (!transactionCommitted)", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Handler_ShouldRespectCancellationToken()
|
public void Handler_ShouldRespectCancellationToken()
|
||||||
{
|
{
|
||||||
@@ -148,6 +172,29 @@ public sealed class DiscordNewSessionHandlerTests
|
|||||||
Assert.Contains("message.Embeds = embeds", source, StringComparison.Ordinal);
|
Assert.Contains("message.Embeds = embeds", source, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Handler_ShouldLeaveScheduleMessageCreationToInteractionResponse()
|
||||||
|
{
|
||||||
|
var repoRoot = GetRepoRoot();
|
||||||
|
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||||
|
var source = File.ReadAllText(handlerPath);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("SendScheduleAsync", source, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("PlatformScheduleMessage", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Handler_ShouldStoreReadableDiscordGroupNameForWebCards()
|
||||||
|
{
|
||||||
|
var repoRoot = GetRepoRoot();
|
||||||
|
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs");
|
||||||
|
var source = File.ReadAllText(handlerPath);
|
||||||
|
|
||||||
|
Assert.Contains("groupName", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("displayGroupName", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("VALUES (@GroupName, 'Discord'", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
private static DateTimeOffset FutureDateAt1930()
|
private static DateTimeOffset FutureDateAt1930()
|
||||||
{
|
{
|
||||||
var future = DateTimeOffset.UtcNow.AddDays(7);
|
var future = DateTimeOffset.UtcNow.AddDays(7);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public sealed class DiscordProjectStructureTests
|
|||||||
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
var 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.0.9", compose);
|
Assert.Contains("gmrelay-discord-bot:3.2.0", compose);
|
||||||
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
Assert.Contains("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.0.9</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
Assert.Contains("<Version>3.2.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||||
Assert.Contains("VERSION: 3.0.9", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
Assert.Contains("VERSION: 3.2.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||||
Assert.Contains("gmrelay-bot:3.0.9", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
Assert.Contains("gmrelay-bot:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||||
Assert.Contains("gmrelay-web:3.0.9", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
Assert.Contains("gmrelay-web:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||||
Assert.Contains("gmrelay-discord-bot:3.0.9", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
Assert.Contains("gmrelay-discord-bot:3.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
"v3.0.9",
|
"v3.2.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")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,26 @@ public sealed class DiscordSessionInteractionModuleSourceTests
|
|||||||
Assert.Contains("MessageFlags.Ephemeral", source, StringComparison.Ordinal);
|
Assert.Contains("MessageFlags.Ephemeral", source, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Module_ShouldUpdateSourceScheduleMessageThroughComponentInteraction()
|
||||||
|
{
|
||||||
|
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
|
||||||
|
|
||||||
|
Assert.Contains("InteractionCallback.DeferredModifyMessage", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("FollowupAsync", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("CompleteScheduleUpdateResponseAsync", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Module_ShouldRouteDeleteSessionButtons()
|
||||||
|
{
|
||||||
|
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
|
||||||
|
|
||||||
|
Assert.Contains("[ComponentInteraction(\"delete_session\")]", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("DiscordDeleteSessionHandler", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Module_ShouldRouteRsvpButtonsToNeutralHandler()
|
public async Task Module_ShouldRouteRsvpButtonsToNeutralHandler()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-1
@@ -13,6 +13,7 @@ public sealed class PlatformNeutralSessionInteractionCommandTests
|
|||||||
AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
|
AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
|
||||||
AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
|
AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
|
||||||
AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
||||||
|
AssertProperty<JoinSessionCommand>("DeferScheduleUpdate", typeof(bool));
|
||||||
AssertNoTelegramSpecificProperties<JoinSessionCommand>();
|
AssertNoTelegramSpecificProperties<JoinSessionCommand>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,12 +25,29 @@ public sealed class PlatformNeutralSessionInteractionCommandTests
|
|||||||
AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
|
AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
|
||||||
AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
|
AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
|
||||||
AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
||||||
|
AssertProperty<LeaveSessionCommand>("DeferScheduleUpdate", typeof(bool));
|
||||||
AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
|
AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SessionInteractionResult_ShouldExposeReplyTextAndUpdatedView()
|
||||||
|
{
|
||||||
|
var resultType = typeof(JoinSessionCommand).Assembly.GetType(
|
||||||
|
"GmRelay.Shared.Features.Sessions.CreateSession.SessionInteractionResult");
|
||||||
|
|
||||||
|
Assert.NotNull(resultType);
|
||||||
|
AssertProperty(resultType, "ReplyText", typeof(string));
|
||||||
|
AssertProperty(resultType, "UpdatedView", typeof(GmRelay.Shared.Rendering.SessionBatchViewModel));
|
||||||
|
}
|
||||||
|
|
||||||
private static void AssertProperty<T>(string name, Type expectedType)
|
private static void AssertProperty<T>(string name, Type expectedType)
|
||||||
{
|
{
|
||||||
var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name);
|
AssertProperty(typeof(T), name, expectedType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertProperty(Type type, string name, Type expectedType)
|
||||||
|
{
|
||||||
|
var property = Assert.Single(type.GetProperties(), property => property.Name == name);
|
||||||
|
|
||||||
Assert.Equal(expectedType, property.PropertyType);
|
Assert.Equal(expectedType, property.PropertyType);
|
||||||
}
|
}
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
using GmRelay.Shared.Features.Sessions.ExportCalendar;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.ExportCalendar;
|
||||||
|
|
||||||
|
public sealed class ExportCalendarCommandContractTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ExportCalendarCommand_ShouldExposePlatformNeutralContext()
|
||||||
|
{
|
||||||
|
AssertProperty<ExportCalendarCommand>("Group", typeof(PlatformGroup));
|
||||||
|
AssertProperty<ExportCalendarCommand>("User", typeof(PlatformUser));
|
||||||
|
AssertNoTelegramSpecificProperties<ExportCalendarCommand>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertProperty<T>(string name, Type expectedType)
|
||||||
|
{
|
||||||
|
var property = Assert.Single(typeof(T).GetProperties(), p => p.Name == name);
|
||||||
|
Assert.Equal(expectedType, property.PropertyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertNoTelegramSpecificProperties<T>()
|
||||||
|
{
|
||||||
|
var names = typeof(T).GetProperties().Select(p => p.Name).ToArray();
|
||||||
|
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
|
||||||
|
Assert.DoesNotContain("ChatId", names);
|
||||||
|
Assert.DoesNotContain("MessageId", names);
|
||||||
|
Assert.DoesNotContain("TelegramUserId", names);
|
||||||
|
Assert.DoesNotContain("TelegramUsername", names);
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
using GmRelay.Shared.Features.Sessions.ListSessions;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
|
public sealed class ListSessionsCommandContractTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ListSessionsCommand_ShouldExposePlatformNeutralContext()
|
||||||
|
{
|
||||||
|
AssertProperty<ListSessionsCommand>("Group", typeof(PlatformGroup));
|
||||||
|
AssertProperty<ListSessionsCommand>("User", typeof(PlatformUser));
|
||||||
|
AssertNoTelegramSpecificProperties<ListSessionsCommand>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteSessionCommand_ShouldExposePlatformNeutralContext()
|
||||||
|
{
|
||||||
|
AssertProperty<DeleteSessionCommand>("SessionId", typeof(Guid));
|
||||||
|
AssertProperty<DeleteSessionCommand>("User", typeof(PlatformUser));
|
||||||
|
AssertProperty<DeleteSessionCommand>("Group", typeof(PlatformGroup));
|
||||||
|
AssertProperty<DeleteSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
||||||
|
AssertNoTelegramSpecificProperties<DeleteSessionCommand>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertProperty<T>(string name, Type expectedType)
|
||||||
|
{
|
||||||
|
var property = Assert.Single(typeof(T).GetProperties(), p => p.Name == name);
|
||||||
|
Assert.Equal(expectedType, property.PropertyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertNoTelegramSpecificProperties<T>()
|
||||||
|
{
|
||||||
|
var names = typeof(T).GetProperties().Select(p => p.Name).ToArray();
|
||||||
|
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
|
||||||
|
Assert.DoesNotContain("ChatId", names);
|
||||||
|
Assert.DoesNotContain("MessageId", names);
|
||||||
|
Assert.DoesNotContain("TelegramUserId", names);
|
||||||
|
Assert.DoesNotContain("TelegramUsername", names);
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-13
@@ -1,5 +1,6 @@
|
|||||||
using GmRelay.Bot.Features.Sessions.ListSessions;
|
using GmRelay.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);
|
||||||
}
|
}
|
||||||
@@ -91,6 +91,15 @@ public sealed class PlatformIdentityMigrationTests
|
|||||||
Assert.Contains("platform", service, StringComparison.Ordinal);
|
Assert.Contains("platform", service, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WebSessionService_ShouldAuthorizeGroupsAcrossLinkedIdentities()
|
||||||
|
{
|
||||||
|
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
||||||
|
|
||||||
|
Assert.Contains("_ResolveLinkedPlayerIdsAsync", service, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("player_id = ANY(@PlayerIds)", service, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AttendanceStatsFunction_ShouldReferenceExternalUsername()
|
public async Task AttendanceStatsFunction_ShouldReferenceExternalUsername()
|
||||||
{
|
{
|
||||||
@@ -108,6 +117,28 @@ public sealed class PlatformIdentityMigrationTests
|
|||||||
Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal);
|
Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MigrationV024_ShouldDeprecateTelegramColumns()
|
||||||
|
{
|
||||||
|
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V024__deprecate_telegram_columns.sql");
|
||||||
|
|
||||||
|
Assert.Contains("UPDATE players", migration, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("UPDATE game_groups", migration, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("DEPRECATED", migration, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("calendar_subscriptions", migration, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("user_platform", migration, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("user_external_id", migration, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MigrationV025_ShouldBackfillRescheduleProposals()
|
||||||
|
{
|
||||||
|
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V025__reschedule_proposals_telegram_external.sql");
|
||||||
|
|
||||||
|
Assert.Contains("reschedule_proposals", migration, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("proposed_by_external_user_id", migration, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||||
{
|
{
|
||||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
|||||||
@@ -149,6 +149,68 @@ public sealed class SessionSchedulerServiceTests
|
|||||||
Assert.Null(ex);
|
Assert.Null(ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TickAsync_WhenHandlerThrows_BackoffsForDuration()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||||
|
_clock.UtcNow = now;
|
||||||
|
_store.SessionsNeedingConfirmation = [sessionId];
|
||||||
|
_confirmationHandler.ThrowFor.Add(sessionId);
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.TickAsync(CancellationToken.None);
|
||||||
|
Assert.Single(_confirmationHandler.Calls);
|
||||||
|
|
||||||
|
// Second tick immediately — should be backed off
|
||||||
|
await sut.TickAsync(CancellationToken.None);
|
||||||
|
Assert.Single(_confirmationHandler.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TickAsync_WhenHandlerThrows_AfterBackoffRetriesAgain()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||||
|
_clock.UtcNow = now;
|
||||||
|
_store.SessionsNeedingConfirmation = [sessionId];
|
||||||
|
_confirmationHandler.ThrowFor.Add(sessionId);
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.TickAsync(CancellationToken.None);
|
||||||
|
Assert.Single(_confirmationHandler.Calls);
|
||||||
|
|
||||||
|
// Advance clock past backoff duration (15 min)
|
||||||
|
_clock.UtcNow = now.AddMinutes(16);
|
||||||
|
await sut.TickAsync(CancellationToken.None);
|
||||||
|
Assert.Equal(2, _confirmationHandler.Calls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TickAsync_WhenHandlerSucceedsAfterBackoff_ClearsBackoff()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||||
|
_clock.UtcNow = now;
|
||||||
|
_store.SessionsNeedingConfirmation = [sessionId];
|
||||||
|
_confirmationHandler.ThrowFor.Add(sessionId);
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.TickAsync(CancellationToken.None);
|
||||||
|
Assert.Single(_confirmationHandler.Calls);
|
||||||
|
|
||||||
|
// Remove throw condition, advance past backoff
|
||||||
|
_confirmationHandler.ThrowFor.Remove(sessionId);
|
||||||
|
_clock.UtcNow = now.AddMinutes(16);
|
||||||
|
await sut.TickAsync(CancellationToken.None);
|
||||||
|
Assert.Equal(2, _confirmationHandler.Calls.Count);
|
||||||
|
|
||||||
|
// Next tick should still call because backoff was cleared on success
|
||||||
|
_clock.UtcNow = now.AddMinutes(17);
|
||||||
|
await sut.TickAsync(CancellationToken.None);
|
||||||
|
Assert.Equal(3, _confirmationHandler.Calls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeSendConfirmationHandler : ISendConfirmationHandler
|
private sealed class FakeSendConfirmationHandler : ISendConfirmationHandler
|
||||||
{
|
{
|
||||||
public List<Guid> Calls { get; } = [];
|
public List<Guid> Calls { get; } = [];
|
||||||
|
|||||||
+4
-3
@@ -20,12 +20,13 @@ public sealed class TelegramPlatformMessengerSourceTests
|
|||||||
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/HandleRescheduleTimeInputHandler.cs")]
|
[InlineData("src/GmRelay.Bot/Features/Sessions/RescheduleSession/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);
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-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);
|
||||||
@@ -60,6 +61,19 @@ public sealed class TelegramTopicIntegrationSmokeTests
|
|||||||
Assert.Contains("ExternalThreadId", telegramMessenger, StringComparison.Ordinal);
|
Assert.Contains("ExternalThreadId", telegramMessenger, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WebTemplateBatches_ShouldCreateAndPersistForumTopic()
|
||||||
|
{
|
||||||
|
var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
||||||
|
|
||||||
|
Assert.Contains("GetChat", sessionService, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("CreateForumTopic", sessionService, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("thread_id, topic_created_by_bot", sessionService, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("ThreadId = messageThreadId", sessionService, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("TopicCreatedByBot = topicCreatedByBot", sessionService, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("messageThreadId: messageThreadId", sessionService, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||||
{
|
{
|
||||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
|||||||
@@ -176,6 +176,32 @@ public sealed class DiscordSessionBatchRendererTests
|
|||||||
Assert.Equal("https://example.com/game", embeds[0].Url);
|
Assert.Equal("https://example.com/game", embeds[0].Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldNormalizeBareDomainJoinLinkForEmbedUrl()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "mobaxterm.mobatek.net/game") };
|
||||||
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
|
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
|
var (embeds, _) = DiscordSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
|
Assert.Equal("https://mobaxterm.mobatek.net/game", embeds[0].Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldNotSetEmbedUrlWhenJoinLinkIsNotHttpUrl()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "test") };
|
||||||
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
|
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
|
var (embeds, _) = DiscordSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
|
Assert.Null(embeds[0].Url);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Render_ShouldEmbedCorrectFieldValues()
|
public void Render_ShouldEmbedCorrectFieldValues()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
|
public sealed class HomePageSourceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task HomePage_ShouldShowPlatformBadgeForGroups()
|
||||||
|
{
|
||||||
|
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Home.razor");
|
||||||
|
|
||||||
|
Assert.Contains("platform-badge", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("FormatPlatform", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("group.Platform", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SessionService_ShouldUseSessionTitleWhenDiscordGroupNameIsOnlyId()
|
||||||
|
{
|
||||||
|
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
||||||
|
|
||||||
|
Assert.Contains("latest_session.title", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("NULLIF(g.name, g.external_group_id)", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||||
|
{
|
||||||
|
var dir = AppContext.BaseDirectory;
|
||||||
|
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
|
||||||
|
{
|
||||||
|
dir = Directory.GetParent(dir)?.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
var repoRoot = dir ?? throw new InvalidOperationException("Could not find repo root");
|
||||||
|
return await File.ReadAllTextAsync(Path.Combine(repoRoot, relativePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user