feat: send personal player notifications
Deploy Telegram Bot / build-and-push (push) Successful in 3m36s
Deploy Telegram Bot / deploy (push) Successful in 11s

This commit is contained in:
2026-04-27 10:11:11 +03:00
parent 3228e77c7f
commit a8f2b10956
23 changed files with 666 additions and 38 deletions
@@ -1,3 +1,5 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services;
public sealed class AuthorizedSessionService(ISessionStore sessionStore)
@@ -70,6 +72,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)
@@ -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,3 +1,5 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services;
public interface ISessionStore
@@ -10,6 +12,7 @@ public interface ISessionStore
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);
}
+146 -15
View File
@@ -19,9 +19,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 +31,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 +44,8 @@ internal sealed record WebBatchSessionRow(
int? MaxPlayers,
int? BatchMessageId,
long TelegramChatId,
int? ThreadId);
int? ThreadId,
string NotificationMode);
public sealed class SessionService(
NpgsqlDataSource dataSource,
@@ -73,7 +77,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 +114,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 +152,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 +172,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 +191,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 +229,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 +253,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 +383,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 +434,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 +462,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 +488,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 +513,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 +542,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 +555,8 @@ public sealed class SessionService(
ScheduledAt = scheduledAt,
Status = SessionStatus.Planned,
ThreadId = threadId,
sourceSession.MaxPlayers
sourceSession.MaxPlayers,
sourceSession.NotificationMode
},
transaction);
@@ -518,7 +584,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 +702,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