feat(platform): route scheduler notifications through platform messenger
PR Checks / test-and-build (pull_request) Successful in 7m9s
PR Checks / test-and-build (pull_request) Successful in 7m9s
This commit is contained in:
@@ -1,318 +0,0 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||
|
||||
public sealed record HandleRsvpCommand(
|
||||
Guid SessionId,
|
||||
long TelegramUserId,
|
||||
string Status,
|
||||
string CallbackQueryId,
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
|
||||
|
||||
internal sealed record SessionContext(
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
long GmTelegramId,
|
||||
long TelegramChatId,
|
||||
int? ThreadId);
|
||||
|
||||
internal sealed record ParticipantRsvp(
|
||||
long TelegramId,
|
||||
string DisplayName,
|
||||
string? TelegramUsername,
|
||||
string RsvpStatus);
|
||||
|
||||
public sealed class HandleRsvpHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
ILogger<HandleRsvpHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(HandleRsvpCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
var participantExists = await connection.ExecuteScalarAsync<bool>(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
)
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction);
|
||||
|
||||
if (!participantExists)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(
|
||||
callbackQueryId: command.CallbackQueryId,
|
||||
text: "Вы не являетесь участником этой сессии.",
|
||||
cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE session_participants
|
||||
SET rsvp_status = @Status,
|
||||
responded_at = now()
|
||||
WHERE session_id = @SessionId
|
||||
AND player_id = (SELECT id FROM players WHERE telegram_id = @TelegramUserId)
|
||||
AND registration_status = @Active
|
||||
AND rsvp_status != @Status
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId, command.Status, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction);
|
||||
|
||||
if (updated == 0)
|
||||
{
|
||||
var alreadyText = command.Status == RsvpStatus.Confirmed
|
||||
? "Вы уже подтвердили участие."
|
||||
: "Вы уже отказались от участия.";
|
||||
|
||||
await bot.AnswerCallbackQuery(
|
||||
callbackQueryId: command.CallbackQueryId,
|
||||
text: alreadyText,
|
||||
cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var session = await connection.QuerySingleAsync<SessionContext>(
|
||||
"""
|
||||
SELECT s.title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.status AS Status,
|
||||
g.gm_telegram_id AS GmTelegramId,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.thread_id AS ThreadId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId
|
||||
""",
|
||||
new { command.SessionId },
|
||||
transaction);
|
||||
|
||||
if (command.Status == RsvpStatus.Declined)
|
||||
{
|
||||
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0);
|
||||
|
||||
if (decision.ShouldRevertSessionToConfirmationSent)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET status = @ConfirmationSent, updated_at = now()
|
||||
WHERE id = @SessionId AND status = @Confirmed
|
||||
""",
|
||||
new
|
||||
{
|
||||
command.SessionId,
|
||||
ConfirmationSent = SessionStatus.ConfirmationSent,
|
||||
Confirmed = SessionStatus.Confirmed
|
||||
},
|
||||
transaction);
|
||||
}
|
||||
|
||||
var declinedPlayer = await connection.QuerySingleAsync<string>(
|
||||
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
|
||||
new { command.TelegramUserId },
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: session.GmTelegramId,
|
||||
text: $"🚨 Отмена! {declinedPlayer} не сможет прийти на игру «{session.Title}».",
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", command.SessionId);
|
||||
}
|
||||
|
||||
await bot.AnswerCallbackQuery(
|
||||
callbackQueryId: command.CallbackQueryId,
|
||||
text: decision.CallbackText,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var counts = await connection.QuerySingleAsync<RsvpCounts>(
|
||||
"""
|
||||
SELECT
|
||||
count(*) AS Total,
|
||||
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
|
||||
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
|
||||
FROM session_participants
|
||||
WHERE session_id = @SessionId AND is_gm = false
|
||||
AND registration_status = @Active
|
||||
""",
|
||||
new
|
||||
{
|
||||
command.SessionId,
|
||||
Confirmed = RsvpStatus.Confirmed,
|
||||
Declined = RsvpStatus.Declined,
|
||||
Active = ParticipantRegistrationStatus.Active
|
||||
},
|
||||
transaction);
|
||||
|
||||
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed);
|
||||
|
||||
if (decision.ShouldMarkSessionConfirmed)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET status = @Confirmed, updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
new { command.SessionId, Confirmed = SessionStatus.Confirmed },
|
||||
transaction);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
if (decision.ShouldNotifyGroup)
|
||||
{
|
||||
try
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: session.TelegramChatId,
|
||||
messageThreadId: session.ThreadId,
|
||||
text: $"🎉 Игра «{session.Title}» подтверждена! Все участники на месте.",
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", command.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (decision.ShouldNotifyGm)
|
||||
{
|
||||
try
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: session.GmTelegramId,
|
||||
text: $"✅ Все подтвердили участие в «{session.Title}» ({session.ScheduledAt.FormatMoscow()} МСК).",
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", command.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
await bot.AnswerCallbackQuery(
|
||||
callbackQueryId: command.CallbackQueryId,
|
||||
text: decision.CallbackText,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
await UpdateConfirmationMessage(command, session, ct);
|
||||
}
|
||||
|
||||
private async Task UpdateConfirmationMessage(HandleRsvpCommand command, SessionContext session, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var participants = (await connection.QueryAsync<ParticipantRsvp>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
sp.rsvp_status AS RsvpStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
ORDER BY sp.responded_at NULLS LAST
|
||||
""",
|
||||
new { command.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||||
|
||||
var confirmed = participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
|
||||
var declined = participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
|
||||
var pending = participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList();
|
||||
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"🎲 Подтвердите участие в «{session.Title}»",
|
||||
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
|
||||
string.Empty
|
||||
};
|
||||
|
||||
foreach (var participant in confirmed)
|
||||
{
|
||||
lines.Add($" ✅ {FormatName(participant)}");
|
||||
}
|
||||
|
||||
foreach (var participant in declined)
|
||||
{
|
||||
lines.Add($" ❌ ~~{FormatName(participant)}~~");
|
||||
}
|
||||
|
||||
foreach (var participant in pending)
|
||||
{
|
||||
lines.Add($" ⏳ {FormatName(participant)}");
|
||||
}
|
||||
|
||||
lines.Add(string.Empty);
|
||||
|
||||
if (confirmed.Count == participants.Count)
|
||||
{
|
||||
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})");
|
||||
}
|
||||
else if (declined.Count > 0)
|
||||
{
|
||||
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)");
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})");
|
||||
}
|
||||
|
||||
var text = string.Join("\n", lines);
|
||||
|
||||
var replyMarkup = confirmed.Count == participants.Count
|
||||
? null
|
||||
: new InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{command.SessionId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{command.SessionId}")
|
||||
]
|
||||
]);
|
||||
|
||||
await bot.EditMessageText(
|
||||
chatId: command.ChatId,
|
||||
messageId: command.MessageId,
|
||||
text: text,
|
||||
replyMarkup: replyMarkup,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatName(ParticipantRsvp participant) =>
|
||||
participant.TelegramUsername is not null ? $"@{participant.TelegramUsername}" : participant.DisplayName;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Shared.Domain;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||
|
||||
// ── DTOs for Dapper mapping ──────────────────────────────────────────
|
||||
|
||||
internal sealed record SessionInfo(
|
||||
Guid Id,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
Guid GroupId,
|
||||
long TelegramChatId,
|
||||
int? ThreadId,
|
||||
string NotificationMode);
|
||||
|
||||
internal sealed record ParticipantInfo(
|
||||
long TelegramId,
|
||||
string DisplayName,
|
||||
string? TelegramUsername);
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Sends the interactive confirmation message (inline keyboard) to the group chat.
|
||||
/// Called by SessionSchedulerService at T-24h.
|
||||
/// </summary>
|
||||
public sealed class SendConfirmationHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
DirectSessionNotificationSender directSender,
|
||||
ILogger<SendConfirmationHandler> logger) : ISendConfirmationHandler
|
||||
{
|
||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
// 1. Load session + group info
|
||||
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,
|
||||
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.id = @SessionId AND s.status = @Planned
|
||||
""",
|
||||
new { SessionId = sessionId, Planned = SessionStatus.Planned });
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
logger.LogWarning("Session {SessionId} not found or not in Planned status", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Load non-GM participants
|
||||
var participants = (await connection.QueryAsync<ParticipantInfo>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername
|
||||
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();
|
||||
|
||||
if (participants.Count == 0)
|
||||
{
|
||||
logger.LogWarning("Session {SessionId} has no non-GM participants", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Build confirmation message
|
||||
var playerList = string.Join("\n", participants.Select(p =>
|
||||
$" ⏳ {FormatPlayerName(p)}"));
|
||||
|
||||
var text = $"""
|
||||
🎲 Подтвердите участие в «{session.Title}»
|
||||
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
|
||||
|
||||
{playerList}
|
||||
|
||||
Статус: ожидаем подтверждения (0/{participants.Count})
|
||||
""";
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{sessionId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{sessionId}")
|
||||
]
|
||||
]);
|
||||
|
||||
// 4. Send to group
|
||||
var message = await bot.SendMessage(
|
||||
chatId: session.TelegramChatId,
|
||||
messageThreadId: session.ThreadId,
|
||||
text: text,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: ct);
|
||||
|
||||
// 5. Update session status, store message ID, and mark confirmation sent
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET status = @Status,
|
||||
confirmation_message_id = @MessageId,
|
||||
confirmation_sent_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
AND confirmation_sent_at IS NULL
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Status = SessionStatus.ConfirmationSent,
|
||||
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);
|
||||
}
|
||||
|
||||
internal static string FormatPlayerName(ParticipantInfo p) =>
|
||||
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Shared.Domain;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
|
||||
namespace GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record JoinLinkSession(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string JoinLink,
|
||||
DateTime ScheduledAt,
|
||||
long TelegramChatId,
|
||||
int? ThreadId,
|
||||
string NotificationMode);
|
||||
|
||||
internal sealed record ConfirmedPlayer(
|
||||
long TelegramId,
|
||||
string DisplayName,
|
||||
string? TelegramUsername);
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Sends the join link to the group chat at T-5min, tagging all confirmed players.
|
||||
/// Called by SessionSchedulerService.
|
||||
/// </summary>
|
||||
public sealed class SendJoinLinkHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
DirectSessionNotificationSender directSender,
|
||||
ILogger<SendJoinLinkHandler> logger) : ISendJoinLinkHandler
|
||||
{
|
||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
// 1. Load session
|
||||
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,
|
||||
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.id = @SessionId
|
||||
AND s.status = @Confirmed
|
||||
AND s.link_message_id IS NULL
|
||||
""",
|
||||
new { SessionId = sessionId, Confirmed = SessionStatus.Confirmed });
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
logger.LogWarning("Session {SessionId} not eligible for join link", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Load confirmed players
|
||||
var players = (await connection.QueryAsync<ConfirmedPlayer>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND sp.rsvp_status = @Confirmed
|
||||
AND sp.registration_status = @Active
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Confirmed = RsvpStatus.Confirmed,
|
||||
Active = ParticipantRegistrationStatus.Active
|
||||
})).ToList();
|
||||
|
||||
// 3. Build message with player mentions
|
||||
var mentions = string.Join(", ", players.Select(p =>
|
||||
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName));
|
||||
|
||||
var text = $"""
|
||||
🎮 Игра «{session.Title}» начинается через 5 минут!
|
||||
|
||||
🔗 Ссылка на подключение:
|
||||
{session.JoinLink}
|
||||
|
||||
Участники: {mentions}
|
||||
|
||||
Хорошей игры! 🎲
|
||||
""";
|
||||
|
||||
// 4. Send
|
||||
var message = await bot.SendMessage(
|
||||
chatId: session.TelegramChatId,
|
||||
messageThreadId: session.ThreadId,
|
||||
text: text,
|
||||
cancellationToken: ct);
|
||||
|
||||
// 5. Mark as sent (idempotent — link_message_id IS NULL guard in query)
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET link_message_id = @MessageId, updated_at = now()
|
||||
WHERE id = @SessionId AND link_message_id IS NULL
|
||||
""",
|
||||
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);
|
||||
}
|
||||
}
|
||||
+28
-44
@@ -1,13 +1,11 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Notifications;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
|
||||
@@ -19,9 +17,8 @@ internal sealed record TelegramProposalFieldsDto(
|
||||
|
||||
public sealed class RescheduleVotingDeadlineService(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
IPlatformMessenger messenger,
|
||||
DirectSessionNotificationSender directSender,
|
||||
PlatformDirectNotificationSender directSender,
|
||||
RescheduleVotingFinalizer finalizer,
|
||||
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
|
||||
{
|
||||
@@ -98,7 +95,7 @@ public sealed class RescheduleVotingDeadlineService(
|
||||
}
|
||||
|
||||
var directRecipients = result.Participants
|
||||
.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName))
|
||||
.Select(p => TelegramPlatformIds.User(p.TelegramId, p.DisplayName))
|
||||
.ToList();
|
||||
|
||||
await TryUpdateVoteMessage(result, telegramFields, ct);
|
||||
@@ -130,28 +127,24 @@ public sealed class RescheduleVotingDeadlineService(
|
||||
|
||||
try
|
||||
{
|
||||
var resultText = result.SelectedOption is not null
|
||||
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {result.SelectedOption.DisplayOrder}: <b>{result.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
|
||||
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(result.Decision.Reason)}";
|
||||
|
||||
var text = $"""
|
||||
{HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||
await messenger.UpdateRescheduleVoteAsync(
|
||||
new PlatformRescheduleVoteUpdate(
|
||||
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
|
||||
TelegramPlatformIds.Message(
|
||||
telegramFields.TelegramChatId,
|
||||
telegramFields.ThreadId,
|
||||
telegramFields.VoteMessageId.Value),
|
||||
result.ProposalId,
|
||||
result.SessionId,
|
||||
result.Title,
|
||||
result.CurrentScheduledAt,
|
||||
result.VotingDeadlineAt,
|
||||
result.Decision,
|
||||
result.SelectedOption,
|
||||
result.Options,
|
||||
result.Participants,
|
||||
result.Votes)}
|
||||
|
||||
{resultText}
|
||||
""";
|
||||
|
||||
await bot.EditMessageText(
|
||||
chatId: telegramFields.TelegramChatId,
|
||||
messageId: telegramFields.VoteMessageId.Value,
|
||||
text: text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
result.Votes,
|
||||
result.Participants),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -201,7 +194,7 @@ public sealed class RescheduleVotingDeadlineService(
|
||||
{
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
|
||||
$"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(result.Title)}».",
|
||||
$"Расписание обновлено после голосования за перенос сессии \"{System.Net.WebUtility.HtmlEncode(result.Title)}\".",
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -213,29 +206,20 @@ public sealed class RescheduleVotingDeadlineService(
|
||||
|
||||
private async Task SendDirectResult(
|
||||
RescheduleVotingFinalizerResult result,
|
||||
IReadOnlyList<DirectNotificationRecipient> recipients,
|
||||
IReadOnlyList<PlatformUser> recipients,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var htmlText = result.SelectedOption is not null
|
||||
? $"""
|
||||
✅ <b>Сессия перенесена по итогам голосования</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
|
||||
📅 Новое время: <b>{result.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)
|
||||
"""
|
||||
: $"""
|
||||
❌ <b>Перенос сессии отклонён по итогам голосования</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
|
||||
📅 Время остаётся прежним: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
||||
Причина: {System.Net.WebUtility.HtmlEncode(result.Decision.Reason)}
|
||||
""";
|
||||
|
||||
await directSender.SendAsync(
|
||||
result.SelectedOption is not null
|
||||
? PlatformDirectSessionNotificationKind.RescheduleApproved
|
||||
: PlatformDirectSessionNotificationKind.RescheduleRejected,
|
||||
recipients,
|
||||
htmlText,
|
||||
result.SelectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
|
||||
result.SessionId,
|
||||
result.Title,
|
||||
result.SelectedOption?.ProposedAt.UtcDateTime ?? result.CurrentScheduledAt,
|
||||
joinLink: null,
|
||||
actorDisplayName: null,
|
||||
reason: result.SelectedOption is null ? result.Decision.Reason : null,
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Globalization;
|
||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
@@ -125,6 +127,135 @@ public sealed class TelegramPlatformMessenger(
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task<PlatformMessageRef> SendConfirmationRequestAsync(PlatformConfirmationRequest request, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(request.Group.Platform);
|
||||
|
||||
var chatId = ParseLong(request.Group.ExternalGroupId);
|
||||
var threadId = ParseNullableInt(request.Group.ExternalThreadId);
|
||||
var message = await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
messageThreadId: threadId,
|
||||
text: BuildConfirmationText(request),
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: BuildRsvpKeyboard(request.SessionId),
|
||||
cancellationToken: ct);
|
||||
|
||||
return TelegramPlatformIds.Message(chatId, threadId, message.MessageId);
|
||||
}
|
||||
|
||||
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
|
||||
{
|
||||
var request = update.Request;
|
||||
EnsureTelegram(request.Group.Platform);
|
||||
var existingMessage = request.ExistingMessage
|
||||
?? throw new ArgumentException("Existing confirmation message reference is required.", nameof(update));
|
||||
|
||||
EnsureTelegram(existingMessage.Platform);
|
||||
await bot.EditMessageText(
|
||||
chatId: ParseLong(existingMessage.ExternalGroupId),
|
||||
messageId: ParseInt(existingMessage.ExternalMessageId),
|
||||
text: BuildConfirmationText(request),
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: update.DisableActions ? null : BuildRsvpKeyboard(request.SessionId),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task<PlatformMessageRef> SendJoinLinkNotificationAsync(
|
||||
PlatformJoinLinkNotification notification,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(notification.Group.Platform);
|
||||
|
||||
var chatId = ParseLong(notification.Group.ExternalGroupId);
|
||||
var threadId = ParseNullableInt(notification.Group.ExternalThreadId);
|
||||
var message = await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
messageThreadId: threadId,
|
||||
text: BuildJoinLinkText(notification),
|
||||
cancellationToken: ct);
|
||||
|
||||
return TelegramPlatformIds.Message(chatId, threadId, message.MessageId);
|
||||
}
|
||||
|
||||
public Task SendDirectSessionNotificationAsync(
|
||||
PlatformDirectSessionNotification notification,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(notification.Recipient.Platform);
|
||||
return bot.SendMessage(
|
||||
chatId: ParseLong(notification.Recipient.ExternalUserId),
|
||||
text: BuildDirectNotificationText(notification),
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct)
|
||||
{
|
||||
switch (notification.Kind)
|
||||
{
|
||||
case PlatformRsvpOutcomeKind.GroupAllConfirmed:
|
||||
if (notification.Group is null)
|
||||
{
|
||||
throw new ArgumentException("Group notification requires a group.", nameof(notification));
|
||||
}
|
||||
|
||||
EnsureTelegram(notification.Group.Platform);
|
||||
await bot.SendMessage(
|
||||
chatId: ParseLong(notification.Group.ExternalGroupId),
|
||||
messageThreadId: ParseNullableInt(notification.Group.ExternalThreadId),
|
||||
text: $"🎉 Игра «{notification.Title}» подтверждена! Все участники на месте.",
|
||||
cancellationToken: ct);
|
||||
break;
|
||||
|
||||
case PlatformRsvpOutcomeKind.GmAllConfirmed:
|
||||
case PlatformRsvpOutcomeKind.GmPlayerDeclined:
|
||||
foreach (var recipient in notification.Recipients)
|
||||
{
|
||||
EnsureTelegram(recipient.Platform);
|
||||
await bot.SendMessage(
|
||||
chatId: ParseLong(recipient.ExternalUserId),
|
||||
text: BuildRsvpOutcomeDirectText(notification),
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(notification), notification.Kind, "Unknown RSVP outcome kind.");
|
||||
}
|
||||
}
|
||||
|
||||
public Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(update.Group.Platform);
|
||||
EnsureTelegram(update.ExistingMessage.Platform);
|
||||
|
||||
var resultText = update.SelectedOption is not null
|
||||
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {update.SelectedOption.DisplayOrder}: <b>{update.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
|
||||
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(update.Decision.Reason)}";
|
||||
|
||||
var text = $"""
|
||||
{HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||
update.Title,
|
||||
update.CurrentScheduledAt,
|
||||
update.VotingDeadlineAt,
|
||||
update.Options,
|
||||
update.Participants,
|
||||
update.Votes)}
|
||||
|
||||
{resultText}
|
||||
""";
|
||||
|
||||
return bot.EditMessageText(
|
||||
chatId: ParseLong(update.ExistingMessage.ExternalGroupId),
|
||||
messageId: ParseInt(update.ExistingMessage.ExternalMessageId),
|
||||
text: text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
private async Task<Message> SendScheduleTextMessage(
|
||||
long chatId,
|
||||
int? threadId,
|
||||
@@ -139,6 +270,134 @@ public sealed class TelegramPlatformMessenger(
|
||||
replyMarkup: markup,
|
||||
cancellationToken: ct);
|
||||
|
||||
private static string BuildConfirmationText(PlatformConfirmationRequest request)
|
||||
{
|
||||
var confirmed = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
|
||||
var declined = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
|
||||
var pending = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList();
|
||||
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"🎲 Подтвердите участие в «{System.Net.WebUtility.HtmlEncode(request.Title)}»",
|
||||
$"📅 {request.ScheduledAt.FormatMoscow()} (МСК)",
|
||||
string.Empty
|
||||
};
|
||||
|
||||
foreach (var participant in confirmed)
|
||||
{
|
||||
lines.Add($" ✅ {FormatTelegramParticipant(participant)}");
|
||||
}
|
||||
|
||||
foreach (var participant in declined)
|
||||
{
|
||||
lines.Add($" ❌ <s>{FormatTelegramParticipant(participant)}</s>");
|
||||
}
|
||||
|
||||
foreach (var participant in pending)
|
||||
{
|
||||
lines.Add($" ⏳ {FormatTelegramParticipant(participant)}");
|
||||
}
|
||||
|
||||
lines.Add(string.Empty);
|
||||
|
||||
if (request.Participants.Count > 0 && confirmed.Count == request.Participants.Count)
|
||||
{
|
||||
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{request.Participants.Count})");
|
||||
}
|
||||
else if (declined.Count > 0)
|
||||
{
|
||||
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{request.Participants.Count} подтвердили)");
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{request.Participants.Count})");
|
||||
}
|
||||
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
private static string BuildJoinLinkText(PlatformJoinLinkNotification notification)
|
||||
{
|
||||
var mentions = string.Join(", ", notification.ConfirmedPlayers.Select(FormatTelegramParticipant));
|
||||
|
||||
return $"""
|
||||
🎮 Игра «{notification.Title}» начинается через 5 минут!
|
||||
|
||||
🔗 Ссылка на подключение:
|
||||
{notification.JoinLink}
|
||||
|
||||
Участники: {mentions}
|
||||
|
||||
Хорошей игры! 🎲
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildDirectNotificationText(PlatformDirectSessionNotification notification) =>
|
||||
notification.Kind switch
|
||||
{
|
||||
PlatformDirectSessionNotificationKind.ConfirmationRequest => $"""
|
||||
🎲 <b>Подтвердите участие в игре</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
|
||||
|
||||
Ответьте кнопкой в групповом сообщении расписания.
|
||||
""",
|
||||
PlatformDirectSessionNotificationKind.OneHourReminder => $"""
|
||||
⏰ <b>Игра начнётся примерно через 1 час</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
|
||||
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
|
||||
""",
|
||||
PlatformDirectSessionNotificationKind.JoinLink => $"""
|
||||
🎮 <b>Игра начинается через 5 минут</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
|
||||
""",
|
||||
PlatformDirectSessionNotificationKind.RescheduleApproved => $"""
|
||||
✅ <b>Сессия перенесена по итогам голосования</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
📅 Новое время: <b>{notification.ScheduledAt.FormatMoscow()}</b> (МСК)
|
||||
""",
|
||||
PlatformDirectSessionNotificationKind.RescheduleRejected => $"""
|
||||
❌ <b>Перенос сессии отклонён по итогам голосования</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
📅 Время остаётся прежним: <b>{notification.ScheduledAt.FormatMoscow()}</b> (МСК)
|
||||
Причина: {System.Net.WebUtility.HtmlEncode(notification.Reason ?? string.Empty)}
|
||||
""",
|
||||
_ => BuildFallbackDirectText(notification)
|
||||
};
|
||||
|
||||
private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) =>
|
||||
$"<b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>\n{notification.ScheduledAt.FormatMoscow()} (МСК)";
|
||||
|
||||
private static string BuildRsvpOutcomeDirectText(PlatformRsvpOutcomeNotification notification) =>
|
||||
notification.Kind switch
|
||||
{
|
||||
PlatformRsvpOutcomeKind.GmAllConfirmed =>
|
||||
$"✅ Все подтвердили участие в «{System.Net.WebUtility.HtmlEncode(notification.Title)}» ({notification.ScheduledAt.FormatMoscow()} МСК).",
|
||||
PlatformRsvpOutcomeKind.GmPlayerDeclined =>
|
||||
$"🚨 Отмена! {System.Net.WebUtility.HtmlEncode(notification.ActorDisplayName ?? "Игрок")} не сможет прийти на игру «{System.Net.WebUtility.HtmlEncode(notification.Title)}».",
|
||||
_ => System.Net.WebUtility.HtmlEncode(notification.Title)
|
||||
};
|
||||
|
||||
private static InlineKeyboardMarkup BuildRsvpKeyboard(Guid sessionId) =>
|
||||
new([
|
||||
[
|
||||
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{sessionId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{sessionId}")
|
||||
]
|
||||
]);
|
||||
|
||||
private static string FormatTelegramParticipant(PlatformSessionParticipant participant) =>
|
||||
participant.User.ExternalUsername is not null
|
||||
? $"@{participant.User.ExternalUsername}"
|
||||
: System.Net.WebUtility.HtmlEncode(participant.User.DisplayName);
|
||||
|
||||
private async Task TrySendScheduleImageOnly(
|
||||
long chatId,
|
||||
int? threadId,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Bot.Features.Sessions.ListSessions;
|
||||
using GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||||
@@ -187,11 +187,11 @@ public sealed class UpdateRouter(
|
||||
|
||||
var command = new HandleRsvpCommand(
|
||||
SessionId: sessionId,
|
||||
TelegramUserId: query.From.Id,
|
||||
User: user,
|
||||
Status: status,
|
||||
CallbackQueryId: query.Id,
|
||||
ChatId: message.Chat.Id,
|
||||
MessageId: message.MessageId);
|
||||
InteractionId: query.Id,
|
||||
Group: group,
|
||||
ConfirmationMessage: scheduleMessage);
|
||||
|
||||
await rsvpHandler.HandleAsync(command, ct);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
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;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Bot.Infrastructure.Health;
|
||||
using GmRelay.Bot.Infrastructure.Logging;
|
||||
using GmRelay.Bot.Infrastructure.Scheduling;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
|
||||
using GmRelay.Shared.Features.Notifications;
|
||||
using GmRelay.Shared.Features.Reminders.SendJoinLink;
|
||||
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Infrastructure.Scheduling;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
@@ -54,11 +54,12 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||
});
|
||||
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
|
||||
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
|
||||
builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Telegram));
|
||||
|
||||
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
||||
builder.Services.AddSingleton<SendConfirmationHandler>();
|
||||
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
|
||||
builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
||||
builder.Services.AddSingleton<PlatformDirectNotificationSender>();
|
||||
builder.Services.AddSingleton<HandleRsvpHandler>();
|
||||
builder.Services.AddSingleton<SendJoinLinkHandler>();
|
||||
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
|
||||
@@ -85,7 +86,7 @@ builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
|
||||
builder.Services.AddHostedService<TelegramBotService>();
|
||||
|
||||
// ── Clock and scheduling ──────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<ISystemClock, SystemClock>();
|
||||
builder.Services.AddSingleton<ISystemClock, GmRelay.Bot.Infrastructure.Scheduling.SystemClock>();
|
||||
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
|
||||
|
||||
// ── Session scheduler ────────────────────────────────────────────────
|
||||
|
||||
@@ -664,6 +664,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Dapper": "[2.1.72, )",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )",
|
||||
"Npgsql": "[10.0.2, )"
|
||||
}
|
||||
|
||||
+67
-54
@@ -1,19 +1,15 @@
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using Npgsql;
|
||||
|
||||
public sealed class DiscordRescheduleVotingDeadlineService(
|
||||
NpgsqlDataSource dataSource,
|
||||
RescheduleVotingFinalizer finalizer,
|
||||
RestClient restClient,
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<DiscordRescheduleVotingDeadlineService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -27,7 +23,9 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
||||
await ProcessDueProposals(stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessDueProposals(CancellationToken ct)
|
||||
@@ -57,10 +55,8 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
||||
if (result.SourcePlatform != "Discord")
|
||||
return;
|
||||
|
||||
// Update Discord vote message
|
||||
await TryUpdateDiscordVoteMessage(result, ct);
|
||||
|
||||
// If approved, update batch schedule
|
||||
if (result.SelectedOption is not null)
|
||||
{
|
||||
await TryUpdateBatchScheduleAsync(result, ct);
|
||||
@@ -68,7 +64,9 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
||||
|
||||
logger.LogInformation(
|
||||
"Finalized Discord reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
|
||||
proposalId, result.SessionId, result.Decision.Outcome);
|
||||
proposalId,
|
||||
result.SessionId,
|
||||
result.Decision.Outcome);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -83,10 +81,13 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var msgRef = await connection.QuerySingleOrDefaultAsync<PlatformMessageRefDto>(
|
||||
"""
|
||||
SELECT external_channel_id AS ExternalChannelId, external_message_id AS ExternalMessageId
|
||||
FROM platform_messages
|
||||
WHERE session_id = @SessionId AND purpose = 'reschedule_vote' AND platform = 'Discord'
|
||||
ORDER BY created_at DESC
|
||||
SELECT g.external_group_id AS ExternalGroupId,
|
||||
COALESCE(pm.external_channel_id, g.external_channel_id, g.external_group_id) AS ExternalChannelId,
|
||||
pm.external_message_id AS ExternalMessageId
|
||||
FROM platform_messages pm
|
||||
JOIN game_groups g ON g.id = pm.group_id
|
||||
WHERE pm.session_id = @SessionId AND pm.purpose = 'reschedule_vote' AND pm.platform = 'Discord'
|
||||
ORDER BY pm.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
new { result.SessionId });
|
||||
@@ -94,31 +95,27 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
||||
if (msgRef is null)
|
||||
return;
|
||||
|
||||
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
|
||||
result.Title, result.CurrentScheduledAt, result.VotingDeadlineAt,
|
||||
result.Options, result.Participants, result.Votes);
|
||||
var group = CreateDiscordGroup(msgRef);
|
||||
|
||||
var channelId = ulong.Parse(msgRef.ExternalChannelId);
|
||||
var messageId = ulong.Parse(msgRef.ExternalMessageId);
|
||||
|
||||
// Disable buttons after finalization
|
||||
var disabledRow = new ActionRowProperties();
|
||||
foreach (var btn in actionRow.OfType<ButtonProperties>())
|
||||
{
|
||||
disabledRow.Add(new ButtonProperties(btn.CustomId, btn.Label ?? string.Empty, ButtonStyle.Secondary) { Disabled = true });
|
||||
}
|
||||
|
||||
var resultText = result.SelectedOption is not null
|
||||
? $"Голосование завершено. Победил вариант {result.SelectedOption.DisplayOrder}: **{result.SelectedOption.ProposedAt.FormatMoscow()}** (МСК)."
|
||||
: $"Голосование завершено. {result.Decision.Reason}";
|
||||
|
||||
var updatedEmbed = embed.WithDescription($"{embed.Description}\n\n{resultText}");
|
||||
|
||||
await restClient.ModifyMessageAsync(channelId, messageId, options =>
|
||||
{
|
||||
options.Embeds = new[] { updatedEmbed };
|
||||
options.Components = new[] { disabledRow };
|
||||
});
|
||||
await messenger.UpdateRescheduleVoteAsync(
|
||||
new PlatformRescheduleVoteUpdate(
|
||||
group,
|
||||
new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
msgRef.ExternalGroupId,
|
||||
null,
|
||||
msgRef.ExternalMessageId),
|
||||
result.ProposalId,
|
||||
result.SessionId,
|
||||
result.Title,
|
||||
result.CurrentScheduledAt,
|
||||
result.VotingDeadlineAt,
|
||||
result.Decision,
|
||||
result.SelectedOption,
|
||||
result.Options,
|
||||
result.Votes,
|
||||
result.Participants),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -130,14 +127,16 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
||||
{
|
||||
try
|
||||
{
|
||||
// Query batch schedule message ref
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var batchRef = await connection.QuerySingleOrDefaultAsync<PlatformMessageRefDto>(
|
||||
"""
|
||||
SELECT external_channel_id AS ExternalChannelId, external_message_id AS ExternalMessageId
|
||||
FROM platform_messages
|
||||
WHERE batch_id = @BatchId AND purpose = 'schedule' AND platform = 'Discord'
|
||||
ORDER BY created_at DESC
|
||||
SELECT g.external_group_id AS ExternalGroupId,
|
||||
COALESCE(pm.external_channel_id, g.external_channel_id, g.external_group_id) AS ExternalChannelId,
|
||||
pm.external_message_id AS ExternalMessageId
|
||||
FROM platform_messages pm
|
||||
JOIN game_groups g ON g.id = pm.group_id
|
||||
WHERE pm.batch_id = @BatchId AND pm.purpose = 'schedule' AND pm.platform = 'Discord'
|
||||
ORDER BY pm.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
new { result.BatchId });
|
||||
@@ -145,14 +144,16 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
||||
if (batchRef is null)
|
||||
return;
|
||||
|
||||
// Rebuild schedule view and update Discord message
|
||||
var sessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||
new { result.BatchId })).ToList();
|
||||
|
||||
var participants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, COALESCE(p.external_username, p.telegram_username) AS TelegramUsername, sp.registration_status AS RegistrationStatus
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
JOIN sessions s ON sp.session_id = s.id
|
||||
@@ -162,16 +163,18 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
||||
new { result.BatchId })).ToList();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build(result.Title, sessions, participants);
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
|
||||
var group = CreateDiscordGroup(batchRef);
|
||||
|
||||
var channelId = ulong.Parse(batchRef.ExternalChannelId);
|
||||
var messageId = ulong.Parse(batchRef.ExternalMessageId);
|
||||
|
||||
await restClient.ModifyMessageAsync(channelId, messageId, options =>
|
||||
{
|
||||
options.Embeds = embeds;
|
||||
options.Components = actionRows;
|
||||
});
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
group,
|
||||
view,
|
||||
new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
batchRef.ExternalGroupId,
|
||||
null,
|
||||
batchRef.ExternalMessageId)),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -179,5 +182,15 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PlatformMessageRefDto(string ExternalChannelId, string ExternalMessageId);
|
||||
private static PlatformGroup CreateDiscordGroup(PlatformMessageRefDto message) =>
|
||||
new(
|
||||
PlatformKind.Discord,
|
||||
message.ExternalGroupId,
|
||||
message.ExternalGroupId,
|
||||
message.ExternalChannelId);
|
||||
|
||||
internal sealed record PlatformMessageRefDto(
|
||||
string ExternalGroupId,
|
||||
string ExternalChannelId,
|
||||
string ExternalMessageId);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using System.Globalization;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ComponentInteractions;
|
||||
@@ -9,6 +13,7 @@ namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
public sealed class DiscordSessionInteractionModule(
|
||||
JoinSessionHandler joinSessionHandler,
|
||||
LeaveSessionHandler leaveSessionHandler,
|
||||
HandleRsvpHandler rsvpHandler,
|
||||
DiscordRescheduleVoteHandler voteHandler,
|
||||
DiscordInteractionReplyCache interactionReplies,
|
||||
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
|
||||
@@ -67,6 +72,65 @@ public sealed class DiscordSessionInteractionModule(
|
||||
await CompleteWithStoredReplyAsync(input.InteractionId);
|
||||
}
|
||||
|
||||
[ComponentInteraction("rsvp")]
|
||||
public async Task RsvpAsync(string status, string sessionId)
|
||||
{
|
||||
if (!Guid.TryParse(sessionId, out var parsedSessionId))
|
||||
{
|
||||
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
|
||||
return;
|
||||
}
|
||||
|
||||
var rsvpStatus = status switch
|
||||
{
|
||||
"confirm" => RsvpStatus.Confirmed,
|
||||
"decline" => RsvpStatus.Declined,
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (rsvpStatus is null)
|
||||
{
|
||||
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
|
||||
return;
|
||||
}
|
||||
|
||||
var input = CreateInput(parsedSessionId);
|
||||
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
||||
|
||||
try
|
||||
{
|
||||
await rsvpHandler.HandleAsync(
|
||||
new HandleRsvpCommand(
|
||||
parsedSessionId,
|
||||
new PlatformUser(
|
||||
PlatformKind.Discord,
|
||||
Context.User.Id.ToString(CultureInfo.InvariantCulture),
|
||||
string.IsNullOrWhiteSpace(Context.User.GlobalName) ? Context.User.Username : Context.User.GlobalName,
|
||||
Context.User.Username),
|
||||
rsvpStatus,
|
||||
input.InteractionId,
|
||||
new PlatformGroup(
|
||||
PlatformKind.Discord,
|
||||
input.GuildId,
|
||||
input.GuildId,
|
||||
input.ChannelId),
|
||||
new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
input.GuildId,
|
||||
null,
|
||||
input.MessageId)),
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
|
||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||
return;
|
||||
}
|
||||
|
||||
await CompleteWithStoredReplyAsync(input.InteractionId);
|
||||
}
|
||||
|
||||
[ComponentInteraction("reschedule_vote")]
|
||||
public async Task RescheduleVoteAsync(string optionId)
|
||||
{
|
||||
@@ -112,9 +176,9 @@ public sealed class DiscordSessionInteractionModule(
|
||||
return new DiscordSessionInteractionInput(
|
||||
SessionId: sessionId,
|
||||
InteractionId: Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
GuildId: guild.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
ChannelId: Context.Channel.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
MessageId: message.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
GuildId: guild.Id.ToString(CultureInfo.InvariantCulture),
|
||||
ChannelId: Context.Channel.Id.ToString(CultureInfo.InvariantCulture),
|
||||
MessageId: message.Id.ToString(CultureInfo.InvariantCulture),
|
||||
UserId: Context.User.Id,
|
||||
Username: Context.User.Username,
|
||||
DisplayName: Context.User.GlobalName);
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
|
||||
namespace GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
|
||||
public sealed class DiscordPlatformMessenger(
|
||||
RestClient restClient,
|
||||
DiscordInteractionReplyCache interactionReplies) : IPlatformMessenger
|
||||
public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
||||
{
|
||||
private readonly RestClient restClient;
|
||||
private readonly DiscordInteractionReplyCache interactionReplies;
|
||||
private readonly ILogger<DiscordPlatformMessenger>? logger;
|
||||
|
||||
public DiscordPlatformMessenger(
|
||||
RestClient restClient,
|
||||
DiscordInteractionReplyCache interactionReplies)
|
||||
: this(restClient, interactionReplies, logger: null)
|
||||
{
|
||||
}
|
||||
|
||||
public DiscordPlatformMessenger(
|
||||
RestClient restClient,
|
||||
DiscordInteractionReplyCache interactionReplies,
|
||||
ILogger<DiscordPlatformMessenger>? logger)
|
||||
{
|
||||
this.restClient = restClient;
|
||||
this.interactionReplies = interactionReplies;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
|
||||
{
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
|
||||
|
||||
var channelId = ulong.Parse(message.Group.ExternalChannelId
|
||||
?? message.Group.ExternalGroupId);
|
||||
var channelId = GetChannelId(message.Group);
|
||||
|
||||
var msg = await restClient.SendMessageAsync(
|
||||
channelId,
|
||||
@@ -27,7 +49,7 @@ public sealed class DiscordPlatformMessenger(
|
||||
PlatformKind.Discord,
|
||||
message.Group.ExternalGroupId,
|
||||
null,
|
||||
msg.Id.ToString());
|
||||
msg.Id.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
|
||||
@@ -37,9 +59,8 @@ public sealed class DiscordPlatformMessenger(
|
||||
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
|
||||
|
||||
var channelId = ulong.Parse(message.Group.ExternalChannelId
|
||||
?? message.Group.ExternalGroupId);
|
||||
var messageId = ulong.Parse(message.ExistingMessage.ExternalMessageId);
|
||||
var channelId = GetChannelId(message.Group);
|
||||
var messageId = ParseSnowflake(message.ExistingMessage.ExternalMessageId);
|
||||
|
||||
await restClient.ModifyMessageAsync(
|
||||
channelId,
|
||||
@@ -53,18 +74,12 @@ public sealed class DiscordPlatformMessenger(
|
||||
|
||||
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
|
||||
{
|
||||
var channelIdStr = group.ExternalChannelId ?? group.ExternalGroupId
|
||||
?? throw new InvalidOperationException("Group has no ExternalChannelId or ExternalGroupId.");
|
||||
|
||||
if (!ulong.TryParse(channelIdStr, out var channelId))
|
||||
throw new InvalidOperationException($"Invalid Discord channel/group ID: '{channelIdStr}'.");
|
||||
|
||||
await restClient.SendMessageAsync(channelId, htmlText);
|
||||
await restClient.SendMessageAsync(GetChannelId(group), htmlText);
|
||||
}
|
||||
|
||||
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
|
||||
public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
await SendDirectContentAsync(message.Recipient, message.HtmlText, ct);
|
||||
}
|
||||
|
||||
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct)
|
||||
@@ -77,4 +92,281 @@ public sealed class DiscordPlatformMessenger(
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<PlatformMessageRef> SendConfirmationRequestAsync(
|
||||
PlatformConfirmationRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var channelId = GetChannelId(request.Group);
|
||||
var message = await restClient.SendMessageAsync(
|
||||
channelId,
|
||||
new MessageProperties()
|
||||
.WithEmbeds([BuildConfirmationEmbed(request)])
|
||||
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
request.Group.ExternalGroupId,
|
||||
null,
|
||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
|
||||
{
|
||||
if (update.Request.ExistingMessage is null)
|
||||
return;
|
||||
|
||||
var channelId = GetChannelId(update.Request.Group);
|
||||
var messageId = ParseSnowflake(update.Request.ExistingMessage.ExternalMessageId);
|
||||
var components = BuildRsvpRows(update.Request.SessionId, update.DisableActions);
|
||||
|
||||
await restClient.ModifyMessageAsync(
|
||||
channelId,
|
||||
messageId,
|
||||
options =>
|
||||
{
|
||||
options.Embeds = [BuildConfirmationEmbed(update.Request)];
|
||||
options.Components = components;
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<PlatformMessageRef> SendJoinLinkNotificationAsync(
|
||||
PlatformJoinLinkNotification notification,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var channelId = GetChannelId(notification.Group);
|
||||
var message = await restClient.SendMessageAsync(
|
||||
channelId,
|
||||
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
notification.Group.ExternalGroupId,
|
||||
null,
|
||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
public async Task SendDirectSessionNotificationAsync(
|
||||
PlatformDirectSessionNotification notification,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendDirectContentAsync(
|
||||
notification.Recipient,
|
||||
BuildDirectContent(notification),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
ex,
|
||||
"Failed to send Discord direct notification {NotificationKind} for session {SessionId} to user {ExternalUserId}",
|
||||
notification.Kind,
|
||||
notification.SessionId,
|
||||
notification.Recipient.ExternalUserId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct)
|
||||
{
|
||||
if (notification.Kind == PlatformRsvpOutcomeKind.GroupAllConfirmed && notification.Group is not null)
|
||||
{
|
||||
await restClient.SendMessageAsync(
|
||||
GetChannelId(notification.Group),
|
||||
BuildRsvpGroupOutcomeContent(notification));
|
||||
return;
|
||||
}
|
||||
|
||||
var directKind = notification.Kind == PlatformRsvpOutcomeKind.GmPlayerDeclined
|
||||
? PlatformDirectSessionNotificationKind.RsvpDeclined
|
||||
: PlatformDirectSessionNotificationKind.RsvpAllConfirmed;
|
||||
|
||||
foreach (var recipient in notification.Recipients)
|
||||
{
|
||||
await SendDirectSessionNotificationAsync(
|
||||
new PlatformDirectSessionNotification(
|
||||
directKind,
|
||||
recipient,
|
||||
notification.SessionId,
|
||||
notification.Title,
|
||||
notification.ScheduledAt,
|
||||
ActorDisplayName: notification.ActorDisplayName),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct)
|
||||
{
|
||||
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
|
||||
update.Title,
|
||||
update.CurrentScheduledAt,
|
||||
update.VotingDeadlineAt,
|
||||
update.Options,
|
||||
update.Participants,
|
||||
update.Votes);
|
||||
|
||||
var disabledRow = new ActionRowProperties();
|
||||
foreach (var button in actionRow.OfType<ButtonProperties>())
|
||||
{
|
||||
disabledRow.Add(new ButtonProperties(
|
||||
button.CustomId,
|
||||
button.Label ?? string.Empty,
|
||||
ButtonStyle.Secondary)
|
||||
{
|
||||
Disabled = true
|
||||
});
|
||||
}
|
||||
|
||||
var updatedEmbed = embed.WithDescription(
|
||||
$"{embed.Description}\n\n{BuildRescheduleResultText(update)}");
|
||||
|
||||
await restClient.ModifyMessageAsync(
|
||||
GetChannelId(update.Group),
|
||||
ParseSnowflake(update.ExistingMessage.ExternalMessageId),
|
||||
options =>
|
||||
{
|
||||
options.Embeds = [updatedEmbed];
|
||||
options.Components = [disabledRow];
|
||||
});
|
||||
}
|
||||
|
||||
private static EmbedProperties BuildConfirmationEmbed(PlatformConfirmationRequest request)
|
||||
{
|
||||
var embed = new EmbedProperties()
|
||||
.WithTitle($"Подтверждение: {request.Title}")
|
||||
.WithDescription(BuildConfirmationDescription(request))
|
||||
.WithColor(new Color(0x5865F2));
|
||||
|
||||
return embed.AddFields(
|
||||
[
|
||||
BuildParticipantField("Подтвердили", request.Participants, RsvpStatus.Confirmed),
|
||||
BuildParticipantField("Отказались", request.Participants, RsvpStatus.Declined),
|
||||
BuildParticipantField("Ожидаем ответ", request.Participants, RsvpStatus.Pending)
|
||||
]);
|
||||
}
|
||||
|
||||
private static string BuildConfirmationDescription(PlatformConfirmationRequest request) =>
|
||||
$"Время: **{request.ScheduledAt.FormatMoscow()}** (МСК)\n" +
|
||||
"Подтвердите участие кнопкой ниже.";
|
||||
|
||||
private static EmbedFieldProperties BuildParticipantField(
|
||||
string title,
|
||||
IReadOnlyList<PlatformSessionParticipant> participants,
|
||||
string status)
|
||||
{
|
||||
var values = participants
|
||||
.Where(participant => participant.RsvpStatus == status)
|
||||
.Select(FormatDiscordParticipant)
|
||||
.ToList();
|
||||
|
||||
return new EmbedFieldProperties()
|
||||
.WithName(title)
|
||||
.WithValue(values.Count == 0 ? "—" : string.Join("\n", values))
|
||||
.WithInline();
|
||||
}
|
||||
|
||||
private static EmbedProperties BuildJoinLinkEmbed(PlatformJoinLinkNotification notification)
|
||||
{
|
||||
var mentions = notification.ConfirmedPlayers.Count == 0
|
||||
? "—"
|
||||
: string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User)));
|
||||
|
||||
return new EmbedProperties()
|
||||
.WithTitle($"Ссылка на игру: {notification.Title}")
|
||||
.WithDescription(
|
||||
$"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" +
|
||||
$"Ссылка: {notification.JoinLink}\n\n" +
|
||||
$"Участники: {mentions}")
|
||||
.WithUrl(notification.JoinLink)
|
||||
.WithColor(new Color(0x57F287));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ActionRowProperties> BuildRsvpRows(Guid sessionId, bool disabled)
|
||||
{
|
||||
var row = new ActionRowProperties();
|
||||
row.Add(new ButtonProperties($"rsvp:confirm:{sessionId}", "Буду", ButtonStyle.Success)
|
||||
{
|
||||
Disabled = disabled
|
||||
});
|
||||
row.Add(new ButtonProperties($"rsvp:decline:{sessionId}", "Не смогу", ButtonStyle.Danger)
|
||||
{
|
||||
Disabled = disabled
|
||||
});
|
||||
|
||||
return [row];
|
||||
}
|
||||
|
||||
private static string BuildDirectContent(PlatformDirectSessionNotification notification)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine(notification.Kind switch
|
||||
{
|
||||
PlatformDirectSessionNotificationKind.ConfirmationRequest => "Нужно подтвердить участие",
|
||||
PlatformDirectSessionNotificationKind.OneHourReminder => "Напоминание: сессия через час",
|
||||
PlatformDirectSessionNotificationKind.JoinLink => "Ссылка на игру",
|
||||
PlatformDirectSessionNotificationKind.RsvpAllConfirmed => "Все игроки подтвердили участие",
|
||||
PlatformDirectSessionNotificationKind.RsvpDeclined => "Игрок отказался от участия",
|
||||
PlatformDirectSessionNotificationKind.RescheduleApproved => "Сессия перенесена",
|
||||
PlatformDirectSessionNotificationKind.RescheduleRejected => "Перенос сессии отклонен",
|
||||
_ => "Уведомление по сессии"
|
||||
});
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine($"**{notification.Title}**");
|
||||
builder.AppendLine($"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.JoinLink))
|
||||
builder.AppendLine($"Ссылка: {notification.JoinLink}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.ActorDisplayName))
|
||||
builder.AppendLine($"Игрок: {notification.ActorDisplayName}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.Reason))
|
||||
builder.AppendLine($"Причина: {notification.Reason}");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildRsvpGroupOutcomeContent(PlatformRsvpOutcomeNotification notification) =>
|
||||
$"Все участники подтвердили сессию **{notification.Title}** на " +
|
||||
$"**{notification.ScheduledAt.FormatMoscow()}** (МСК).";
|
||||
|
||||
private static string BuildRescheduleResultText(PlatformRescheduleVoteUpdate update)
|
||||
{
|
||||
if (update.SelectedOption is not null)
|
||||
{
|
||||
return "Голосование завершено. " +
|
||||
$"Победил вариант {update.SelectedOption.DisplayOrder}: " +
|
||||
$"**{update.SelectedOption.ProposedAt.FormatMoscow()}** (МСК).";
|
||||
}
|
||||
|
||||
return $"Голосование завершено. {update.Decision.Reason}";
|
||||
}
|
||||
|
||||
private async Task SendDirectContentAsync(PlatformUser recipient, string content, CancellationToken ct)
|
||||
{
|
||||
var userId = ParseSnowflake(recipient.ExternalUserId);
|
||||
var dm = await restClient.GetDMChannelAsync(userId, cancellationToken: ct);
|
||||
await restClient.SendMessageAsync(
|
||||
dm.Id,
|
||||
new MessageProperties().WithContent(content),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
private static string FormatDiscordParticipant(PlatformSessionParticipant participant) =>
|
||||
$"{Mention(participant.User)} ({participant.User.DisplayName})";
|
||||
|
||||
private static string Mention(PlatformUser user) => $"<@{user.ExternalUserId}>";
|
||||
|
||||
private static ulong GetChannelId(PlatformGroup group)
|
||||
{
|
||||
var channelId = group.ExternalChannelId ?? group.ExternalGroupId
|
||||
?? throw new InvalidOperationException("Discord group has no channel or group identifier.");
|
||||
|
||||
return ParseSnowflake(channelId);
|
||||
}
|
||||
|
||||
private static ulong ParseSnowflake(string value) =>
|
||||
ulong.Parse(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,14 @@ using GmRelay.DiscordBot.Features.Sessions;
|
||||
using GmRelay.DiscordBot.Infrastructure;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.DiscordBot.Infrastructure.Logging;
|
||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
|
||||
using GmRelay.Shared.Features.Notifications;
|
||||
using GmRelay.Shared.Features.Reminders.SendJoinLink;
|
||||
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Infrastructure.Scheduling;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -54,7 +60,18 @@ builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordInteractionReplyCache>();
|
||||
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
|
||||
builder.Services.AddSingleton<ISystemClock, SystemClock>();
|
||||
builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Discord));
|
||||
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
|
||||
builder.Services.AddSingleton<PlatformDirectNotificationSender>();
|
||||
builder.Services.AddSingleton<SendConfirmationHandler>();
|
||||
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
|
||||
builder.Services.AddSingleton<SendJoinLinkHandler>();
|
||||
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
|
||||
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
||||
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
|
||||
builder.Services.AddSingleton<HandleRsvpHandler>();
|
||||
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
|
||||
builder.Services.AddHostedService<SessionSchedulerService>();
|
||||
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
|
||||
|
||||
builder.Services
|
||||
|
||||
@@ -669,6 +669,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Dapper": "[2.1.72, )",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )",
|
||||
"Npgsql": "[10.0.2, )"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
using System.Globalization;
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Notifications;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||
|
||||
public sealed record HandleRsvpCommand(
|
||||
Guid SessionId,
|
||||
PlatformUser User,
|
||||
string Status,
|
||||
string InteractionId,
|
||||
PlatformGroup Group,
|
||||
PlatformMessageRef ConfirmationMessage);
|
||||
|
||||
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
|
||||
|
||||
internal sealed record RsvpSessionContext(
|
||||
Guid GroupId,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status);
|
||||
|
||||
internal sealed record ParticipantRsvpRow(
|
||||
string Platform,
|
||||
string ExternalUserId,
|
||||
string DisplayName,
|
||||
string? ExternalUsername,
|
||||
string RsvpStatus,
|
||||
string RegistrationStatus,
|
||||
bool IsGm);
|
||||
|
||||
internal sealed record RsvpRecipientRow(
|
||||
string Platform,
|
||||
string ExternalUserId,
|
||||
string DisplayName,
|
||||
string? ExternalUsername);
|
||||
|
||||
public sealed class HandleRsvpHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<HandleRsvpHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(HandleRsvpCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
var participantExists = await connection.ExecuteScalarAsync<bool>(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND COALESCE(p.platform, 'Telegram') = @Platform
|
||||
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @ExternalUserId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
)
|
||||
""",
|
||||
new
|
||||
{
|
||||
command.SessionId,
|
||||
Platform = command.User.Platform.ToString(),
|
||||
command.User.ExternalUserId,
|
||||
Active = ParticipantRegistrationStatus.Active
|
||||
},
|
||||
transaction);
|
||||
|
||||
if (!participantExists)
|
||||
{
|
||||
await messenger.AnswerInteractionAsync(
|
||||
new PlatformInteractionReply(
|
||||
command.InteractionId,
|
||||
"Вы не являетесь участником этой сессии."),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE session_participants
|
||||
SET rsvp_status = @Status,
|
||||
responded_at = now()
|
||||
WHERE session_id = @SessionId
|
||||
AND player_id = (
|
||||
SELECT id
|
||||
FROM players
|
||||
WHERE COALESCE(platform, 'Telegram') = @Platform
|
||||
AND COALESCE(external_user_id, telegram_id::TEXT) = @ExternalUserId
|
||||
LIMIT 1
|
||||
)
|
||||
AND registration_status = @Active
|
||||
AND rsvp_status != @Status
|
||||
""",
|
||||
new
|
||||
{
|
||||
command.SessionId,
|
||||
command.Status,
|
||||
Platform = command.User.Platform.ToString(),
|
||||
command.User.ExternalUserId,
|
||||
Active = ParticipantRegistrationStatus.Active
|
||||
},
|
||||
transaction);
|
||||
|
||||
if (updated == 0)
|
||||
{
|
||||
var alreadyText = command.Status == RsvpStatus.Confirmed
|
||||
? "Вы уже подтвердили участие."
|
||||
: "Вы уже отказались от участия.";
|
||||
|
||||
await messenger.AnswerInteractionAsync(
|
||||
new PlatformInteractionReply(command.InteractionId, alreadyText),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var session = await connection.QuerySingleAsync<RsvpSessionContext>(
|
||||
"""
|
||||
SELECT s.group_id AS GroupId,
|
||||
s.title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.status AS Status
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
""",
|
||||
new { command.SessionId },
|
||||
transaction);
|
||||
|
||||
if (command.Status == RsvpStatus.Declined)
|
||||
{
|
||||
var decision = RsvpFlowRules.Evaluate(
|
||||
command.Status,
|
||||
session.Status,
|
||||
totalParticipants: 0,
|
||||
confirmedParticipants: 0);
|
||||
|
||||
if (decision.ShouldRevertSessionToConfirmationSent)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET status = @ConfirmationSent, updated_at = now()
|
||||
WHERE id = @SessionId AND status = @Confirmed
|
||||
""",
|
||||
new
|
||||
{
|
||||
command.SessionId,
|
||||
ConfirmationSent = SessionStatus.ConfirmationSent,
|
||||
Confirmed = SessionStatus.Confirmed
|
||||
},
|
||||
transaction);
|
||||
}
|
||||
|
||||
var gmRecipients = (await GetGmRecipientsAsync(connection, session.GroupId, transaction))
|
||||
.ToList();
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
if (gmRecipients.Count > 0)
|
||||
{
|
||||
await messenger.SendRsvpOutcomeAsync(
|
||||
new PlatformRsvpOutcomeNotification(
|
||||
PlatformRsvpOutcomeKind.GmPlayerDeclined,
|
||||
Group: null,
|
||||
gmRecipients,
|
||||
command.SessionId,
|
||||
session.Title,
|
||||
session.ScheduledAt,
|
||||
ActorDisplayName: command.User.DisplayName),
|
||||
ct);
|
||||
}
|
||||
|
||||
await messenger.AnswerInteractionAsync(
|
||||
new PlatformInteractionReply(command.InteractionId, decision.CallbackText),
|
||||
ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var counts = await connection.QuerySingleAsync<RsvpCounts>(
|
||||
"""
|
||||
SELECT
|
||||
count(*) AS Total,
|
||||
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
|
||||
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
|
||||
FROM session_participants
|
||||
WHERE session_id = @SessionId AND is_gm = false
|
||||
AND registration_status = @Active
|
||||
""",
|
||||
new
|
||||
{
|
||||
command.SessionId,
|
||||
Confirmed = RsvpStatus.Confirmed,
|
||||
Declined = RsvpStatus.Declined,
|
||||
Active = ParticipantRegistrationStatus.Active
|
||||
},
|
||||
transaction);
|
||||
|
||||
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed);
|
||||
|
||||
if (decision.ShouldMarkSessionConfirmed)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET status = @Confirmed, updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
new { command.SessionId, Confirmed = SessionStatus.Confirmed },
|
||||
transaction);
|
||||
}
|
||||
|
||||
var gmRecipients = decision.ShouldNotifyGm
|
||||
? (await GetGmRecipientsAsync(connection, session.GroupId, transaction)).ToList()
|
||||
: [];
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
if (decision.ShouldNotifyGroup)
|
||||
{
|
||||
await messenger.SendRsvpOutcomeAsync(
|
||||
new PlatformRsvpOutcomeNotification(
|
||||
PlatformRsvpOutcomeKind.GroupAllConfirmed,
|
||||
command.Group,
|
||||
[],
|
||||
command.SessionId,
|
||||
session.Title,
|
||||
session.ScheduledAt),
|
||||
ct);
|
||||
}
|
||||
|
||||
if (decision.ShouldNotifyGm && gmRecipients.Count > 0)
|
||||
{
|
||||
await messenger.SendRsvpOutcomeAsync(
|
||||
new PlatformRsvpOutcomeNotification(
|
||||
PlatformRsvpOutcomeKind.GmAllConfirmed,
|
||||
Group: null,
|
||||
gmRecipients,
|
||||
command.SessionId,
|
||||
session.Title,
|
||||
session.ScheduledAt),
|
||||
ct);
|
||||
}
|
||||
|
||||
await messenger.AnswerInteractionAsync(
|
||||
new PlatformInteractionReply(command.InteractionId, decision.CallbackText),
|
||||
ct);
|
||||
}
|
||||
|
||||
await UpdateConfirmationMessage(command, session, ct);
|
||||
}
|
||||
|
||||
private async Task UpdateConfirmationMessage(
|
||||
HandleRsvpCommand command,
|
||||
RsvpSessionContext session,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var participants = (await connection.QueryAsync<ParticipantRsvpRow>(
|
||||
"""
|
||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
||||
sp.rsvp_status AS RsvpStatus,
|
||||
sp.registration_status AS RegistrationStatus,
|
||||
sp.is_gm AS IsGm
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
ORDER BY sp.responded_at NULLS LAST
|
||||
""",
|
||||
new { command.SessionId, Active = ParticipantRegistrationStatus.Active }))
|
||||
.Select(ToParticipant)
|
||||
.ToList();
|
||||
|
||||
var disableActions = participants.Count > 0 &&
|
||||
participants.All(participant => participant.RsvpStatus == RsvpStatus.Confirmed);
|
||||
|
||||
await messenger.UpdateConfirmationRequestAsync(
|
||||
new PlatformRsvpMessageUpdate(
|
||||
new PlatformConfirmationRequest(
|
||||
command.Group,
|
||||
command.SessionId,
|
||||
session.Title,
|
||||
session.ScheduledAt,
|
||||
participants,
|
||||
command.ConfirmationMessage),
|
||||
disableActions),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<PlatformUser>> GetGmRecipientsAsync(
|
||||
NpgsqlConnection connection,
|
||||
Guid groupId,
|
||||
NpgsqlTransaction transaction)
|
||||
{
|
||||
var rows = await connection.QueryAsync<RsvpRecipientRow>(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = @GroupId
|
||||
UNION
|
||||
SELECT DISTINCT
|
||||
COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
|
||||
FROM game_groups g
|
||||
JOIN players p ON p.telegram_id = g.gm_telegram_id
|
||||
WHERE g.id = @GroupId
|
||||
AND g.gm_telegram_id IS NOT NULL
|
||||
""",
|
||||
new { GroupId = groupId },
|
||||
transaction);
|
||||
|
||||
return rows.Select(row => new PlatformUser(
|
||||
ParsePlatform(row.Platform),
|
||||
row.ExternalUserId,
|
||||
row.DisplayName,
|
||||
row.ExternalUsername));
|
||||
}
|
||||
|
||||
private static PlatformSessionParticipant ToParticipant(ParticipantRsvpRow row) =>
|
||||
new(
|
||||
new PlatformUser(
|
||||
ParsePlatform(row.Platform),
|
||||
row.ExternalUserId,
|
||||
row.DisplayName,
|
||||
row.ExternalUsername),
|
||||
row.RsvpStatus,
|
||||
row.RegistrationStatus,
|
||||
row.IsGm);
|
||||
|
||||
private static PlatformKind ParsePlatform(string platform) =>
|
||||
Enum.Parse<PlatformKind>(platform, ignoreCase: true);
|
||||
}
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||
namespace GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||
|
||||
internal sealed record RsvpFlowDecision(
|
||||
public sealed record RsvpFlowDecision(
|
||||
string CallbackText,
|
||||
bool ShouldAlertGm,
|
||||
bool ShouldRevertSessionToConfirmationSent,
|
||||
@@ -10,7 +10,7 @@ internal sealed record RsvpFlowDecision(
|
||||
bool ShouldNotifyGroup,
|
||||
bool ShouldNotifyGm);
|
||||
|
||||
internal static class RsvpFlowRules
|
||||
public static class RsvpFlowRules
|
||||
{
|
||||
public static RsvpFlowDecision Evaluate(
|
||||
string requestedStatus,
|
||||
@@ -21,7 +21,7 @@ internal static class RsvpFlowRules
|
||||
if (requestedStatus == RsvpStatus.Declined)
|
||||
{
|
||||
return new RsvpFlowDecision(
|
||||
CallbackText: "\u0412\u044b \u043e\u0442\u043a\u0430\u0437\u0430\u043b\u0438\u0441\u044c \u043e\u0442 \u0443\u0447\u0430\u0441\u0442\u0438\u044f.",
|
||||
CallbackText: "Вы отказались от участия.",
|
||||
ShouldAlertGm: true,
|
||||
ShouldRevertSessionToConfirmationSent: currentSessionStatus == SessionStatus.Confirmed,
|
||||
ShouldMarkSessionConfirmed: false,
|
||||
@@ -32,7 +32,7 @@ internal static class RsvpFlowRules
|
||||
var everyoneConfirmed = confirmedParticipants == totalParticipants;
|
||||
|
||||
return new RsvpFlowDecision(
|
||||
CallbackText: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u0443\u0447\u0430\u0441\u0442\u0438\u0435!",
|
||||
CallbackText: "Вы подтвердили участие!",
|
||||
ShouldAlertGm: false,
|
||||
ShouldRevertSessionToConfirmationSent: false,
|
||||
ShouldMarkSessionConfirmed: everyoneConfirmed,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||
namespace GmRelay.Shared.Features.Confirmation.SendConfirmation;
|
||||
|
||||
public interface ISendConfirmationHandler
|
||||
{
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Globalization;
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Notifications;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Confirmation.SendConfirmation;
|
||||
|
||||
internal sealed record ConfirmationSessionRow(
|
||||
Guid Id,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
Guid GroupId,
|
||||
string Platform,
|
||||
string ExternalGroupId,
|
||||
string DisplayName,
|
||||
string? ExternalChannelId,
|
||||
int? ThreadId,
|
||||
string NotificationMode);
|
||||
|
||||
internal sealed record ConfirmationParticipantRow(
|
||||
string Platform,
|
||||
string ExternalUserId,
|
||||
string DisplayName,
|
||||
string? ExternalUsername,
|
||||
string RsvpStatus,
|
||||
string RegistrationStatus,
|
||||
bool IsGm);
|
||||
|
||||
public sealed class SendConfirmationHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
IPlatformMessenger messenger,
|
||||
PlatformDirectNotificationSender directSender,
|
||||
ILogger<SendConfirmationHandler> logger) : ISendConfirmationHandler
|
||||
{
|
||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var session = await connection.QuerySingleOrDefaultAsync<ConfirmationSessionRow>(
|
||||
"""
|
||||
SELECT s.id,
|
||||
s.title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.group_id AS GroupId,
|
||||
COALESCE(g.platform, 'Telegram') AS Platform,
|
||||
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
|
||||
g.name AS DisplayName,
|
||||
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
|
||||
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.id = @SessionId AND s.status = @Planned
|
||||
""",
|
||||
new { SessionId = sessionId, Planned = SessionStatus.Planned });
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
logger.LogWarning("Session {SessionId} not found or not in Planned status", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
var participants = (await connection.QueryAsync<ConfirmationParticipantRow>(
|
||||
"""
|
||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
||||
sp.rsvp_status AS RsvpStatus,
|
||||
sp.registration_status AS RegistrationStatus,
|
||||
sp.is_gm AS IsGm
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
ORDER BY sp.created_at ASC
|
||||
""",
|
||||
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active }))
|
||||
.Select(ToParticipant)
|
||||
.ToList();
|
||||
|
||||
if (participants.Count == 0)
|
||||
{
|
||||
logger.LogWarning("Session {SessionId} has no non-GM participants", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
var group = CreateGroup(session);
|
||||
var message = await messenger.SendConfirmationRequestAsync(
|
||||
new PlatformConfirmationRequest(
|
||||
group,
|
||||
session.Id,
|
||||
session.Title,
|
||||
session.ScheduledAt,
|
||||
participants),
|
||||
ct);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET status = @Status,
|
||||
confirmation_message_id = @MessageId,
|
||||
confirmation_sent_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
AND confirmation_sent_at IS NULL
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Status = SessionStatus.ConfirmationSent,
|
||||
MessageId = TryGetTelegramMessageId(message)
|
||||
});
|
||||
|
||||
await PersistPlatformMessageAsync(
|
||||
connection,
|
||||
message,
|
||||
session.GroupId,
|
||||
session.Id,
|
||||
batchId: null,
|
||||
purpose: "confirmation");
|
||||
|
||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
||||
if (mode.ShouldSendDirectMessages())
|
||||
{
|
||||
await directSender.SendAsync(
|
||||
PlatformDirectSessionNotificationKind.ConfirmationRequest,
|
||||
participants.Select(p => p.User),
|
||||
session.Id,
|
||||
session.Title,
|
||||
session.ScheduledAt,
|
||||
joinLink: null,
|
||||
actorDisplayName: null,
|
||||
reason: null,
|
||||
ct);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Confirmation sent for session {SessionId} ({Title}), platform={Platform}, message_id={MessageId}",
|
||||
sessionId,
|
||||
session.Title,
|
||||
message.Platform,
|
||||
message.ExternalMessageId);
|
||||
}
|
||||
|
||||
private static PlatformSessionParticipant ToParticipant(ConfirmationParticipantRow row) =>
|
||||
new(
|
||||
new PlatformUser(
|
||||
ParsePlatform(row.Platform),
|
||||
row.ExternalUserId,
|
||||
row.DisplayName,
|
||||
row.ExternalUsername),
|
||||
row.RsvpStatus,
|
||||
row.RegistrationStatus,
|
||||
row.IsGm);
|
||||
|
||||
private static PlatformGroup CreateGroup(ConfirmationSessionRow row) =>
|
||||
new(
|
||||
ParsePlatform(row.Platform),
|
||||
row.ExternalGroupId,
|
||||
row.DisplayName,
|
||||
row.ExternalChannelId,
|
||||
row.ThreadId?.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
private static PlatformKind ParsePlatform(string platform) =>
|
||||
Enum.Parse<PlatformKind>(platform, ignoreCase: true);
|
||||
|
||||
private static int? TryGetTelegramMessageId(PlatformMessageRef message) =>
|
||||
message.Platform == PlatformKind.Telegram &&
|
||||
int.TryParse(message.ExternalMessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId)
|
||||
? messageId
|
||||
: null;
|
||||
|
||||
private static Task PersistPlatformMessageAsync(
|
||||
NpgsqlConnection connection,
|
||||
PlatformMessageRef message,
|
||||
Guid groupId,
|
||||
Guid? sessionId,
|
||||
Guid? batchId,
|
||||
string purpose) =>
|
||||
connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO platform_messages (
|
||||
platform,
|
||||
group_id,
|
||||
batch_id,
|
||||
session_id,
|
||||
external_channel_id,
|
||||
external_thread_id,
|
||||
external_message_id,
|
||||
purpose)
|
||||
VALUES (
|
||||
@Platform,
|
||||
@GroupId,
|
||||
@BatchId,
|
||||
@SessionId,
|
||||
@ExternalChannelId,
|
||||
@ExternalThreadId,
|
||||
@ExternalMessageId,
|
||||
@Purpose)
|
||||
""",
|
||||
new
|
||||
{
|
||||
Platform = message.Platform.ToString(),
|
||||
GroupId = groupId,
|
||||
BatchId = batchId,
|
||||
SessionId = sessionId,
|
||||
ExternalChannelId = message.ExternalGroupId,
|
||||
message.ExternalThreadId,
|
||||
message.ExternalMessageId,
|
||||
Purpose = purpose
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace GmRelay.Shared.Features.Notifications;
|
||||
|
||||
public sealed class PlatformDirectNotificationSender(
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<PlatformDirectNotificationSender> logger)
|
||||
{
|
||||
public async Task SendAsync(
|
||||
PlatformDirectSessionNotificationKind kind,
|
||||
IEnumerable<PlatformUser> recipients,
|
||||
Guid sessionId,
|
||||
string title,
|
||||
DateTime scheduledAt,
|
||||
string? joinLink,
|
||||
string? actorDisplayName,
|
||||
string? reason,
|
||||
CancellationToken ct)
|
||||
{
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
try
|
||||
{
|
||||
await messenger.SendDirectSessionNotificationAsync(
|
||||
new PlatformDirectSessionNotification(
|
||||
kind,
|
||||
recipient,
|
||||
sessionId,
|
||||
title,
|
||||
scheduledAt,
|
||||
joinLink,
|
||||
actorDisplayName,
|
||||
reason),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Failed to send {NotificationKind} notification for session {SessionId} to {Platform} user {ExternalUserId} ({DisplayName})",
|
||||
kind,
|
||||
sessionId,
|
||||
recipient.Platform,
|
||||
recipient.ExternalUserId,
|
||||
recipient.DisplayName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||
namespace GmRelay.Shared.Features.Reminders.SendJoinLink;
|
||||
|
||||
public interface ISendJoinLinkHandler
|
||||
{
|
||||
@@ -0,0 +1,228 @@
|
||||
using System.Globalization;
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Notifications;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Reminders.SendJoinLink;
|
||||
|
||||
internal sealed record JoinLinkSessionRow(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
string Title,
|
||||
string JoinLink,
|
||||
DateTime ScheduledAt,
|
||||
string Platform,
|
||||
string ExternalGroupId,
|
||||
string DisplayName,
|
||||
string? ExternalChannelId,
|
||||
int? ThreadId,
|
||||
string NotificationMode);
|
||||
|
||||
internal sealed record JoinLinkPlayerRow(
|
||||
string Platform,
|
||||
string ExternalUserId,
|
||||
string DisplayName,
|
||||
string? ExternalUsername,
|
||||
string RsvpStatus,
|
||||
string RegistrationStatus,
|
||||
bool IsGm);
|
||||
|
||||
public sealed class SendJoinLinkHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
IPlatformMessenger messenger,
|
||||
PlatformDirectNotificationSender directSender,
|
||||
ILogger<SendJoinLinkHandler> logger) : ISendJoinLinkHandler
|
||||
{
|
||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var session = await connection.QuerySingleOrDefaultAsync<JoinLinkSessionRow>(
|
||||
"""
|
||||
SELECT s.id,
|
||||
s.group_id AS GroupId,
|
||||
s.title,
|
||||
s.join_link AS JoinLink,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
COALESCE(g.platform, 'Telegram') AS Platform,
|
||||
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
|
||||
g.name AS DisplayName,
|
||||
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
|
||||
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.id = @SessionId
|
||||
AND s.status = @Confirmed
|
||||
AND (
|
||||
(COALESCE(g.platform, 'Telegram') = 'Telegram' AND s.link_message_id IS NULL)
|
||||
OR (
|
||||
COALESCE(g.platform, 'Telegram') <> 'Telegram'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM platform_messages pm
|
||||
WHERE pm.session_id = s.id
|
||||
AND pm.platform = COALESCE(g.platform, 'Telegram')
|
||||
AND pm.purpose = 'join_link'
|
||||
)
|
||||
)
|
||||
)
|
||||
""",
|
||||
new { SessionId = sessionId, Confirmed = SessionStatus.Confirmed });
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
logger.LogWarning("Session {SessionId} not eligible for join link", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
var players = (await connection.QueryAsync<JoinLinkPlayerRow>(
|
||||
"""
|
||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
||||
sp.rsvp_status AS RsvpStatus,
|
||||
sp.registration_status AS RegistrationStatus,
|
||||
sp.is_gm AS IsGm
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND sp.rsvp_status = @Confirmed
|
||||
AND sp.registration_status = @Active
|
||||
ORDER BY sp.created_at ASC
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Confirmed = RsvpStatus.Confirmed,
|
||||
Active = ParticipantRegistrationStatus.Active
|
||||
}))
|
||||
.Select(ToParticipant)
|
||||
.ToList();
|
||||
|
||||
var group = CreateGroup(session);
|
||||
var message = await messenger.SendJoinLinkNotificationAsync(
|
||||
new PlatformJoinLinkNotification(
|
||||
group,
|
||||
session.Id,
|
||||
session.Title,
|
||||
session.ScheduledAt,
|
||||
session.JoinLink,
|
||||
players),
|
||||
ct);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET link_message_id = @MessageId, updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
MessageId = TryGetTelegramMessageId(message)
|
||||
});
|
||||
|
||||
await PersistPlatformMessageAsync(
|
||||
connection,
|
||||
message,
|
||||
session.GroupId,
|
||||
session.Id,
|
||||
batchId: null,
|
||||
purpose: "join_link");
|
||||
|
||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
||||
if (mode.ShouldSendDirectMessages())
|
||||
{
|
||||
await directSender.SendAsync(
|
||||
PlatformDirectSessionNotificationKind.JoinLink,
|
||||
players.Select(p => p.User),
|
||||
session.Id,
|
||||
session.Title,
|
||||
session.ScheduledAt,
|
||||
session.JoinLink,
|
||||
actorDisplayName: null,
|
||||
reason: null,
|
||||
ct);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Join link sent for session {SessionId} ({Title}), platform={Platform}, message_id={MessageId}",
|
||||
sessionId,
|
||||
session.Title,
|
||||
message.Platform,
|
||||
message.ExternalMessageId);
|
||||
}
|
||||
|
||||
private static PlatformSessionParticipant ToParticipant(JoinLinkPlayerRow row) =>
|
||||
new(
|
||||
new PlatformUser(
|
||||
ParsePlatform(row.Platform),
|
||||
row.ExternalUserId,
|
||||
row.DisplayName,
|
||||
row.ExternalUsername),
|
||||
row.RsvpStatus,
|
||||
row.RegistrationStatus,
|
||||
row.IsGm);
|
||||
|
||||
private static PlatformGroup CreateGroup(JoinLinkSessionRow row) =>
|
||||
new(
|
||||
ParsePlatform(row.Platform),
|
||||
row.ExternalGroupId,
|
||||
row.DisplayName,
|
||||
row.ExternalChannelId,
|
||||
row.ThreadId?.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
private static PlatformKind ParsePlatform(string platform) =>
|
||||
Enum.Parse<PlatformKind>(platform, ignoreCase: true);
|
||||
|
||||
private static int? TryGetTelegramMessageId(PlatformMessageRef message) =>
|
||||
message.Platform == PlatformKind.Telegram &&
|
||||
int.TryParse(message.ExternalMessageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId)
|
||||
? messageId
|
||||
: null;
|
||||
|
||||
private static Task PersistPlatformMessageAsync(
|
||||
NpgsqlConnection connection,
|
||||
PlatformMessageRef message,
|
||||
Guid groupId,
|
||||
Guid? sessionId,
|
||||
Guid? batchId,
|
||||
string purpose) =>
|
||||
connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO platform_messages (
|
||||
platform,
|
||||
group_id,
|
||||
batch_id,
|
||||
session_id,
|
||||
external_channel_id,
|
||||
external_thread_id,
|
||||
external_message_id,
|
||||
purpose)
|
||||
VALUES (
|
||||
@Platform,
|
||||
@GroupId,
|
||||
@BatchId,
|
||||
@SessionId,
|
||||
@ExternalChannelId,
|
||||
@ExternalThreadId,
|
||||
@ExternalMessageId,
|
||||
@Purpose)
|
||||
""",
|
||||
new
|
||||
{
|
||||
Platform = message.Platform.ToString(),
|
||||
GroupId = groupId,
|
||||
BatchId = batchId,
|
||||
SessionId = sessionId,
|
||||
ExternalChannelId = message.ExternalGroupId,
|
||||
message.ExternalThreadId,
|
||||
message.ExternalMessageId,
|
||||
Purpose = purpose
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
||||
namespace GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
||||
|
||||
public interface ISendOneHourReminderHandler
|
||||
{
|
||||
+38
-18
@@ -1,27 +1,35 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Notifications;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
||||
namespace GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
||||
|
||||
internal sealed record OneHourReminderSession(
|
||||
internal sealed record OneHourReminderSessionRow(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string JoinLink,
|
||||
DateTime ScheduledAt,
|
||||
string NotificationMode);
|
||||
|
||||
internal sealed record OneHourReminderRecipientRow(
|
||||
string Platform,
|
||||
string ExternalUserId,
|
||||
string DisplayName,
|
||||
string? ExternalUsername);
|
||||
|
||||
public sealed class SendOneHourReminderHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DirectSessionNotificationSender directSender,
|
||||
PlatformDirectNotificationSender directSender,
|
||||
ILogger<SendOneHourReminderHandler> logger) : ISendOneHourReminderHandler
|
||||
{
|
||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var session = await connection.QuerySingleOrDefaultAsync<OneHourReminderSession>(
|
||||
var session = await connection.QuerySingleOrDefaultAsync<OneHourReminderSessionRow>(
|
||||
"""
|
||||
SELECT id,
|
||||
title,
|
||||
@@ -46,10 +54,12 @@ public sealed class SendOneHourReminderHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
var recipients = (await connection.QueryAsync<DirectNotificationRecipient>(
|
||||
var recipients = (await connection.QueryAsync<OneHourReminderRecipientRow>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
p.display_name AS DisplayName
|
||||
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
|
||||
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||
p.display_name AS DisplayName,
|
||||
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
@@ -62,20 +72,27 @@ public sealed class SendOneHourReminderHandler(
|
||||
SessionId = sessionId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Declined = RsvpStatus.Declined
|
||||
})).ToList();
|
||||
}))
|
||||
.Select(row => new PlatformUser(
|
||||
ParsePlatform(row.Platform),
|
||||
row.ExternalUserId,
|
||||
row.DisplayName,
|
||||
row.ExternalUsername))
|
||||
.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 directSender.SendAsync(
|
||||
PlatformDirectSessionNotificationKind.OneHourReminder,
|
||||
recipients,
|
||||
session.Id,
|
||||
session.Title,
|
||||
session.ScheduledAt,
|
||||
session.JoinLink,
|
||||
actorDisplayName: null,
|
||||
reason: null,
|
||||
ct);
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
@@ -94,4 +111,7 @@ public sealed class SendOneHourReminderHandler(
|
||||
session.Title,
|
||||
session.NotificationMode);
|
||||
}
|
||||
|
||||
private static PlatformKind ParsePlatform(string platform) =>
|
||||
Enum.Parse<PlatformKind>(platform, ignoreCase: true);
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.72" />
|
||||
<PackageReference Include="Dapper.AOT" Version="1.0.48" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
+40
-17
@@ -2,7 +2,7 @@ using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Scheduling;
|
||||
namespace GmRelay.Shared.Infrastructure.Scheduling;
|
||||
|
||||
public interface ISessionTriggerStore
|
||||
{
|
||||
@@ -11,7 +11,9 @@ public interface ISessionTriggerStore
|
||||
Task<IReadOnlyList<Guid>> GetSessionsNeedingJoinLinkAsync(DateTimeOffset now, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class DbSessionTriggerStore(NpgsqlDataSource dataSource) : ISessionTriggerStore
|
||||
public sealed class DbSessionTriggerStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
PlatformSchedulerOptions options) : ISessionTriggerStore
|
||||
{
|
||||
private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24);
|
||||
private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1);
|
||||
@@ -23,14 +25,17 @@ public sealed class DbSessionTriggerStore(NpgsqlDataSource dataSource) : ISessio
|
||||
|
||||
var results = await connection.QueryAsync<Guid>(
|
||||
"""
|
||||
SELECT id
|
||||
FROM sessions
|
||||
WHERE status = @Planned
|
||||
AND scheduled_at - @LeadTime <= @Now
|
||||
AND confirmation_sent_at IS NULL
|
||||
SELECT s.id
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE g.platform = @Platform
|
||||
AND s.status = @Planned
|
||||
AND s.scheduled_at - @LeadTime <= @Now
|
||||
AND s.confirmation_sent_at IS NULL
|
||||
""",
|
||||
new
|
||||
{
|
||||
Platform = options.Platform.ToString(),
|
||||
Planned = SessionStatus.Planned,
|
||||
LeadTime = ConfirmationLeadTime,
|
||||
Now = now.UtcDateTime
|
||||
@@ -45,14 +50,17 @@ public sealed class DbSessionTriggerStore(NpgsqlDataSource dataSource) : ISessio
|
||||
|
||||
var results = 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
|
||||
SELECT s.id
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE g.platform = @Platform
|
||||
AND s.status IN (@Confirmed, @ConfirmationSent)
|
||||
AND s.scheduled_at - @LeadTime <= @Now
|
||||
AND s.one_hour_reminder_processed_at IS NULL
|
||||
""",
|
||||
new
|
||||
{
|
||||
Platform = options.Platform.ToString(),
|
||||
Confirmed = SessionStatus.Confirmed,
|
||||
ConfirmationSent = SessionStatus.ConfirmationSent,
|
||||
LeadTime = OneHourReminderLeadTime,
|
||||
@@ -68,14 +76,29 @@ public sealed class DbSessionTriggerStore(NpgsqlDataSource dataSource) : ISessio
|
||||
|
||||
var results = await connection.QueryAsync<Guid>(
|
||||
"""
|
||||
SELECT id
|
||||
FROM sessions
|
||||
WHERE status = @Confirmed
|
||||
AND scheduled_at - @LeadTime <= @Now
|
||||
AND link_message_id IS NULL
|
||||
SELECT s.id
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE g.platform = @Platform
|
||||
AND s.status = @Confirmed
|
||||
AND s.scheduled_at - @LeadTime <= @Now
|
||||
AND (
|
||||
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
|
||||
OR (
|
||||
g.platform <> 'Telegram'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM platform_messages pm
|
||||
WHERE pm.session_id = s.id
|
||||
AND pm.platform = g.platform
|
||||
AND pm.purpose = 'join_link'
|
||||
)
|
||||
)
|
||||
)
|
||||
""",
|
||||
new
|
||||
{
|
||||
Platform = options.Platform.ToString(),
|
||||
Confirmed = SessionStatus.Confirmed,
|
||||
LeadTime = JoinLinkLeadTime,
|
||||
Now = now.UtcDateTime
|
||||
@@ -0,0 +1,5 @@
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Shared.Infrastructure.Scheduling;
|
||||
|
||||
public sealed record PlatformSchedulerOptions(PlatformKind Platform);
|
||||
+7
-14
@@ -1,18 +1,15 @@
|
||||
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
||||
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
|
||||
using GmRelay.Shared.Features.Reminders.SendJoinLink;
|
||||
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Scheduling;
|
||||
namespace GmRelay.Shared.Infrastructure.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Stateless scheduler: wakes every 60 seconds, queries PostgreSQL for actionable sessions.
|
||||
/// Three triggers:
|
||||
/// T-24h: send confirmation request with inline keyboard
|
||||
/// T-1h: send one-hour direct reminder
|
||||
/// T-5min: send join link to all confirmed players
|
||||
///
|
||||
/// If the Raspberry Pi reboots, nothing is lost — all state is in the DB.
|
||||
/// All state is kept in the database so worker restarts do not lose scheduled work.
|
||||
/// </summary>
|
||||
public sealed class SessionSchedulerService(
|
||||
ISessionTriggerStore triggerStore,
|
||||
@@ -50,10 +47,6 @@ public sealed class SessionSchedulerService(
|
||||
logger.LogInformation("Session scheduler stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a single scheduler tick using the current clock time.
|
||||
/// Public so it can be called from integration tests with a fake clock.
|
||||
/// </summary>
|
||||
public async Task TickAsync(CancellationToken ct)
|
||||
{
|
||||
var now = clock.UtcNow;
|
||||
@@ -13,4 +13,22 @@ public interface IPlatformMessenger
|
||||
Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct);
|
||||
|
||||
Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct);
|
||||
|
||||
Task<PlatformMessageRef> SendConfirmationRequestAsync(PlatformConfirmationRequest request, CancellationToken ct) =>
|
||||
throw new NotSupportedException("This platform messenger does not support confirmation requests.");
|
||||
|
||||
Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct) =>
|
||||
throw new NotSupportedException("This platform messenger does not support confirmation request updates.");
|
||||
|
||||
Task<PlatformMessageRef> SendJoinLinkNotificationAsync(PlatformJoinLinkNotification notification, CancellationToken ct) =>
|
||||
throw new NotSupportedException("This platform messenger does not support join-link notifications.");
|
||||
|
||||
Task SendDirectSessionNotificationAsync(PlatformDirectSessionNotification notification, CancellationToken ct) =>
|
||||
throw new NotSupportedException("This platform messenger does not support direct session notifications.");
|
||||
|
||||
Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct) =>
|
||||
throw new NotSupportedException("This platform messenger does not support RSVP outcome notifications.");
|
||||
|
||||
Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct) =>
|
||||
throw new NotSupportedException("This platform messenger does not support reschedule vote updates.");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Rendering;
|
||||
|
||||
namespace GmRelay.Shared.Platform;
|
||||
@@ -34,3 +35,81 @@ public sealed record PlatformCalendarFile(
|
||||
byte[] Content,
|
||||
string CaptionHtml,
|
||||
IReadOnlyList<PlatformMessageAction> Actions);
|
||||
|
||||
public sealed record PlatformSessionParticipant(
|
||||
PlatformUser User,
|
||||
string RsvpStatus,
|
||||
string RegistrationStatus,
|
||||
bool IsGm = false);
|
||||
|
||||
public sealed record PlatformConfirmationRequest(
|
||||
PlatformGroup Group,
|
||||
Guid SessionId,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
IReadOnlyList<PlatformSessionParticipant> Participants,
|
||||
PlatformMessageRef? ExistingMessage = null);
|
||||
|
||||
public sealed record PlatformJoinLinkNotification(
|
||||
PlatformGroup Group,
|
||||
Guid SessionId,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string JoinLink,
|
||||
IReadOnlyList<PlatformSessionParticipant> ConfirmedPlayers,
|
||||
PlatformMessageRef? ExistingMessage = null);
|
||||
|
||||
public enum PlatformDirectSessionNotificationKind
|
||||
{
|
||||
ConfirmationRequest = 0,
|
||||
OneHourReminder = 1,
|
||||
JoinLink = 2,
|
||||
RsvpAllConfirmed = 3,
|
||||
RsvpDeclined = 4,
|
||||
RescheduleApproved = 5,
|
||||
RescheduleRejected = 6
|
||||
}
|
||||
|
||||
public sealed record PlatformDirectSessionNotification(
|
||||
PlatformDirectSessionNotificationKind Kind,
|
||||
PlatformUser Recipient,
|
||||
Guid SessionId,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string? JoinLink = null,
|
||||
string? ActorDisplayName = null,
|
||||
string? Reason = null);
|
||||
|
||||
public sealed record PlatformRsvpMessageUpdate(
|
||||
PlatformConfirmationRequest Request,
|
||||
bool DisableActions);
|
||||
|
||||
public enum PlatformRsvpOutcomeKind
|
||||
{
|
||||
GroupAllConfirmed = 0,
|
||||
GmAllConfirmed = 1,
|
||||
GmPlayerDeclined = 2
|
||||
}
|
||||
|
||||
public sealed record PlatformRsvpOutcomeNotification(
|
||||
PlatformRsvpOutcomeKind Kind,
|
||||
PlatformGroup? Group,
|
||||
IReadOnlyList<PlatformUser> Recipients,
|
||||
Guid SessionId,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string? ActorDisplayName = null);
|
||||
|
||||
public sealed record PlatformRescheduleVoteUpdate(
|
||||
PlatformGroup Group,
|
||||
PlatformMessageRef ExistingMessage,
|
||||
Guid ProposalId,
|
||||
Guid SessionId,
|
||||
string Title,
|
||||
DateTime CurrentScheduledAt,
|
||||
DateTimeOffset VotingDeadlineAt,
|
||||
RescheduleVoteDecision Decision,
|
||||
RescheduleOptionDto? SelectedOption,
|
||||
IReadOnlyList<RescheduleOptionDto> Options,
|
||||
IReadOnlyList<RescheduleOptionVoteDto> Votes,
|
||||
IReadOnlyList<VoteParticipantDto> Participants);
|
||||
|
||||
@@ -14,6 +14,19 @@
|
||||
"resolved": "1.0.48",
|
||||
"contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA=="
|
||||
},
|
||||
"Microsoft.Extensions.Hosting.Abstractions": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.5, )",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.5, )",
|
||||
@@ -38,10 +51,49 @@
|
||||
"resolved": "5.6.7",
|
||||
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA=="
|
||||
},
|
||||
"Microsoft.Extensions.Diagnostics.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Options": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Primitives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v2.6.0</div>
|
||||
<div class="nav-version">v2.7.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
Reference in New Issue
Block a user