2 Commits

Author SHA1 Message Date
Toutsu 2529df4157 feat: support co-gm group delegation
Deploy Telegram Bot / build-and-push (push) Successful in 3m51s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-27 14:27:16 +03:00
Toutsu a8f2b10956 feat: send personal player notifications
Deploy Telegram Bot / build-and-push (push) Successful in 3m36s
Deploy Telegram Bot / deploy (push) Successful in 11s
2026-04-27 10:11:11 +03:00
31 changed files with 1463 additions and 93 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 1.4.1
VERSION: 1.6.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>1.4.1</Version>
<Version>1.6.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+13 -4
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v1.4.1`.
**Текущая версия:** `v1.6.0`.
---
@@ -15,14 +15,17 @@
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
- **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания.
- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры прямо из Telegram.
- **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
- **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**.
### 🌐 Web Dashboard (Blazor Server)
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
- **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard.
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
@@ -101,7 +104,7 @@ docker compose up -d
* `Закрепление сообщений` — рекомендуется.
> [!TIP]
> Колонку "Мастер" (GM) бот определяет по первому человеку, который создал сессию в этой группе. Только этот пользователь сможет отменять игры через кнопки бота и редактировать их в веб-интерфейсе.
> Owner группы определяется по первому человеку, который создал сессию в этой группе. Owner может назначать co-GM в Web Dashboard; owner и co-GM могут управлять сессиями через кнопки бота и веб-интерфейс.
---
@@ -123,14 +126,20 @@ docker compose up -d
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
### Делегирование управления
На странице группы Web Dashboard показывает owner и список co-GM. Owner может добавить помощника по Telegram ID, имени и username, а также снять роль co-GM. Назначенный co-GM видит группу в панели управления и может редактировать сессии, управлять batch-операциями, очередью, переносами и удалением игр, но не может назначать других co-GM.
### Bulk-операции в Web Dashboard
На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. ГМ может:
На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. Owner и co-GM могут:
- обновить общий `title` и `link` сразу у всех сессий batch;
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
- клонировать batch на следующую неделю или следующий календарный месяц.
После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается. При клонировании создаётся новая пачка с новым Telegram-сообщением и пустым составом игроков.
Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам.
### Другие команды
- `/listsessions` — Показать список всех актуальных игр в этой группе.
- `/reschedulesession` — Перенести сессию на другое время с голосованием игроков.
+2 -2
View File
@@ -17,7 +17,7 @@ services:
retries: 10
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.4.1
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.6.0
restart: always
depends_on:
db:
@@ -29,7 +29,7 @@ services:
- gmrelay
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.4.1
image: git.codeanddice.ru/toutsu/gmrelay-web:1.6.0
restart: always
depends_on:
db:
@@ -1,4 +1,5 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
@@ -13,7 +14,8 @@ internal sealed record SessionInfo(
string Title,
DateTime ScheduledAt,
Guid GroupId,
long TelegramChatId);
long TelegramChatId,
string NotificationMode);
internal sealed record ParticipantInfo(
long TelegramId,
@@ -29,6 +31,7 @@ internal sealed record ParticipantInfo(
public sealed class SendConfirmationHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<SendConfirmationHandler> logger)
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
@@ -39,7 +42,8 @@ public sealed class SendConfirmationHandler(
var session = await connection.QuerySingleOrDefaultAsync<SessionInfo>(
"""
SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId,
g.telegram_chat_id AS TelegramChatId
g.telegram_chat_id AS TelegramChatId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.status = @Planned
@@ -115,6 +119,26 @@ public sealed class SendConfirmationHandler(
MessageId = message.MessageId
});
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var directText = $"""
🎲 <b>Подтвердите участие в игре</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
Ответьте кнопкой в групповом сообщении расписания.
""";
await directSender.SendAsync(
participants.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)),
directText,
"confirmation",
sessionId,
ct);
}
logger.LogInformation(
"Confirmation sent for session {SessionId} ({Title}), message_id={MessageId}",
sessionId, session.Title, message.MessageId);
@@ -0,0 +1,41 @@
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
namespace GmRelay.Bot.Features.Notifications;
public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName);
public sealed class DirectSessionNotificationSender(
ITelegramBotClient bot,
ILogger<DirectSessionNotificationSender> logger)
{
public async Task SendAsync(
IEnumerable<DirectNotificationRecipient> recipients,
string htmlText,
string notificationKind,
Guid sessionId,
CancellationToken ct)
{
foreach (var recipient in recipients)
{
try
{
await bot.SendMessage(
chatId: recipient.TelegramId,
text: htmlText,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Failed to send {NotificationKind} DM for session {SessionId} to player {TelegramId} ({DisplayName})",
notificationKind,
sessionId,
recipient.TelegramId,
recipient.DisplayName);
}
}
}
}
@@ -1,4 +1,5 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
@@ -12,7 +13,8 @@ internal sealed record JoinLinkSession(
string Title,
string JoinLink,
DateTime ScheduledAt,
long TelegramChatId);
long TelegramChatId,
string NotificationMode);
internal sealed record ConfirmedPlayer(
long TelegramId,
@@ -28,6 +30,7 @@ internal sealed record ConfirmedPlayer(
public sealed class SendJoinLinkHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<SendJoinLinkHandler> logger)
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
@@ -38,7 +41,8 @@ public sealed class SendJoinLinkHandler(
var session = await connection.QuerySingleOrDefaultAsync<JoinLinkSession>(
"""
SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt,
g.telegram_chat_id AS TelegramChatId
g.telegram_chat_id AS TelegramChatId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
@@ -102,6 +106,24 @@ public sealed class SendJoinLinkHandler(
""",
new { SessionId = sessionId, MessageId = message.MessageId });
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var directText = $"""
🎮 <b>Игра начинается через 5 минут</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)}
""";
await directSender.SendAsync(
players.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)),
directText,
"join-link",
sessionId,
ct);
}
logger.LogInformation(
"Join link sent for session {SessionId} ({Title}), message_id={MessageId}",
sessionId, session.Title, message.MessageId);
@@ -0,0 +1,97 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using Npgsql;
namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder;
internal sealed record OneHourReminderSession(
Guid Id,
string Title,
string JoinLink,
DateTime ScheduledAt,
string NotificationMode);
public sealed class SendOneHourReminderHandler(
NpgsqlDataSource dataSource,
DirectSessionNotificationSender directSender,
ILogger<SendOneHourReminderHandler> logger)
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var session = await connection.QuerySingleOrDefaultAsync<OneHourReminderSession>(
"""
SELECT id,
title,
join_link AS JoinLink,
scheduled_at AS ScheduledAt,
notification_mode AS NotificationMode
FROM sessions
WHERE id = @SessionId
AND status IN (@Confirmed, @ConfirmationSent)
AND one_hour_reminder_processed_at IS NULL
""",
new
{
SessionId = sessionId,
Confirmed = SessionStatus.Confirmed,
ConfirmationSent = SessionStatus.ConfirmationSent
});
if (session is null)
{
logger.LogWarning("Session {SessionId} not eligible for one-hour reminder", sessionId);
return;
}
var recipients = (await connection.QueryAsync<DirectNotificationRecipient>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName
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
AND sp.rsvp_status != @Declined
""",
new
{
SessionId = sessionId,
Active = ParticipantRegistrationStatus.Active,
Declined = RsvpStatus.Declined
})).ToList();
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages() && recipients.Count > 0)
{
var text = $"""
⏰ <b>Игра начнётся примерно через 1 час</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)}
""";
await directSender.SendAsync(recipients, text, "one-hour-reminder", session.Id, ct);
}
await connection.ExecuteAsync(
"""
UPDATE sessions
SET one_hour_reminder_processed_at = now(),
updated_at = now()
WHERE id = @SessionId
AND one_hour_reminder_processed_at IS NULL
""",
new { SessionId = sessionId });
logger.LogInformation(
"One-hour reminder processed for session {SessionId} ({Title}) with mode {NotificationMode}",
sessionId,
session.Title,
session.NotificationMode);
}
}
@@ -1,4 +1,5 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
@@ -15,11 +16,12 @@ public sealed record CancelSessionCommand(
int MessageId);
// DTOs for AOT compilation
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, long GmId);
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, bool CanManage, string NotificationMode);
public sealed class CancelSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<CancelSessionHandler> logger)
{
public async Task HandleAsync(CancelSessionCommand command, CancellationToken ct)
@@ -27,13 +29,23 @@ public sealed class CancelSessionHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Проверяем, что запрос делает ГМ данной сессии
// 1. Проверяем, что запрос делает управляющий данной группы.
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
@"SELECT s.title as Title, s.batch_id as BatchId, g.gm_telegram_id as GmId
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId",
new { command.SessionId }, transaction);
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.notification_mode AS NotificationMode,
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)
{
@@ -41,9 +53,9 @@ public sealed class CancelSessionHandler(
return;
}
if (session.GmId != command.TelegramUserId)
if (!session.CanManage)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может отменять сессию.", showAlert: true, cancellationToken: ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", showAlert: true, cancellationToken: ct);
return;
}
@@ -73,6 +85,19 @@ public sealed class CancelSessionHandler(
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
new { BatchId = session.BatchId }, transaction);
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { command.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
await transaction.CommitAsync(ct);
// 4. Перерисовываем сообщение
@@ -92,6 +117,17 @@ public sealed class CancelSessionHandler(
// Опционально: написать отдельное сообщение в чат
await bot.SendMessage(command.ChatId, $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await directSender.SendAsync(
directRecipients,
$"❌ <b>Сессия отменена</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>",
"session-cancelled",
command.SessionId,
ct);
}
}
catch (Exception ex)
{
@@ -7,6 +7,8 @@ using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
public sealed class CreateSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient,
@@ -74,16 +76,64 @@ public sealed class CreateSessionHandler(
new { TgId = gmId, Name = gmName, Username = gmUsername },
transaction);
var groupId = await connection.ExecuteScalarAsync<Guid>(
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
"""
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
VALUES (@ChatId, @ChatName, @GmId)
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
RETURNING id;
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.telegram_id = @GmId
) AS CanManage
FROM game_groups g
WHERE g.telegram_chat_id = @ChatId
""",
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
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)
VALUES (@ChatId, @ChatName, @GmId)
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 p.telegram_id = @GmId
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);
}
int? messageThreadId = null;
if (message.Chat.IsForum)
{
@@ -13,7 +13,7 @@ public sealed record PromoteWaitlistedPlayerCommand(
long ChatId,
int MessageId);
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, long GmId, int? MaxPlayers);
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, bool CanManage, int? MaxPlayers);
internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName);
public sealed class PromoteWaitlistedPlayerHandler(
@@ -34,13 +34,18 @@ public sealed class PromoteWaitlistedPlayerHandler(
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.max_players AS MaxPlayers,
g.gm_telegram_id AS GmId
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
JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId
FOR UPDATE
""",
new { command.SessionId },
new { command.SessionId, command.TelegramUserId },
transaction);
if (session is null)
@@ -50,10 +55,10 @@ public sealed class PromoteWaitlistedPlayerHandler(
return;
}
if (session.GmId != command.TelegramUserId)
if (!session.CanManage)
{
await transaction.RollbackAsync(ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct);
return;
}
@@ -12,7 +12,7 @@ public sealed record DeleteSessionCommand(
long ChatId,
int MessageId);
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, long GmId, int? ThreadId);
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, bool CanManage, int? ThreadId);
public sealed class DeleteSessionHandler(
NpgsqlDataSource dataSource,
@@ -24,13 +24,23 @@ public sealed class DeleteSessionHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Fetch session and verify GM
// 1. Fetch session and verify group manager.
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
@"SELECT s.title as Title, s.batch_id as BatchId, s.thread_id as ThreadId, g.gm_telegram_id as GmId
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId",
new { command.SessionId }, transaction);
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.thread_id AS ThreadId,
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)
{
@@ -38,9 +48,9 @@ public sealed class DeleteSessionHandler(
return;
}
if (session.GmId != command.TelegramUserId)
if (!session.CanManage)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может удалять сессию.", showAlert: true, cancellationToken: ct);
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может удалять сессию.", showAlert: true, cancellationToken: ct);
return;
}
@@ -77,16 +87,23 @@ public sealed class DeleteSessionHandler(
@"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,
g.gm_telegram_id as GmId
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, g.gm_telegram_id
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
@@ -110,8 +127,8 @@ public sealed class DeleteSessionHandler(
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
}
var isGm = command.TelegramUserId == sessionsList.First().GmId;
var keyboard = isGm
var canManage = sessionsList.First().CanManage;
var keyboard = canManage
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
: null;
@@ -6,7 +6,7 @@ using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, long GmId);
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource,
@@ -20,16 +20,23 @@ public sealed class ListSessionsHandler(
@"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,
g.gm_telegram_id as GmId
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, g.gm_telegram_id
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
@@ -56,8 +63,8 @@ public sealed class ListSessionsHandler(
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
}
var isGm = message.From?.Id == sessionsList.First().GmId;
var keyboard = isGm
var canManage = sessionsList.First().CanManage;
var keyboard = canManage
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
: null;
@@ -1,4 +1,5 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
@@ -12,9 +13,13 @@ namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record AwaitingProposalDto(
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
Guid BatchId, int? BatchMessageId, long TelegramChatId);
Guid BatchId, int? BatchMessageId, long TelegramChatId, string NotificationMode);
internal sealed record VoteParticipantDto(Guid PlayerId, string DisplayName, string? TelegramUsername);
internal sealed record VoteParticipantDto(
Guid PlayerId,
string DisplayName,
string? TelegramUsername,
long TelegramId = 0);
// ── Handler ──────────────────────────────────────────────────────────
@@ -26,6 +31,7 @@ internal sealed record VoteParticipantDto(Guid PlayerId, string DisplayName, str
public sealed class HandleRescheduleTimeInputHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<HandleRescheduleTimeInputHandler> logger)
{
/// <summary>
@@ -48,13 +54,21 @@ public sealed class HandleRescheduleTimeInputHandler(
"""
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
g.telegram_chat_id AS TelegramChatId,
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
""",
@@ -86,7 +100,10 @@ public sealed class HandleRescheduleTimeInputHandler(
// 3. Load participants (non-GM) signed up for this session
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
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
@@ -135,6 +152,29 @@ public sealed class HandleRescheduleTimeInputHandler(
replyMarkup: keyboard,
cancellationToken: ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var directText = $"""
🔄 <b>Голосование за перенос сессии</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
📅 Новое время: <b>{newTime.FormatMoscow()}</b> (МСК)
Проголосуйте кнопкой в групповом сообщении.
""";
await directSender.SendAsync(
participants.Select(p => new DirectNotificationRecipient(
p.TelegramId,
p.DisplayName)),
directText,
"reschedule-vote",
proposal.SessionId,
ct);
}
// Store vote message ID
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
@@ -156,7 +196,11 @@ public sealed class HandleRescheduleTimeInputHandler(
await connection.ExecuteAsync(
"""
UPDATE sessions SET scheduled_at = @NewTime, status = @Status, updated_at = now()
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
@@ -1,4 +1,5 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
@@ -25,11 +26,13 @@ internal sealed record VoteProposalDto(
string SessionStatus,
long TelegramChatId,
int? ConfirmationMessageId,
int? BatchMessageId);
int? BatchMessageId,
string NotificationMode);
public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<HandleRescheduleVoteHandler> logger)
{
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
@@ -48,7 +51,8 @@ public sealed class HandleRescheduleVoteHandler(
s.status AS SessionStatus,
s.confirmation_message_id AS ConfirmationMessageId,
s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId
g.telegram_chat_id AS TelegramChatId,
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
@@ -105,7 +109,8 @@ public sealed class HandleRescheduleVoteHandler(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
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
@@ -130,6 +135,8 @@ public sealed class HandleRescheduleVoteHandler(
if (decision.Outcome == RescheduleVoteOutcome.Rejected)
{
var directRecipients = await LoadDirectRecipients(connection, proposal.SessionId, transaction);
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
new { Id = command.ProposalId },
@@ -156,12 +163,25 @@ public sealed class HandleRescheduleVoteHandler(
}
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await directSender.SendAsync(
directRecipients,
$"❌ <b>Перенос сессии отклонён</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>\n📅 Время остаётся прежним: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)",
"reschedule-rejected",
proposal.SessionId,
ct);
}
logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId);
return;
}
if (decision.ShouldRescheduleSession)
{
var directRecipients = await LoadDirectRecipients(connection, proposal.SessionId, transaction);
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero);
await connection.ExecuteAsync(
@@ -171,6 +191,7 @@ public sealed class HandleRescheduleVoteHandler(
status = @Status,
confirmation_message_id = NULL,
link_message_id = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
@@ -214,6 +235,17 @@ public sealed class HandleRescheduleVoteHandler(
await TryUpdateBatchMessage(proposal, ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await directSender.SendAsync(
directRecipients,
$"✅ <b>Сессия перенесена</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>\n📅 Новое время: <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК)",
"reschedule-approved",
proposal.SessionId,
ct);
}
logger.LogInformation(
"Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
proposal.SessionId,
@@ -257,6 +289,25 @@ public sealed class HandleRescheduleVoteHandler(
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
}
private static async Task<List<DirectNotificationRecipient>> LoadDirectRecipients(
Npgsql.NpgsqlConnection connection,
Guid sessionId,
Npgsql.NpgsqlTransaction transaction)
{
return (await connection.QueryAsync<DirectNotificationRecipient>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName
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 { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
}
private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct)
{
try
@@ -16,7 +16,7 @@ public sealed record InitiateRescheduleCommand(
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record RescheduleSessionInfoDto(string Title, long GmId);
internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage);
// ── Handler ──────────────────────────────────────────────────────────
@@ -34,15 +34,21 @@ public sealed class InitiateRescheduleHandler(
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
// 1. Verify GM ownership
// 1. Verify group management access.
var session = await connection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
"""
SELECT s.title AS Title, g.gm_telegram_id AS GmId
SELECT s.title AS Title,
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
JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId AND s.status != @Cancelled
""",
new { command.SessionId, Cancelled = SessionStatus.Cancelled });
new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled });
if (session is null)
{
@@ -50,10 +56,10 @@ public sealed class InitiateRescheduleHandler(
return;
}
if (session.GmId != command.TelegramUserId)
if (!session.CanManage)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId,
"Только Мастер Игры (GM) может переносить сессию.", showAlert: true, cancellationToken: ct);
"Только owner или co-GM может переносить сессию.", showAlert: true, cancellationToken: ct);
return;
}
@@ -2,6 +2,7 @@ using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using Npgsql;
namespace GmRelay.Bot.Infrastructure.Scheduling;
@@ -17,11 +18,13 @@ namespace GmRelay.Bot.Infrastructure.Scheduling;
public sealed class SessionSchedulerService(
NpgsqlDataSource dataSource,
SendConfirmationHandler confirmationHandler,
SendOneHourReminderHandler oneHourReminderHandler,
SendJoinLinkHandler joinLinkHandler,
ILogger<SessionSchedulerService> logger) : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24);
private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1);
private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -36,6 +39,7 @@ public sealed class SessionSchedulerService(
try
{
await ProcessConfirmationTriggers(stoppingToken);
await ProcessOneHourReminderTriggers(stoppingToken);
await ProcessJoinLinkTriggers(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
@@ -52,6 +56,42 @@ public sealed class SessionSchedulerService(
logger.LogInformation("Session scheduler stopped");
}
/// <summary>
/// T-1h trigger: process direct reminders according to the session notification mode.
/// </summary>
private async Task ProcessOneHourReminderTriggers(CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var sessionIds = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status IN (@Confirmed, @ConfirmationSent)
AND scheduled_at - @LeadTime <= now()
AND one_hour_reminder_processed_at IS NULL
""",
new
{
Confirmed = SessionStatus.Confirmed,
ConfirmationSent = SessionStatus.ConfirmationSent,
LeadTime = OneHourReminderLeadTime
});
foreach (var sessionId in sessionIds)
{
try
{
await oneHourReminderHandler.HandleAsync(sessionId, ct);
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId);
}
}
}
/// <summary>
/// T-24h trigger: find sessions that need confirmation requests sent.
/// Condition: status='Planned' AND scheduled_at minus 24h is in the past.
@@ -0,0 +1,8 @@
ALTER TABLE sessions
ADD COLUMN notification_mode VARCHAR(50) NOT NULL DEFAULT 'GroupAndDirect'
CHECK (notification_mode IN ('GroupAndDirect', 'GroupOnly')),
ADD COLUMN one_hour_reminder_processed_at TIMESTAMPTZ;
CREATE INDEX ix_sessions_one_hour_reminders ON sessions (scheduled_at)
WHERE status IN ('Confirmed', 'ConfirmationSent')
AND one_hour_reminder_processed_at IS NULL;
@@ -0,0 +1,26 @@
-- Add explicit owner/co-GM management roles for each Telegram group.
INSERT INTO players (telegram_id, display_name)
SELECT DISTINCT gg.gm_telegram_id,
'GM ' || gg.gm_telegram_id::text
FROM game_groups gg
ON CONFLICT (telegram_id) DO NOTHING;
CREATE TABLE group_managers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL CHECK (role IN ('Owner', 'CoGm')),
added_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (group_id, player_id)
);
INSERT INTO group_managers (group_id, player_id, role)
SELECT gg.id, p.id, 'Owner'
FROM game_groups gg
JOIN players p ON p.telegram_id = gg.gm_telegram_id
ON CONFLICT (group_id, player_id) DO NOTHING;
CREATE INDEX ix_group_managers_group_role ON group_managers (group_id, role);
CREATE INDEX ix_group_managers_player ON group_managers (player_id);
+4
View File
@@ -1,6 +1,8 @@
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Database;
@@ -50,8 +52,10 @@ builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
// ── Feature handlers (explicit registration — AOT safe) ──────────────
builder.Services.AddSingleton<SendConfirmationHandler>();
builder.Services.AddSingleton<DirectSessionNotificationSender>();
builder.Services.AddSingleton<HandleRsvpHandler>();
builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<CreateSessionHandler>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
@@ -0,0 +1,34 @@
namespace GmRelay.Shared.Domain;
public enum GroupManagerRole
{
Owner,
CoGm
}
public static class GroupManagerRoleExtensions
{
public const string OwnerValue = "Owner";
public const string CoGmValue = "CoGm";
public static string ToDatabaseValue(this GroupManagerRole role) => role switch
{
GroupManagerRole.Owner => OwnerValue,
GroupManagerRole.CoGm => CoGmValue,
_ => throw new ArgumentOutOfRangeException(nameof(role), role, "Unknown group manager role.")
};
public static GroupManagerRole FromDatabaseValue(string value) => value switch
{
OwnerValue => GroupManagerRole.Owner,
CoGmValue => GroupManagerRole.CoGm,
_ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown group manager role.")
};
public static string ToDisplayName(this GroupManagerRole role) => role switch
{
GroupManagerRole.Owner => "Owner",
GroupManagerRole.CoGm => "Co-GM",
_ => throw new ArgumentOutOfRangeException(nameof(role), role, "Unknown group manager role.")
};
}
@@ -0,0 +1,33 @@
namespace GmRelay.Shared.Domain;
public enum SessionNotificationMode
{
GroupAndDirect,
GroupOnly
}
public static class SessionNotificationModeExtensions
{
public const string GroupAndDirectValue = nameof(SessionNotificationMode.GroupAndDirect);
public const string GroupOnlyValue = nameof(SessionNotificationMode.GroupOnly);
public static bool ShouldSendDirectMessages(this SessionNotificationMode mode) =>
mode == SessionNotificationMode.GroupAndDirect;
public static string ToDatabaseValue(this SessionNotificationMode mode) =>
mode switch
{
SessionNotificationMode.GroupAndDirect => GroupAndDirectValue,
SessionNotificationMode.GroupOnly => GroupOnlyValue,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown notification mode.")
};
public static SessionNotificationMode FromDatabaseValue(string? value) =>
value switch
{
null or "" => SessionNotificationMode.GroupAndDirect,
GroupAndDirectValue => SessionNotificationMode.GroupAndDirect,
GroupOnlyValue => SessionNotificationMode.GroupOnly,
_ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown notification mode.")
};
}
@@ -20,6 +20,57 @@
<h2>📅 Предстоящие игры</h2>
</div>
@if (groupManagement is not null)
{
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Управление группой</h3>
<p>@groupManagement.Group.Name · @FormatRole(CurrentUserRole)</p>
</div>
<span class="status-badge status-info">@FormatRole(CurrentUserRole)</span>
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
@foreach (var manager in groupManagement.Managers)
{
<span class="status-badge @(manager.Role == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
@FormatManager(manager)
</span>
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
{
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.TelegramId)" @onclick="() => RemoveCoGm(manager.TelegramId)">
@(removingCoGmId == manager.TelegramId ? "⏳ Удаляем..." : "Убрать")
</button>
}
}
</div>
@if (groupManagement.CurrentUserIsOwner)
{
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
<div class="batch-bulk-fields">
<div class="gm-form-group">
<label class="gm-form-label">Telegram ID co-GM</label>
<InputNumber @bind-Value="coGmModel.TelegramId" class="gm-form-control" min="1" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Имя</label>
<InputText @bind-Value="coGmModel.DisplayName" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Username</label>
<InputText @bind-Value="coGmModel.TelegramUsername" class="gm-form-control" />
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
@(isAddingCoGm ? "⏳ Добавляем..." : " Добавить co-GM")
</button>
</EditForm>
}
</div>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
@@ -77,9 +128,16 @@
<label class="gm-form-label">Общая ссылка</label>
<InputText @bind-Value="batch.JoinLink" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Уведомления игрокам</label>
<select @bind="batch.NotificationMode" class="gm-form-control">
<option value="@SessionNotificationModeExtensions.GroupAndDirectValue">В группе и в личку</option>
<option value="@SessionNotificationModeExtensions.GroupOnlyValue">Только в группе</option>
</select>
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@IsBatchBusy(batch)">
@(IsBatchBusy(batch) ? "⏳ Обновляем..." : "💾 Обновить title/link")
@(IsBatchBusy(batch) ? "⏳ Обновляем..." : "💾 Обновить batch")
</button>
</EditForm>
@@ -205,12 +263,16 @@
@code {
[Parameter] public Guid GroupId { get; set; }
private List<WebSession>? sessions;
private WebGroupManagement? groupManagement;
private List<BatchBulkEditModel> batchModels = [];
private Guid? promotingSessionId;
private Guid? processingBatchId;
private long? removingCoGmId;
private bool isAddingCoGm;
private long telegramId;
private string? errorMessage;
private string? successMessage;
private CoGmEditModel coGmModel = new();
protected override async Task OnInitializedAsync()
{
@@ -226,6 +288,13 @@
private async Task LoadSessions()
{
groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId);
if (groupManagement is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
if (sessions is null)
{
@@ -236,6 +305,72 @@
RebuildBatchModels();
}
private async Task AddCoGm()
{
errorMessage = null;
successMessage = null;
if (!coGmModel.TelegramId.HasValue || coGmModel.TelegramId.Value <= 0)
{
errorMessage = "Telegram ID co-GM должен быть положительным числом.";
return;
}
isAddingCoGm = true;
try
{
await SessionService.AddCoGmForOwnerAsync(
GroupId,
telegramId,
coGmModel.TelegramId.Value,
coGmModel.DisplayName,
coGmModel.TelegramUsername);
coGmModel = new();
successMessage = "Co-GM добавлен.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось добавить co-GM: " + ex.Message;
}
finally
{
isAddingCoGm = false;
}
}
private async Task RemoveCoGm(long coGmTelegramId)
{
errorMessage = null;
successMessage = null;
removingCoGmId = coGmTelegramId;
try
{
await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId);
successMessage = "Co-GM удалён.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось удалить co-GM: " + ex.Message;
}
finally
{
removingCoGmId = null;
}
}
private async Task PromoteWaitlisted(Guid sessionId)
{
errorMessage = null;
@@ -277,7 +412,11 @@
try
{
await SessionService.UpdateBatchDetailsForGmAsync(batch.BatchId, telegramId, batch.Title, batch.JoinLink);
successMessage = "Общие title/link обновлены для всей пачки.";
await SessionService.UpdateBatchNotificationModeForGmAsync(
batch.BatchId,
telegramId,
SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode));
successMessage = "Настройки batch обновлены.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
@@ -373,6 +512,7 @@
BatchId = group.Key,
Title = firstSession.Title,
JoinLink = firstSession.JoinLink,
NotificationMode = firstSession.NotificationMode,
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
IntervalDays = InferIntervalDays(orderedSessions),
@@ -392,6 +532,22 @@
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
private string CurrentUserRole =>
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
?? GroupManagerRoleExtensions.CoGmValue;
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
private static string FormatManager(WebGroupManager manager)
{
var username = string.IsNullOrWhiteSpace(manager.TelegramUsername)
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
: "@" + manager.TelegramUsername;
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {username}";
}
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
{
if (orderedSessions.Count < 2)
@@ -447,10 +603,18 @@
public Guid BatchId { get; init; }
public string Title { get; set; } = "";
public string JoinLink { get; set; } = "";
public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue;
public DateTime FirstScheduledAtLocal { get; set; } = DateTime.Now;
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
public int IntervalDays { get; set; } = 7;
public int SessionCount { get; init; }
public string CloneInterval { get; set; } = "week";
}
private sealed class CoGmEditModel
{
public long? TelegramId { get; set; }
public string DisplayName { get; set; } = "";
public string? TelegramUsername { get; set; }
}
}
@@ -1,6 +1,7 @@
@page "/"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using GmRelay.Shared.Domain
@using GmRelay.Web.Services
@attribute [Authorize]
@inject AuthorizedSessionService SessionService
@@ -43,6 +44,9 @@
<div class="group-card-icon">🎮</div>
<h3 class="group-card-title">@group.Name</h3>
<p class="group-card-id">ID: @group.TelegramChatId</p>
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;">
@FormatRole(group.ManagerRole)
</span>
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
Посмотреть игры →
</a>
@@ -97,4 +101,7 @@
groups = await SessionService.GetGroupsForGmAsync(telegramId);
}
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
}
@@ -1,3 +1,5 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services;
public sealed class AuthorizedSessionService(ISessionStore sessionStore)
@@ -5,6 +7,24 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
sessionStore.GetGroupsForGmAsync(gmId);
public async Task<WebGroupManagement?> GetGroupManagementForGmAsync(Guid groupId, long gmId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
return null;
}
var group = await sessionStore.GetGroupAsync(groupId);
if (group is null)
{
return null;
}
var managers = await sessionStore.GetGroupManagersAsync(groupId);
var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, gmId);
return new WebGroupManagement(group, managers, isOwner);
}
public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
@@ -70,6 +90,17 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
await sessionStore.UpdateBatchDetailsAsync(batchId, batch.GroupId, title.Trim(), joinLink.Trim());
}
public async Task UpdateBatchNotificationModeForGmAsync(Guid batchId, long gmId, SessionNotificationMode notificationMode)
{
var batch = await GetBatchForGmAsync(batchId, gmId);
if (batch is null)
{
throw new SessionAccessDeniedException(batchId, gmId);
}
await sessionStore.UpdateBatchNotificationModeAsync(batchId, batch.GroupId, notificationMode);
}
public async Task RescheduleBatchForGmAsync(Guid batchId, long gmId, DateTime firstScheduledAt, int intervalDays)
{
if (intervalDays <= 0)
@@ -97,9 +128,45 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval);
}
public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
{
if (coGmTelegramId <= 0)
{
throw new ArgumentOutOfRangeException(nameof(coGmTelegramId), coGmTelegramId, "Telegram id must be greater than zero.");
}
if (ownerTelegramId == coGmTelegramId)
{
throw new InvalidOperationException("Owner is already a group manager.");
}
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
{
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
}
var normalizedName = string.IsNullOrWhiteSpace(displayName)
? $"Telegram {coGmTelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
: displayName.Trim();
var normalizedUsername = string.IsNullOrWhiteSpace(telegramUsername)
? null
: telegramUsername.Trim().TrimStart('@');
await sessionStore.AddGroupCoGmAsync(groupId, ownerTelegramId, coGmTelegramId, normalizedName, normalizedUsername);
}
public async Task RemoveCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId)
{
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
{
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
}
await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId);
}
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
{
var group = await sessionStore.GetGroupAsync(groupId);
return group?.GmTelegramId == gmId;
return await sessionStore.IsGroupManagerAsync(groupId, gmId);
}
}
@@ -1,3 +1,5 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services;
public enum BatchCloneInterval
@@ -13,7 +15,8 @@ public sealed record WebSessionBatch(
string JoinLink,
DateTime FirstScheduledAt,
DateTime LastScheduledAt,
int SessionCount);
int SessionCount,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
public static class BatchSchedulePlanner
{
@@ -1,15 +1,23 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services;
public interface ISessionStore
{
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId);
Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId);
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
Task<WebSession?> GetSessionAsync(Guid sessionId);
Task<WebSessionBatch?> GetBatchAsync(Guid batchId);
Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers);
Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId);
Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink);
Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode);
Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays);
Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval);
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername);
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId);
}
+315 -19
View File
@@ -6,7 +6,24 @@ using Telegram.Bot;
namespace GmRelay.Web.Services;
public sealed record WebGameGroup(Guid Id, long TelegramChatId, string Name, long GmTelegramId);
public sealed record WebGameGroup(
Guid Id,
long TelegramChatId,
string Name,
long GmTelegramId,
string ManagerRole = GroupManagerRoleExtensions.OwnerValue);
public sealed record WebGroupManager(
long TelegramId,
string DisplayName,
string? TelegramUsername,
string Role,
DateTime AddedAt);
public sealed record WebGroupManagement(
WebGameGroup Group,
IReadOnlyList<WebGroupManager> Managers,
bool CurrentUserIsOwner);
public sealed record WebSession(
Guid Id,
Guid GroupId,
@@ -19,9 +36,11 @@ public sealed record WebSession(
long TelegramChatId,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount);
int WaitlistedPlayerCount,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName);
internal sealed record WebDirectNotificationRecipient(long TelegramId, string DisplayName);
internal sealed record WebBatchInfo(
Guid BatchId,
Guid GroupId,
@@ -29,7 +48,8 @@ internal sealed record WebBatchInfo(
string JoinLink,
long TelegramChatId,
int? BatchMessageId,
int? ThreadId);
int? ThreadId,
string NotificationMode);
internal sealed record WebBatchSessionRow(
Guid Id,
@@ -41,7 +61,8 @@ internal sealed record WebBatchSessionRow(
int? MaxPlayers,
int? BatchMessageId,
long TelegramChatId,
int? ThreadId);
int? ThreadId,
string NotificationMode);
public sealed class SessionService(
NpgsqlDataSource dataSource,
@@ -52,7 +73,18 @@ public sealed class SessionService(
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGameGroup>(
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE gm_telegram_id = @GmId",
"""
SELECT g.id,
g.telegram_chat_id AS TelegramChatId,
g.name,
g.gm_telegram_id AS GmTelegramId,
gm.role AS ManagerRole
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE p.telegram_id = @GmId
ORDER BY g.name
""",
new { GmId = gmId })).ToList();
}
@@ -60,8 +92,145 @@ public sealed class SessionService(
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE id = @GroupId",
new { GroupId = groupId });
"""
SELECT g.id,
g.telegram_chat_id AS TelegramChatId,
g.name,
g.gm_telegram_id AS GmTelegramId,
@OwnerRole AS ManagerRole
FROM game_groups g
WHERE g.id = @GroupId
""",
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
}
public async Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
AND p.telegram_id = @TelegramId
)
""",
new { GroupId = groupId, TelegramId = telegramId });
}
public async Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
AND p.telegram_id = @TelegramId
AND gm.role = @OwnerRole
)
""",
new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
}
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGroupManager>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
gm.role AS Role,
gm.created_at AS AddedAt
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END,
gm.created_at,
p.display_name
""",
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList();
}
public async Task AddGroupCoGmAsync(
Guid groupId,
long ownerTelegramId,
long coGmTelegramId,
string displayName,
string? telegramUsername)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
await conn.ExecuteAsync(
"""
INSERT INTO players (telegram_id, display_name, telegram_username)
VALUES (@TelegramId, @DisplayName, @TelegramUsername)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username
""",
new
{
TelegramId = coGmTelegramId,
DisplayName = displayName,
TelegramUsername = telegramUsername
},
transaction);
await conn.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role, added_by_player_id)
SELECT @GroupId,
co_gm.id,
@CoGmRole,
owner_player.id
FROM players co_gm
LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId
WHERE co_gm.telegram_id = @CoGmTelegramId
ON CONFLICT (group_id, player_id) DO UPDATE
SET role = CASE
WHEN group_managers.role = @OwnerRole THEN group_managers.role
ELSE EXCLUDED.role
END,
added_by_player_id = EXCLUDED.added_by_player_id
""",
new
{
GroupId = groupId,
OwnerTelegramId = ownerTelegramId,
CoGmTelegramId = coGmTelegramId,
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
},
transaction);
await transaction.CommitAsync();
}
public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"""
DELETE FROM group_managers gm
USING players p
WHERE gm.player_id = p.id
AND gm.group_id = @GroupId
AND p.telegram_id = @CoGmTelegramId
AND gm.role = @CoGmRole
""",
new
{
GroupId = groupId,
CoGmTelegramId = coGmTelegramId,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
});
}
public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
@@ -73,7 +242,8 @@ public sealed class SessionService(
g.telegram_chat_id AS TelegramChatId,
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -109,7 +279,8 @@ public sealed class SessionService(
g.telegram_chat_id AS TelegramChatId,
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -146,7 +317,8 @@ public sealed class SessionService(
(array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink,
MIN(s.scheduled_at) AS FirstScheduledAt,
MAX(s.scheduled_at) AS LastScheduledAt,
COUNT(*)::int AS SessionCount
COUNT(*)::int AS SessionCount,
(array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
FROM sessions s
WHERE s.batch_id = @BatchId
GROUP BY s.batch_id, s.group_id
@@ -165,7 +337,8 @@ public sealed class SessionService(
g.telegram_chat_id AS TelegramChatId,
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @Id AND s.group_id = @GroupId",
@@ -183,6 +356,10 @@ public sealed class SessionService(
scheduled_at = @ScheduledAt,
join_link = @JoinLink,
max_players = @MaxPlayers,
one_hour_reminder_processed_at = CASE
WHEN scheduled_at <> @ScheduledAt THEN NULL
ELSE one_hour_reminder_processed_at
END,
updated_at = now()
WHERE id = @Id AND group_id = @GroupId",
new
@@ -217,6 +394,13 @@ public sealed class SessionService(
await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var recipients = await LoadSessionDirectRecipientsAsync(conn, sessionId);
await SendDirectNotificationsAsync(recipients, notification, "web-session-updated", sessionId);
}
if (oldSession.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title);
@@ -234,7 +418,8 @@ public sealed class SessionService(
g.telegram_chat_id AS TelegramChatId,
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
@@ -363,6 +548,41 @@ public sealed class SessionService(
}
}
public async Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
if (batch is null)
{
throw new SessionAccessDeniedException(batchId, 0);
}
var updatedRows = await conn.ExecuteAsync(
"""
UPDATE sessions
SET notification_mode = @NotificationMode,
updated_at = now()
WHERE batch_id = @BatchId
AND group_id = @GroupId
""",
new
{
BatchId = batchId,
GroupId = groupId,
NotificationMode = notificationMode.ToDatabaseValue()
},
transaction);
if (updatedRows == 0)
{
throw new SessionAccessDeniedException(batchId, 0);
}
await transaction.CommitAsync();
}
public async Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays)
{
await using var conn = await dataSource.OpenConnectionAsync();
@@ -379,7 +599,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers,
s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.batch_id = @BatchId
@@ -406,6 +627,7 @@ public sealed class SessionService(
"""
UPDATE sessions
SET scheduled_at = @ScheduledAt,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
@@ -431,6 +653,13 @@ public sealed class SessionService(
$"↔️ Шаг: <b>{intervalDays} дн.</b>";
await bot.SendMessage(firstSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(firstSession.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var recipients = await LoadBatchDirectRecipientsAsync(conn, batchId);
await SendDirectNotificationsAsync(recipients, notification, "web-batch-rescheduled", batchId);
}
}
public async Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval)
@@ -449,7 +678,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers,
s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.batch_id = @BatchId
@@ -477,8 +707,8 @@ public sealed class SessionService(
var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
var sessionId = await conn.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players)
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @MaxPlayers)
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players, notification_mode)
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @MaxPlayers, @NotificationMode)
RETURNING id
""",
new
@@ -490,7 +720,8 @@ public sealed class SessionService(
ScheduledAt = scheduledAt,
Status = SessionStatus.Planned,
ThreadId = threadId,
sourceSession.MaxPlayers
sourceSession.MaxPlayers,
sourceSession.NotificationMode
},
transaction);
@@ -518,7 +749,71 @@ public sealed class SessionService(
batchJoinLink,
renderedSessions.Min(session => session.ScheduledAt),
renderedSessions.Max(session => session.ScheduledAt),
renderedSessions.Count);
renderedSessions.Count,
sourceSessions[0].NotificationMode);
}
private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync(
Npgsql.NpgsqlConnection conn,
Guid sessionId)
{
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName
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 { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
}
private async Task<List<WebDirectNotificationRecipient>> LoadBatchDirectRecipientsAsync(
Npgsql.NpgsqlConnection conn,
Guid batchId)
{
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
"""
SELECT DISTINCT p.telegram_id AS TelegramId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
JOIN sessions s ON s.id = sp.session_id
WHERE s.batch_id = @BatchId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { BatchId = batchId, Active = ParticipantRegistrationStatus.Active })).ToList();
}
private async Task SendDirectNotificationsAsync(
IEnumerable<WebDirectNotificationRecipient> recipients,
string htmlText,
string notificationKind,
Guid entityId)
{
foreach (var recipient in recipients)
{
try
{
await bot.SendMessage(
chatId: recipient.TelegramId,
text: htmlText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Failed to send {NotificationKind} DM for entity {EntityId} to player {TelegramId} ({DisplayName})",
notificationKind,
entityId,
recipient.TelegramId,
recipient.DisplayName);
}
}
}
private async Task TryUpdateBatchMessageAsync(Guid batchId, long chatId, int messageId, string title)
@@ -572,7 +867,8 @@ public sealed class SessionService(
(array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink,
g.telegram_chat_id AS TelegramChatId,
(array_agg(s.batch_message_id ORDER BY s.scheduled_at))[1] AS BatchMessageId,
(array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId
(array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId,
(array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.batch_id = @BatchId
+1 -1
View File
@@ -1,5 +1,5 @@
/* ============================================
GM-Relay Design System v1.4.1
GM-Relay Design System v1.6.0
Dark RPG Dashboard Theme
============================================ */
@@ -0,0 +1,16 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Domain;
public sealed class SessionNotificationModeTests
{
[Theory]
[InlineData(SessionNotificationMode.GroupAndDirect, true)]
[InlineData(SessionNotificationMode.GroupOnly, false)]
public void ShouldSendDirectMessages_ReturnsExpectedDecision(
SessionNotificationMode mode,
bool expected)
{
Assert.Equal(expected, mode.ShouldSendDirectMessages());
}
}
@@ -1,4 +1,5 @@
using GmRelay.Web.Services;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Web;
@@ -27,6 +28,34 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal("Session A", sessions[0].Title);
}
[Fact]
public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenUserIsCoGm()
{
var ownerId = 1001L;
var coGmId = 2002L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", ownerId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
],
managers:
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, coGmId);
Assert.NotNull(sessions);
Assert.Single(sessions);
Assert.Equal("Session A", sessions[0].Title);
}
[Fact]
public async Task GetUpcomingSessionsForGmAsync_ReturnsNull_WhenGroupBelongsToAnotherGm()
{
@@ -210,6 +239,152 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal("https://example.test/b", store.LastUpdatedBatchJoinLink);
}
[Fact]
public async Task UpdateBatchDetailsForGmAsync_UpdatesBatch_WhenUserIsCoGm()
{
var ownerId = 1001L;
var coGmId = 2002L;
var groupId = Guid.NewGuid();
var batchId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", ownerId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
],
managers:
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
await service.UpdateBatchDetailsForGmAsync(batchId, coGmId, "Updated", "https://example.test/b");
Assert.True(store.UpdateBatchDetailsCalled);
Assert.Equal(batchId, store.LastUpdatedBatchId);
Assert.Equal(groupId, store.LastUpdatedBatchGroupId);
}
[Fact]
public async Task AddCoGmForOwnerAsync_AddsCoGm_WhenUserIsOwner()
{
var ownerId = 1001L;
var coGmId = 2002L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", ownerId)
]);
var service = new AuthorizedSessionService(store);
await service.AddCoGmForOwnerAsync(groupId, ownerId, coGmId, "Assistant GM", "assistant");
Assert.True(store.AddCoGmCalled);
Assert.Equal(groupId, store.LastAddedCoGmGroupId);
Assert.Equal(coGmId, store.LastAddedCoGmTelegramId);
Assert.Equal("Assistant GM", store.LastAddedCoGmDisplayName);
Assert.Equal("assistant", store.LastAddedCoGmUsername);
}
[Fact]
public async Task AddCoGmForOwnerAsync_Throws_WhenUserIsCoGm()
{
var ownerId = 1001L;
var coGmId = 2002L;
var newCoGmId = 3003L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", ownerId)
],
managers:
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.AddCoGmForOwnerAsync(groupId, coGmId, newCoGmId, "Second Assistant", null);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.AddCoGmCalled);
}
[Fact]
public async Task RemoveCoGmForOwnerAsync_RemovesCoGm_WhenUserIsOwner()
{
var ownerId = 1001L;
var coGmId = 2002L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", ownerId)
],
managers:
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
await service.RemoveCoGmForOwnerAsync(groupId, ownerId, coGmId);
Assert.True(store.RemoveCoGmCalled);
Assert.Equal(groupId, store.LastRemovedCoGmGroupId);
Assert.Equal(coGmId, store.LastRemovedCoGmTelegramId);
}
[Fact]
public async Task UpdateBatchNotificationModeForGmAsync_Throws_WhenBatchBelongsToAnotherGm()
{
var batchId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.UpdateBatchNotificationModeForGmAsync(batchId, 1001L, SessionNotificationMode.GroupOnly);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateBatchNotificationModeCalled);
}
[Fact]
public async Task UpdateBatchNotificationModeForGmAsync_UpdatesOwnedBatch()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var batchId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
await service.UpdateBatchNotificationModeForGmAsync(batchId, gmId, SessionNotificationMode.GroupOnly);
Assert.True(store.UpdateBatchNotificationModeCalled);
Assert.Equal(batchId, store.LastUpdatedNotificationBatchId);
Assert.Equal(groupId, store.LastUpdatedNotificationGroupId);
Assert.Equal(SessionNotificationMode.GroupOnly, store.LastUpdatedNotificationMode);
}
[Fact]
public async Task RescheduleBatchForGmAsync_RejectsNonPositiveInterval()
{
@@ -287,16 +462,21 @@ public sealed class AuthorizedSessionServiceTests
private sealed class FakeSessionStore(
IEnumerable<WebGameGroup>? groups = null,
IEnumerable<WebSession>? sessions = null) : ISessionStore
IEnumerable<WebSession>? sessions = null,
IEnumerable<FakeGroupManager>? managers = null) : ISessionStore
{
private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? [];
private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.Id) ?? [];
private readonly List<FakeGroupManager> managers = managers?.ToList() ?? [];
public bool UpdateCalled { get; private set; }
public bool PromoteCalled { get; private set; }
public bool UpdateBatchDetailsCalled { get; private set; }
public bool UpdateBatchNotificationModeCalled { get; private set; }
public bool RescheduleBatchCalled { get; private set; }
public bool CloneBatchCalled { get; private set; }
public bool AddCoGmCalled { get; private set; }
public bool RemoveCoGmCalled { get; private set; }
public Guid? LastUpdatedSessionId { get; private set; }
public Guid? LastUpdatedGroupId { get; private set; }
public string? LastUpdatedTitle { get; private set; }
@@ -309,6 +489,9 @@ public sealed class AuthorizedSessionServiceTests
public Guid? LastUpdatedBatchGroupId { get; private set; }
public string? LastUpdatedBatchTitle { get; private set; }
public string? LastUpdatedBatchJoinLink { get; private set; }
public Guid? LastUpdatedNotificationBatchId { get; private set; }
public Guid? LastUpdatedNotificationGroupId { get; private set; }
public SessionNotificationMode? LastUpdatedNotificationMode { get; private set; }
public Guid? LastRescheduledBatchId { get; private set; }
public Guid? LastRescheduledBatchGroupId { get; private set; }
public DateTime? LastRescheduledFirstScheduledAt { get; private set; }
@@ -316,9 +499,15 @@ public sealed class AuthorizedSessionServiceTests
public Guid? LastClonedBatchId { get; private set; }
public Guid? LastClonedBatchGroupId { get; private set; }
public BatchCloneInterval? LastCloneInterval { get; private set; }
public Guid? LastAddedCoGmGroupId { get; private set; }
public long? LastAddedCoGmTelegramId { get; private set; }
public string? LastAddedCoGmDisplayName { get; private set; }
public string? LastAddedCoGmUsername { get; private set; }
public Guid? LastRemovedCoGmGroupId { get; private set; }
public long? LastRemovedCoGmTelegramId { get; private set; }
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList());
Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, gmId)).ToList());
public Task<WebGameGroup?> GetGroupAsync(Guid groupId)
{
@@ -326,6 +515,36 @@ public sealed class AuthorizedSessionServiceTests
return Task.FromResult(group);
}
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsManager(groupId, telegramId));
public Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsOwner(groupId, telegramId));
public Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
{
if (!groupsById.TryGetValue(groupId, out var group))
{
return Task.FromResult(new List<WebGroupManager>());
}
var result = new List<WebGroupManager>
{
new(group.GmTelegramId, "Owner GM", null, GroupManagerRoleExtensions.OwnerValue, DateTime.UtcNow)
};
result.AddRange(managers
.Where(manager => manager.GroupId == groupId)
.Select(manager => new WebGroupManager(
manager.TelegramId,
$"Co-GM {manager.TelegramId}",
null,
manager.Role.ToDatabaseValue(),
DateTime.UtcNow)));
return Task.FromResult(result);
}
public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) =>
Task.FromResult(sessionsById.Values.Where(session => session.GroupId == groupId).ToList());
@@ -388,6 +607,15 @@ public sealed class AuthorizedSessionServiceTests
return Task.CompletedTask;
}
public Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode)
{
UpdateBatchNotificationModeCalled = true;
LastUpdatedNotificationBatchId = batchId;
LastUpdatedNotificationGroupId = groupId;
LastUpdatedNotificationMode = notificationMode;
return Task.CompletedTask;
}
public Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays)
{
RescheduleBatchCalled = true;
@@ -413,5 +641,32 @@ public sealed class AuthorizedSessionServiceTests
DateTime.UtcNow.AddDays(7),
1));
}
public Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
{
AddCoGmCalled = true;
LastAddedCoGmGroupId = groupId;
LastAddedCoGmTelegramId = coGmTelegramId;
LastAddedCoGmDisplayName = displayName;
LastAddedCoGmUsername = telegramUsername;
return Task.CompletedTask;
}
public Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
{
RemoveCoGmCalled = true;
LastRemovedCoGmGroupId = groupId;
LastRemovedCoGmTelegramId = coGmTelegramId;
return Task.CompletedTask;
}
private bool IsManager(Guid groupId, long telegramId) =>
IsOwner(groupId, telegramId) ||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
private bool IsOwner(Guid groupId, long telegramId) =>
groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId == telegramId;
}
private sealed record FakeGroupManager(Guid GroupId, long TelegramId, GroupManagerRole Role);
}