Merge pull request #87: feat(discord): implement reschedule voting via Discord interactions (issue #30)
Deploy Telegram Bot / build-and-push (push) Successful in 4m37s
Deploy Telegram Bot / scan-images (push) Successful in 1m25s
Deploy Telegram Bot / deploy (push) Successful in 14s

Database:
- Add source_platform and proposed_by_external_user_id to reschedule_proposals
- Make proposed_by nullable for Discord proposals

Shared:
- Extract platform-neutral RescheduleVoteRules, RescheduleVotingInput, RescheduleDtos
- Create RescheduleVotingFinalizer for cross-platform deadline handling

Telegram:
- Refactor RescheduleVotingDeadlineService to use RescheduleVotingFinalizer
- Tag Telegram proposals with source_platform = 'Telegram'

Discord:
- /reschedule slash command with time options and deadline
- DiscordRescheduleVoteHandler for button interactions
- DiscordRescheduleVotingRenderer for embeds and buttons
- DiscordRescheduleVotingDeadlineService for automatic finalization
- DiscordSessionInteractionModule routing for vote buttons

Version: 2.5.0 -> 2.6.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 13:12:26 +03:00
34 changed files with 2569 additions and 264 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 2.5.0 VERSION: 2.6.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>2.5.0</Version> <Version>2.6.0</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+23
View File
@@ -0,0 +1,23 @@
# Discord /newsession и /listsessions — Issue #28
## Что реализовано
- Slash-команда /newsession для создания игровых сессий прямо из Discord.
- Slash-команда /listsessions для просмотра предстоящих игр в сервере.
- DiscordPermissionChecker — проверка прав (owner / admin / manager).
- DiscordPlatformMessenger — реализация IPlatformMessenger для Discord (NetCord REST).
- Полная интеграция в DI (Program.cs).
## Архитектура
- Vertical slice: каждая команда — отдельный файл (Command + Handler).
- Platform-agnostic SQL: используются колонки platform, external_group_id, external_user_id.
- Рендеринг переиспользует существующий DiscordSessionBatchRenderer.
## TDD
- 212 тестов, все зелёные.
- Source-level тесты проверяют паттерны: Dapper, Npgsql, транзакции, CancellationToken, платформенную нейтральность.
## Версия
- Minor bump: 2.3.0 → 2.4.0
- Синхронизировано: Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor.
Closes #28
+23
View File
@@ -0,0 +1,23 @@
## 🛠 Patch 2.4.0 — Discord /newsession и /listsessions
Реализованы slash-команды Discord для создания сессий и просмотра расписания без Web Dashboard.
## 🧩 Что вошло в релиз
- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs — slash-команда /newsession с параметрами (title, time, seats, link)
- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs — handler создания batch + session в БД
- src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs — slash-команда /listsessions
- src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs — handler запроса активных сессий с embed-рендерингом
- src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs — проверка прав через Discord permissions bitflag (Administrator = 0x8)
- src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs — реализация IPlatformMessenger для Discord через NetCord REST
- src/GmRelay.DiscordBot/Program.cs — регистрация DI: handlers, permission checker, messenger
- ests/GmRelay.Bot.Tests/Discord/ — 20+ TDD-тестов на парсинг, права, структуру, DI, рендеринг
- Синхронизированы версии: Directory.Build.props, NavMenu.razor, compose.yaml, deploy.yml → 2.4.0
## 🗺 Что это даёт
- Мастера (GM) могут создавать сессии прямо из Discord, не заходя в Web.
- Участники сервера видят расписание через /listsessions.
- Единая PostgreSQL модель для Telegram и Discord — никакого дублирования данных.
## 📦 Версия и деплой
- версия обновлена до 2.4.0
- Docker-образы используют тег 2.4.0
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f crond -f
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.5.0 image: git.codeanddice.ru/toutsu/gmrelay-bot:2.6.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -67,7 +67,7 @@ services:
retries: 3 retries: 3
discord: discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.5.0 image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.6.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -79,7 +79,7 @@ services:
- gmrelay - gmrelay
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:2.5.0 image: git.codeanddice.ru/toutsu/gmrelay-web:2.6.0
restart: always restart: always
depends_on: depends_on:
db: db:
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
using Dapper; using Dapper;
using GmRelay.Bot.Features.Notifications; using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
@@ -17,12 +18,6 @@ internal sealed record AwaitingProposalDto(
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt, Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode); Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode);
internal sealed record VoteParticipantDto(
Guid PlayerId,
string DisplayName,
string? TelegramUsername,
long TelegramId = 0);
// ── Handler ────────────────────────────────────────────────────────── // ── Handler ──────────────────────────────────────────────────────────
/// <summary> /// <summary>
@@ -1,5 +1,6 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
@@ -13,13 +14,6 @@ public sealed record HandleRescheduleVoteCommand(
long ChatId, long ChatId,
int MessageId); int MessageId);
internal sealed record VoteProposalDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt);
public sealed class HandleRescheduleVoteHandler( public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
@@ -83,8 +83,8 @@ public sealed class InitiateRescheduleHandler(
// 3. Create proposal in AwaitingTime status // 3. Create proposal in AwaitingTime status
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
INSERT INTO reschedule_proposals (session_id, proposed_by, status) INSERT INTO reschedule_proposals (session_id, proposed_by, source_platform, status)
VALUES (@SessionId, @GmId, 'AwaitingTime') VALUES (@SessionId, @GmId, 'Telegram', 'AwaitingTime')
""", """,
new { command.SessionId, GmId = command.TelegramUserId }); new { command.SessionId, GmId = command.TelegramUserId });
@@ -1,7 +1,7 @@
using Dapper; using Dapper;
using GmRelay.Bot.Features.Notifications; using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
@@ -11,25 +11,18 @@ using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record DueRescheduleProposalDto( internal sealed record TelegramProposalFieldsDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
int? BatchMessageId,
int? VoteMessageId, int? VoteMessageId,
int? BatchMessageId,
long TelegramChatId, long TelegramChatId,
int? ThreadId, int? ThreadId);
string NotificationMode);
public sealed class RescheduleVotingDeadlineService( public sealed class RescheduleVotingDeadlineService(
NpgsqlDataSource dataSource, NpgsqlDataSource dataSource,
ITelegramBotClient bot, ITelegramBotClient bot,
IPlatformMessenger messenger, IPlatformMessenger messenger,
DirectSessionNotificationSender directSender, DirectSessionNotificationSender directSender,
ISystemClock clock, RescheduleVotingFinalizer finalizer,
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -53,18 +46,7 @@ public sealed class RescheduleVotingDeadlineService(
{ {
try try
{ {
await using var connection = await dataSource.OpenConnectionAsync(ct); var proposalIds = await finalizer.GetDueProposalIdsAsync("Telegram", ct);
var proposalIds = (await connection.QueryAsync<Guid>(
"""
SELECT id
FROM reschedule_proposals
WHERE status = 'Voting'
AND voting_deadline_at IS NOT NULL
AND voting_deadline_at <= @Now
ORDER BY voting_deadline_at
LIMIT 25
""",
new { Now = clock.UtcNow.UtcDateTime })).ToList();
foreach (var proposalId in proposalIds) foreach (var proposalId in proposalIds)
{ {
@@ -82,212 +64,105 @@ public sealed class RescheduleVotingDeadlineService(
private async Task FinalizeProposal(Guid proposalId, CancellationToken ct) private async Task FinalizeProposal(Guid proposalId, CancellationToken ct)
{ {
await using var connection = await dataSource.OpenConnectionAsync(ct); var result = await finalizer.FinalizeAsync(proposalId, ct);
await using var transaction = await connection.BeginTransactionAsync(ct); if (result is null)
return;
var proposal = await connection.QuerySingleOrDefaultAsync<DueRescheduleProposalDto>( if (result.SourcePlatform != "Telegram")
{
logger.LogInformation(
"Skipping Telegram message handling for proposal {ProposalId} with source platform {SourcePlatform}",
proposalId,
result.SourcePlatform);
return;
}
await using var connection = await dataSource.OpenConnectionAsync(ct);
var telegramFields = await connection.QuerySingleOrDefaultAsync<TelegramProposalFieldsDto>(
""" """
SELECT rp.id AS Id, SELECT rp.vote_message_id AS VoteMessageId,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
rp.vote_message_id AS VoteMessageId,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId,
s.batch_message_id AS BatchMessageId, s.batch_message_id AS BatchMessageId,
s.notification_mode AS NotificationMode, g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId, s.thread_id AS ThreadId
g.telegram_chat_id AS TelegramChatId
FROM reschedule_proposals rp FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id JOIN game_groups g ON g.id = s.group_id
WHERE rp.id = @ProposalId WHERE rp.id = @ProposalId
AND rp.status = 'Voting'
AND rp.voting_deadline_at IS NOT NULL
AND rp.voting_deadline_at <= @Now
FOR UPDATE
""", """,
new { ProposalId = proposalId, Now = clock.UtcNow.UtcDateTime }, new { ProposalId = proposalId });
transaction);
if (proposal is null) if (telegramFields is null)
{
logger.LogWarning("Could not find Telegram fields for proposal {ProposalId}", proposalId);
return; return;
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var voteCounts = options
.Select(option => new RescheduleOptionVoteCount(
option.OptionId,
votes.Count(vote => vote.OptionId == option.OptionId)))
.ToList();
var decision = RescheduleVoteRules.SelectWinner(voteCounts);
var selectedOption = decision.SelectedOptionId is { } selectedOptionId
? options.Single(x => x.OptionId == selectedOptionId)
: null;
if (selectedOption is not null)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
confirmation_sent_at = NULL,
link_message_id = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = selectedOption.ProposedAt, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = 'Pending',
responded_at = NULL
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction);
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET status = 'Approved',
selected_option_id = @SelectedOptionId,
proposed_at = @ProposedAt
WHERE id = @ProposalId
""",
new
{
ProposalId = proposal.Id,
SelectedOptionId = selectedOption.OptionId,
ProposedAt = selectedOption.ProposedAt
},
transaction);
}
else
{
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId",
new { ProposalId = proposal.Id },
transaction);
} }
var directRecipients = participants var directRecipients = result.Participants
.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)) .Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName))
.ToList(); .ToList();
await transaction.CommitAsync(ct); await TryUpdateVoteMessage(result, telegramFields, ct);
await TryUpdateVoteMessage(proposal, options, participants, votes, decision, selectedOption, ct); if (result.SelectedOption is not null)
if (selectedOption is not null)
{ {
await TryUpdateBatchMessage(proposal, ct); await TryUpdateBatchMessage(result, telegramFields, ct);
} }
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode); var mode = SessionNotificationModeExtensions.FromDatabaseValue(result.NotificationMode);
if (mode.ShouldSendDirectMessages()) if (mode.ShouldSendDirectMessages())
{ {
await SendDirectResult(proposal, directRecipients, decision, selectedOption, ct); await SendDirectResult(result, directRecipients, ct);
} }
logger.LogInformation( logger.LogInformation(
"Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}", "Updated Telegram messages for finalized reschedule proposal {ProposalId} for session {SessionId}",
proposal.Id, result.ProposalId,
proposal.SessionId, result.SessionId);
decision.Outcome);
} }
private async Task TryUpdateVoteMessage( private async Task TryUpdateVoteMessage(
DueRescheduleProposalDto proposal, RescheduleVotingFinalizerResult result,
IReadOnlyList<RescheduleOptionDto> options, TelegramProposalFieldsDto telegramFields,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyList<RescheduleOptionVoteDto> votes,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
CancellationToken ct) CancellationToken ct)
{ {
if (proposal.VoteMessageId is null) if (telegramFields.VoteMessageId is null)
return; return;
try try
{ {
var resultText = selectedOption is not null var resultText = result.SelectedOption is not null
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {selectedOption.DisplayOrder}: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК)." ? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {result.SelectedOption.DisplayOrder}: <b>{result.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(decision.Reason)}"; : $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(result.Decision.Reason)}";
var text = $""" var text = $"""
{HandleRescheduleTimeInputHandler.BuildVotingMessage( {HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title, result.Title,
proposal.CurrentScheduledAt, result.CurrentScheduledAt,
proposal.VotingDeadlineAt, result.VotingDeadlineAt,
options, result.Options,
participants, result.Participants,
votes)} result.Votes)}
{resultText} {resultText}
"""; """;
await bot.EditMessageText( await bot.EditMessageText(
chatId: proposal.TelegramChatId, chatId: telegramFields.TelegramChatId,
messageId: proposal.VoteMessageId.Value, messageId: telegramFields.VoteMessageId.Value,
text: text, text: text,
parseMode: ParseMode.Html, parseMode: ParseMode.Html,
cancellationToken: ct); cancellationToken: ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", proposal.Id); logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", result.ProposalId);
} }
} }
private async Task TryUpdateBatchMessage(DueRescheduleProposalDto proposal, CancellationToken ct) private async Task TryUpdateBatchMessage(
RescheduleVotingFinalizerResult result,
TelegramProposalFieldsDto telegramFields,
CancellationToken ct)
{ {
try try
{ {
@@ -295,7 +170,7 @@ public sealed class RescheduleVotingDeadlineService(
var batchSessions = (await connection.QueryAsync<SessionBatchDto>( var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at", "SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList(); new { result.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>( var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
""" """
@@ -309,60 +184,58 @@ public sealed class RescheduleVotingDeadlineService(
WHERE s.batch_id = @BatchId AND sp.is_gm = false WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
""", """,
new { proposal.BatchId })).ToList(); new { result.BatchId })).ToList();
if (proposal.BatchMessageId.HasValue) if (telegramFields.BatchMessageId.HasValue)
{ {
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants); var view = SessionBatchViewBuilder.Build(result.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync( await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage( new PlatformScheduleMessage(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId), TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
view, view,
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)), TelegramPlatformIds.Message(telegramFields.TelegramChatId, telegramFields.ThreadId, telegramFields.BatchMessageId.Value)),
ct); ct);
} }
else else
{ {
await messenger.SendGroupMessageAsync( await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId), TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
$"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}».", $"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(result.Title)}».",
ct); ct);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", proposal.Id); logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", result.ProposalId);
} }
} }
private async Task SendDirectResult( private async Task SendDirectResult(
DueRescheduleProposalDto proposal, RescheduleVotingFinalizerResult result,
IReadOnlyList<DirectNotificationRecipient> recipients, IReadOnlyList<DirectNotificationRecipient> recipients,
RescheduleVoteDecision decision,
RescheduleOptionDto? selectedOption,
CancellationToken ct) CancellationToken ct)
{ {
var htmlText = selectedOption is not null var htmlText = result.SelectedOption is not null
? $""" ? $"""
✅ <b>Сессия перенесена по итогам голосования</b> ✅ <b>Сессия перенесена по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b> 📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
📅 Новое время: <b>{selectedOption.ProposedAt.FormatMoscow()}</b> (МСК) 📅 Новое время: <b>{result.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)
""" """
: $""" : $"""
❌ <b>Перенос сессии отклонён по итогам голосования</b> ❌ <b>Перенос сессии отклонён по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b> 📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
📅 Время остаётся прежним: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК) 📅 Время остаётся прежним: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
Причина: {System.Net.WebUtility.HtmlEncode(decision.Reason)} Причина: {System.Net.WebUtility.HtmlEncode(result.Decision.Reason)}
"""; """;
await directSender.SendAsync( await directSender.SendAsync(
recipients, recipients,
htmlText, htmlText,
selectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected", result.SelectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
proposal.SessionId, result.SessionId,
ct); ct);
} }
} }
@@ -1,9 +1,6 @@
namespace GmRelay.Bot.Infrastructure.Scheduling; using GmRelay.Shared.Platform;
public interface ISystemClock namespace GmRelay.Bot.Infrastructure.Scheduling;
{
DateTimeOffset UtcNow { get; }
}
public sealed class SystemClock : ISystemClock public sealed class SystemClock : ISystemClock
{ {
@@ -1,6 +1,7 @@
using GmRelay.Bot.Features.Confirmation.SendConfirmation; using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using GmRelay.Bot.Features.Reminders.SendJoinLink; using GmRelay.Bot.Features.Reminders.SendJoinLink;
using GmRelay.Bot.Features.Reminders.SendOneHourReminder; using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Infrastructure.Scheduling; namespace GmRelay.Bot.Infrastructure.Scheduling;
@@ -0,0 +1,19 @@
-- =============================================================
-- V018: Add platform columns to reschedule_proposals
-- =============================================================
-- Add platform columns to reschedule_proposals to support Discord reschedule voting.
-- proposed_by is made nullable so Discord proposals can leave it NULL
-- (Discord snowflakes don't fit in BIGINT safely).
-- =============================================================
ALTER TABLE reschedule_proposals
ALTER COLUMN proposed_by DROP NOT NULL;
ALTER TABLE reschedule_proposals
ADD COLUMN source_platform VARCHAR(50),
ADD COLUMN proposed_by_external_user_id VARCHAR(255);
UPDATE reschedule_proposals
SET source_platform = 'Telegram',
proposed_by_external_user_id = proposed_by::TEXT
WHERE source_platform IS NULL;
+2
View File
@@ -6,6 +6,7 @@ using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Database; using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Health; using GmRelay.Bot.Infrastructure.Health;
using GmRelay.Bot.Infrastructure.Logging; using GmRelay.Bot.Infrastructure.Logging;
using GmRelay.Bot.Infrastructure.Scheduling; using GmRelay.Bot.Infrastructure.Scheduling;
@@ -75,6 +76,7 @@ builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.Expor
builder.Services.AddSingleton<InitiateRescheduleHandler>(); builder.Services.AddSingleton<InitiateRescheduleHandler>();
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>(); builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<HandleRescheduleVoteHandler>(); builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
// ── Telegram infrastructure ────────────────────────────────────────── // ── Telegram infrastructure ──────────────────────────────────────────
builder.Services.AddSingleton<UpdateRouter>(); builder.Services.AddSingleton<UpdateRouter>();
@@ -0,0 +1,117 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
[SlashCommand("reschedule", "Initiate reschedule voting for a session")]
public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandContext>
{
private readonly DiscordRescheduleHandler _handler;
private readonly ILogger<DiscordRescheduleCommand> _logger;
public DiscordRescheduleCommand(DiscordRescheduleHandler handler, ILogger<DiscordRescheduleCommand> logger)
{
_handler = handler;
_logger = logger;
}
public async Task ExecuteAsync(
[SlashCommandParameter(Name = "session", Description = "Session ID to reschedule")] string sessionIdText,
[SlashCommandParameter(Name = "option1", Description = "First time option (YYYY-MM-DD HH:mm)")] string option1,
[SlashCommandParameter(Name = "option2", Description = "Second time option (YYYY-MM-DD HH:mm)")] string option2,
[SlashCommandParameter(Name = "option3", Description = "Third time option (optional)")] string? option3 = null,
[SlashCommandParameter(Name = "deadline", Description = "Voting deadline (YYYY-MM-DD HH:mm)")] string deadline = "")
{
var guild = Context.Guild
?? throw new InvalidOperationException("This command can only be used in a guild.");
if (!Guid.TryParse(sessionIdText, out var sessionId))
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("❌ Некорректный ID сессии."));
return;
}
var options = new List<string> { option1, option2 };
if (!string.IsNullOrWhiteSpace(option3))
options.Add(option3);
var parsedOptions = new List<DateTimeOffset>();
foreach (var opt in options)
{
var result = DiscordNewSessionHandler.ParseTimeInput(opt);
if (!result.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"❌ {opt}: {result.Error}"));
return;
}
parsedOptions.Add(result.Value);
}
var deadlineResult = DiscordNewSessionHandler.ParseTimeInput(deadline);
if (!deadlineResult.IsSuccess)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($"❌ Дедлайн: {deadlineResult.Error}"));
return;
}
if (deadlineResult.Value >= parsedOptions.Min())
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message("❌ Дедлайн должен быть раньше первого варианта времени."));
return;
}
var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id);
try
{
var result = await _handler.HandleAsync(
guildId: guild.Id.ToString(),
channelId: Context.Channel.Id.ToString(),
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
resolvedPermissions: resolvedPermissions,
guildOwnerId: guild.OwnerId,
sessionId: sessionId,
options: parsedOptions,
deadline: deadlineResult.Value,
CancellationToken.None);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(
$"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC."));
}
catch (UnauthorizedAccessException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($":no_entry: {ex.Message}"));
}
catch (InvalidOperationException ex)
{
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message($":warning: {ex.Message}"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initiate reschedule for session {SessionId}", sessionId);
await Context.Interaction.SendResponseAsync(
InteractionCallback.Message(":boom: Ошибка при запуске голосования."));
}
}
private static ulong GetResolvedPermissions(NetCord.Gateway.Guild guild, ulong userId)
{
if (!guild.Users.TryGetValue(userId, out var guildUser))
return 0;
ulong resolved = 0;
foreach (var roleId in guildUser.RoleIds)
{
if (guild.Roles.TryGetValue(roleId, out var role))
resolved |= (ulong)role.Permissions;
}
return resolved;
}
}
@@ -0,0 +1,155 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using NetCord;
using NetCord.Rest;
using Npgsql;
public sealed record DiscordRescheduleResult(Guid ProposalId, IReadOnlyList<RescheduleOptionDto> Options, DateTimeOffset Deadline);
public sealed class DiscordRescheduleHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
RestClient restClient,
ILogger<DiscordRescheduleHandler> logger)
{
public async Task<DiscordRescheduleResult> HandleAsync(
string guildId,
string channelId,
ulong userId,
string userDisplayName,
ulong resolvedPermissions,
ulong guildOwnerId,
Guid sessionId,
IReadOnlyList<DateTimeOffset> options,
DateTimeOffset deadline,
CancellationToken ct)
{
// 1. Permission check + read-only validation (before Discord message)
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
var dbManagerUserIds = await readConnection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
{
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут переносить сессии.");
}
// 2. Ensure player exists
await readConnection.ExecuteAsync(
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, 'Discord', @UserId, @Name)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE SET display_name = EXCLUDED.display_name",
new { Name = userDisplayName, UserId = userId.ToString() });
// 3. Verify session exists
var session = await readConnection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
"""
SELECT s.title AS Title, s.scheduled_at AS CurrentScheduledAt
FROM sessions s
WHERE s.id = @SessionId AND s.status != @Cancelled
""",
new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled });
if (session is null)
throw new InvalidOperationException("Сессия не найдена или отменена.");
// 4. Check no active proposal
var hasActive = await readConnection.ExecuteScalarAsync<bool>(
"SELECT EXISTS (SELECT 1 FROM reschedule_proposals WHERE session_id = @SessionId AND status IN ('AwaitingTime', 'Voting'))",
new { SessionId = sessionId });
if (hasActive)
throw new InvalidOperationException("Уже есть активный запрос на перенос этой сессии.");
// 5. Load participants for rendering
var participants = (await readConnection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active
""",
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
// 6. Prepare proposal data
var proposalId = Guid.NewGuid();
var optionDtos = options.Select((o, i) => new RescheduleOptionDto(Guid.NewGuid(), i + 1, o)).ToList();
// 7. Build and send Discord vote message BEFORE transaction
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(session.Title, session.CurrentScheduledAt, deadline, optionDtos, participants, []);
var channelIdUlong = ulong.Parse(channelId);
// NOTE: Discord message is sent before DB transaction to avoid orphaned proposals
// if the send fails. There is a negligible race window where the message is visible
// before the DB commit; in practice users cannot click faster than the transaction commits.
var sentMessage = await restClient.SendMessageAsync(
channelIdUlong,
new MessageProperties()
.WithEmbeds(new[] { embed })
.WithComponents(new[] { actionRow }));
// 8. Create proposal + options + platform_messages in transaction
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_proposals (id, session_id, proposed_by, source_platform, proposed_by_external_user_id, status, voting_deadline_at)
VALUES (@Id, @SessionId, NULL, 'Discord', @ProposedBy, 'Voting', @Deadline)
""",
new { Id = proposalId, SessionId = sessionId, ProposedBy = userId.ToString(), Deadline = deadline.UtcDateTime },
transaction);
foreach (var option in optionDtos)
{
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
""",
new { option.OptionId, ProposalId = proposalId, option.ProposedAt, option.DisplayOrder },
transaction);
}
await connection.ExecuteAsync(
"""
INSERT INTO platform_messages (platform, group_id, session_id, external_channel_id, external_message_id, purpose)
VALUES ('Discord', (SELECT id FROM game_groups WHERE platform = 'Discord' AND external_group_id = @GuildId), @SessionId, @ChannelId, @MessageId, 'reschedule_vote')
""",
new { GuildId = guildId, SessionId = sessionId, ChannelId = channelId, MessageId = sentMessage.Id.ToString() },
transaction);
await transaction.CommitAsync(ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Transaction failed after Discord message sent; deleting orphaned message");
try { await restClient.DeleteMessageAsync(channelIdUlong, sentMessage.Id); } catch { /* best effort */ }
throw;
}
logger.LogInformation("Discord reschedule voting started for session {SessionId}, proposal {ProposalId}", sessionId, proposalId);
return new DiscordRescheduleResult(proposalId, optionDtos, deadline);
}
}
internal sealed record RescheduleSessionInfoDto(string Title, DateTime CurrentScheduledAt);
@@ -0,0 +1,131 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using Dapper;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Npgsql;
using NetCord.Rest;
public sealed record DiscordRescheduleVoteInput(
Guid OptionId, ulong UserId, string InteractionId,
string GuildId, string ChannelId, string MessageId);
public sealed class DiscordRescheduleVoteHandler(
NpgsqlDataSource dataSource,
RestClient restClient,
ILogger<DiscordRescheduleVoteHandler> logger)
{
public async Task<string> HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Load proposal + option
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title, s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { input.OptionId },
transaction);
if (proposal is null)
return "Голосование уже завершено или не найдено.";
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
return "Дедлайн уже прошёл. Результаты скоро будут применены.";
// 2. Verify participant (Discord platform)
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.platform = 'Discord'
AND p.external_user_id = @UserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, UserId = input.UserId.ToString(), Active = ParticipantRegistrationStatus.Active },
transaction);
if (playerId is null)
return "Вы не являетесь участником этой сессии.";
// 3. Upsert vote
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id, voted_at = now()
""",
new { ProposalId = proposal.Id, PlayerId = playerId.Value, input.OptionId },
transaction);
// 4. Reload participants, options, votes for re-rendering
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId, display_order AS DisplayOrder, proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
// 5. Re-render and update Discord vote message
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt,
options, participants, votes);
var channelIdUlong = ulong.Parse(input.ChannelId);
var messageIdUlong = ulong.Parse(input.MessageId);
try
{
await restClient.ModifyMessageAsync(channelIdUlong, messageIdUlong, options =>
{
options.Embeds = new[] { embed };
options.Components = new[] { actionRow };
});
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id);
}
return "Ваш голос учтён. До дедлайна его можно изменить.";
}
}
@@ -0,0 +1,183 @@
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,
ILogger<DiscordRescheduleVotingDeadlineService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await ProcessDueProposals(stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await ProcessDueProposals(stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
}
private async Task ProcessDueProposals(CancellationToken ct)
{
try
{
var proposalIds = await finalizer.GetDueProposalIdsAsync("Discord", ct);
foreach (var id in proposalIds)
{
await TryFinalizeAsync(id, ct);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process Discord reschedule proposals");
}
}
private async Task TryFinalizeAsync(Guid proposalId, CancellationToken ct)
{
try
{
var result = await finalizer.FinalizeAsync(proposalId, ct);
if (result is null)
return;
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);
}
logger.LogInformation(
"Finalized Discord reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
proposalId, result.SessionId, result.Decision.Outcome);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to finalize Discord proposal {ProposalId}", proposalId);
}
}
private async Task TryUpdateDiscordVoteMessage(RescheduleVotingFinalizerResult result, CancellationToken ct)
{
try
{
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
LIMIT 1
""",
new { result.SessionId });
if (msgRef is null)
return;
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
result.Title, result.CurrentScheduledAt, result.VotingDeadlineAt,
result.Options, result.Participants, result.Votes);
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 };
});
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update Discord vote message for session {SessionId}", result.SessionId);
}
}
private async Task TryUpdateBatchScheduleAsync(RescheduleVotingFinalizerResult result, CancellationToken ct)
{
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
LIMIT 1
""",
new { result.BatchId });
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
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC
""",
new { result.BatchId })).ToList();
var view = SessionBatchViewBuilder.Build(result.Title, sessions, participants);
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
var channelId = ulong.Parse(batchRef.ExternalChannelId);
var messageId = ulong.Parse(batchRef.ExternalMessageId);
await restClient.ModifyMessageAsync(channelId, messageId, options =>
{
options.Embeds = embeds;
options.Components = actionRows;
});
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update Discord batch schedule for session {SessionId}", result.SessionId);
}
}
internal sealed record PlatformMessageRefDto(string ExternalChannelId, string ExternalMessageId);
}
@@ -9,6 +9,7 @@ namespace GmRelay.DiscordBot.Features.Sessions;
public sealed class DiscordSessionInteractionModule( public sealed class DiscordSessionInteractionModule(
JoinSessionHandler joinSessionHandler, JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler, LeaveSessionHandler leaveSessionHandler,
DiscordRescheduleVoteHandler voteHandler,
DiscordInteractionReplyCache interactionReplies, DiscordInteractionReplyCache interactionReplies,
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext> ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
{ {
@@ -66,6 +67,41 @@ public sealed class DiscordSessionInteractionModule(
await CompleteWithStoredReplyAsync(input.InteractionId); await CompleteWithStoredReplyAsync(input.InteractionId);
} }
[ComponentInteraction("reschedule_vote")]
public async Task RescheduleVoteAsync(string optionId)
{
if (!Guid.TryParse(optionId, out var parsedOptionId))
{
await RespondAsync(CreateEphemeralReply("Vote button is outdated."));
return;
}
var input = CreateInput(Guid.Empty); // sessionId not needed for vote routing
var voteInput = new DiscordRescheduleVoteInput(
parsedOptionId,
Context.User.Id,
Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
input.GuildId,
input.ChannelId,
input.MessageId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
string replyText;
try
{
replyText = await voteHandler.HandleAsync(voteInput, CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord reschedule vote for option {OptionId}", parsedOptionId);
await CompleteResponseAsync("Не удалось обработать голос.");
return;
}
await CompleteResponseAsync(replyText);
}
private DiscordSessionInteractionInput CreateInput(Guid sessionId) private DiscordSessionInteractionInput CreateInput(Guid sessionId)
{ {
var guild = Context.Guild var guild = Context.Guild
@@ -51,9 +51,15 @@ public sealed class DiscordPlatformMessenger(
}); });
} }
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct) public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
{ {
return Task.CompletedTask; 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);
} }
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
@@ -0,0 +1,6 @@
namespace GmRelay.DiscordBot.Infrastructure;
public sealed class SystemClock : GmRelay.Shared.Platform.ISystemClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
+7
View File
@@ -1,8 +1,10 @@
using GmRelay.DiscordBot; using GmRelay.DiscordBot;
using GmRelay.DiscordBot.Features.Sessions; using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Infrastructure;
using GmRelay.DiscordBot.Infrastructure.Discord; using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Infrastructure.Logging; using GmRelay.DiscordBot.Infrastructure.Logging;
using GmRelay.Shared.Features.Sessions.CreateSession; using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -44,11 +46,16 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
builder.Services.AddSingleton<DiscordPermissionChecker>(); builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>(); builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>(); builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<DiscordRescheduleHandler>();
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>(); builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>(); builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>(); builder.Services.AddSingleton<LeaveSessionHandler>();
builder.Services.AddSingleton<DiscordInteractionReplyCache>(); builder.Services.AddSingleton<DiscordInteractionReplyCache>();
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>(); builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
builder.Services.AddSingleton<ISystemClock, SystemClock>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
builder.Services builder.Services
.AddDiscordGateway(options => .AddDiscordGateway(options =>
@@ -0,0 +1,67 @@
namespace GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using NetCord;
using NetCord.Rest;
public static class DiscordRescheduleVotingRenderer
{
public static (EmbedProperties Embed, ActionRowProperties ActionRow) Render(
string title,
DateTime currentTime,
DateTimeOffset deadline,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyList<RescheduleOptionVoteDto> votes)
{
var votesByOption = votes.GroupBy(v => v.OptionId).ToDictionary(g => g.Key, g => g.ToList());
var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet();
var pending = participants.Where(p => !votedPlayerIds.Contains(p.PlayerId)).Select(p => p.DisplayName).ToList();
var sb = new System.Text.StringBuilder();
sb.AppendLine($"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)");
sb.AppendLine($"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)");
sb.AppendLine();
sb.AppendLine("Выберите один из вариантов:");
foreach (var option in options.OrderBy(o => o.DisplayOrder))
{
var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []);
sb.AppendLine($"{option.DisplayOrder}. **{option.ProposedAt.FormatMoscow()}** (МСК) — {optionVotes.Count} голосов");
if (optionVotes.Count > 0)
{
sb.AppendLine($" {string.Join(", ", optionVotes.Select(v => v.DisplayName))}");
}
}
if (pending.Count > 0)
{
sb.AppendLine();
sb.AppendLine($"Не проголосовали: {string.Join(", ", pending)}");
}
sb.AppendLine();
sb.AppendLine($"Голосов: {votedPlayerIds.Count}/{participants.Count}");
sb.AppendLine("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется.");
var embed = new EmbedProperties()
.WithTitle($"🔄 Перенос сессии «{title}»")
.WithDescription(sb.ToString())
.WithColor(new Color(0xFEE75C));
var actionRow = new ActionRowProperties();
foreach (var option in options.OrderBy(o => o.DisplayOrder))
{
actionRow.Add(new ButtonProperties(
$"reschedule_vote:{option.OptionId}",
$"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}",
ButtonStyle.Primary));
}
return (embed, actionRow);
}
private static string FormatButtonTime(DateTimeOffset utc)
=> utc.ToOffset(TimeSpan.FromHours(3)).ToString("dd.MM HH:mm", System.Globalization.CultureInfo.InvariantCulture);
}
@@ -0,0 +1,29 @@
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record RescheduleOptionDto(
Guid OptionId,
int DisplayOrder,
DateTimeOffset ProposedAt);
public sealed record VoteProposalDto(
Guid Id,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string Title,
DateTime CurrentScheduledAt);
public sealed record RescheduleOptionVoteDto(
Guid OptionId,
Guid PlayerId,
string DisplayName,
string? TelegramUsername);
public sealed record RescheduleOptionVoteCount(
Guid OptionId,
int VoteCount);
public sealed record VoteParticipantDto(
Guid PlayerId,
string DisplayName,
string? TelegramUsername,
long TelegramId = 0);
@@ -1,13 +1,13 @@
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
internal enum RescheduleVoteOutcome public enum RescheduleVoteOutcome
{ {
Pending, Pending,
Rejected, Rejected,
Approved Approved
} }
internal sealed record RescheduleVoteDecision( public sealed record RescheduleVoteDecision(
RescheduleVoteOutcome Outcome, RescheduleVoteOutcome Outcome,
string Reason, string Reason,
Guid? SelectedOptionId = null, Guid? SelectedOptionId = null,
@@ -15,7 +15,7 @@ internal sealed record RescheduleVoteDecision(
bool ShouldRescheduleSession = false, bool ShouldRescheduleSession = false,
bool ShouldResetParticipantRsvps = false); bool ShouldResetParticipantRsvps = false);
internal static class RescheduleVoteRules public static class RescheduleVoteRules
{ {
public static RescheduleVoteDecision SelectWinner(IReadOnlyList<RescheduleOptionVoteCount> voteCounts) public static RescheduleVoteDecision SelectWinner(IReadOnlyList<RescheduleOptionVoteCount> voteCounts)
{ {
@@ -49,8 +49,8 @@ internal static class RescheduleVoteRules
{ {
return new RescheduleVoteDecision( return new RescheduleVoteDecision(
Outcome: RescheduleVoteOutcome.Rejected, Outcome: RescheduleVoteOutcome.Rejected,
Reason: "\u041e\u0434\u0438\u043d \u0438\u0437 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u043e\u0432 \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b \u043f\u0435\u0440\u0435\u043d\u043e\u0441.", Reason: "Один из участников отклонил перенос.",
CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430."); CallbackText: "Вы проголосовали против переноса.");
} }
var everyoneApproved = approvedParticipants == totalParticipants; var everyoneApproved = approvedParticipants == totalParticipants;
@@ -58,11 +58,11 @@ internal static class RescheduleVoteRules
return new RescheduleVoteDecision( return new RescheduleVoteDecision(
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending, Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending,
Reason: everyoneApproved Reason: everyoneApproved
? "\u0412\u0441\u0435 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u0438 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b." ? "Все участники согласны."
: "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0430\u0435\u0442\u0441\u044f.", : "Голосование продолжается.",
CallbackText: everyoneApproved CallbackText: everyoneApproved
? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e." ? "Вы подтвердили перенос! Все согласны — время обновлено."
: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!", : "Вы подтвердили перенос!",
ShouldRescheduleSession: everyoneApproved, ShouldRescheduleSession: everyoneApproved,
ShouldResetParticipantRsvps: everyoneApproved); ShouldResetParticipantRsvps: everyoneApproved);
} }
@@ -0,0 +1,215 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record RescheduleVotingFinalizerResult(
Guid ProposalId,
Guid SessionId,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
string NotificationMode,
string SourcePlatform,
DateTimeOffset VotingDeadlineAt,
RescheduleVoteDecision Decision,
RescheduleOptionDto? SelectedOption,
IReadOnlyList<RescheduleOptionDto> Options,
IReadOnlyList<RescheduleOptionVoteDto> Votes,
IReadOnlyList<VoteParticipantDto> Participants);
public sealed class RescheduleVotingFinalizer(
NpgsqlDataSource dataSource,
ISystemClock clock,
ILogger<RescheduleVotingFinalizer> logger)
{
public async Task<IReadOnlyList<Guid>> GetDueProposalIdsAsync(string sourcePlatform, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var proposalIds = (await connection.QueryAsync<Guid>(
"""
SELECT id
FROM reschedule_proposals
WHERE status = 'Voting'
AND voting_deadline_at IS NOT NULL
AND voting_deadline_at <= @Now
AND source_platform = @SourcePlatform
ORDER BY voting_deadline_at
LIMIT 25
""",
new { Now = clock.UtcNow.UtcDateTime, SourcePlatform = sourcePlatform })).ToList();
return proposalIds;
}
public async Task<RescheduleVotingFinalizerResult?> FinalizeAsync(Guid proposalId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var proposal = await connection.QuerySingleOrDefaultAsync<ProposalRow>(
"""
SELECT rp.id AS ProposalId,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
rp.source_platform AS SourcePlatform,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId,
s.notification_mode AS NotificationMode
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
WHERE rp.id = @ProposalId
AND rp.status = 'Voting'
AND rp.voting_deadline_at IS NOT NULL
AND rp.voting_deadline_at <= @Now
FOR UPDATE
""",
new { ProposalId = proposalId, Now = clock.UtcNow.UtcDateTime },
transaction);
if (proposal is null)
return null;
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.ProposalId },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.ProposalId },
transaction)).ToList();
var voteCounts = options
.Select(option => new RescheduleOptionVoteCount(
option.OptionId,
votes.Count(vote => vote.OptionId == option.OptionId)))
.ToList();
var decision = RescheduleVoteRules.SelectWinner(voteCounts);
var selectedOption = decision.SelectedOptionId is { } selectedOptionId
? options.Single(x => x.OptionId == selectedOptionId)
: null;
if (selectedOption is not null)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
confirmation_sent_at = NULL,
link_message_id = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = selectedOption.ProposedAt, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
await connection.ExecuteAsync(
"""
UPDATE session_participants
SET rsvp_status = 'Pending',
responded_at = NULL
WHERE session_id = @SessionId
AND is_gm = false
AND registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction);
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET status = 'Approved',
selected_option_id = @SelectedOptionId,
proposed_at = @ProposedAt
WHERE id = @ProposalId
""",
new
{
ProposalId = proposal.ProposalId,
SelectedOptionId = selectedOption.OptionId,
ProposedAt = selectedOption.ProposedAt
},
transaction);
}
else
{
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @ProposalId",
new { ProposalId = proposal.ProposalId },
transaction);
}
await transaction.CommitAsync(ct);
logger.LogInformation(
"Finalized reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
proposal.ProposalId,
proposal.SessionId,
decision.Outcome);
return new RescheduleVotingFinalizerResult(
proposal.ProposalId,
proposal.SessionId,
proposal.Title,
proposal.CurrentScheduledAt,
proposal.BatchId,
proposal.NotificationMode,
proposal.SourcePlatform,
proposal.VotingDeadlineAt,
decision,
selectedOption,
options,
votes,
participants);
}
private sealed record ProposalRow(
Guid ProposalId,
Guid SessionId,
DateTimeOffset VotingDeadlineAt,
string SourcePlatform,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
string NotificationMode);
}
@@ -1,9 +1,9 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession; namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
internal sealed record RescheduleVotingInput( public sealed record RescheduleVotingInput(
IReadOnlyList<DateTimeOffset> Options, IReadOnlyList<DateTimeOffset> Options,
DateTimeOffset Deadline) DateTimeOffset Deadline)
{ {
@@ -93,18 +93,3 @@ internal sealed record RescheduleVotingInput(
|| normalized.StartsWith("до:", StringComparison.Ordinal); || normalized.StartsWith("до:", StringComparison.Ordinal);
} }
} }
internal sealed record RescheduleOptionDto(
Guid OptionId,
int DisplayOrder,
DateTimeOffset ProposedAt);
internal sealed record RescheduleOptionVoteDto(
Guid OptionId,
Guid PlayerId,
string DisplayName,
string? TelegramUsername);
internal sealed record RescheduleOptionVoteCount(
Guid OptionId,
int VoteCount);
@@ -0,0 +1,6 @@
namespace GmRelay.Shared.Platform;
public interface ISystemClock
{
DateTimeOffset UtcNow { get; }
}
@@ -56,7 +56,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v2.5.0</div> <div class="nav-version">v2.6.0</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@@ -61,7 +61,7 @@ public sealed class DiscordProjectStructureTests
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml")); var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")); var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
Assert.Contains("gmrelay-discord-bot:2.5.0", compose); Assert.Contains("gmrelay-discord-bot:2.6.0", compose);
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose); Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy); Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
Assert.Contains("DISCORD_BOT_TOKEN", deploy); Assert.Contains("DISCORD_BOT_TOKEN", deploy);
@@ -75,13 +75,13 @@ public sealed class DiscordProjectStructureTests
{ {
var repoRoot = GetRepoRoot(); var repoRoot = GetRepoRoot();
Assert.Contains("<Version>2.5.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); Assert.Contains("<Version>2.6.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 2.5.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); Assert.Contains("VERSION: 2.6.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:2.5.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains("gmrelay-bot:2.6.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:2.5.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains("gmrelay-web:2.6.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:2.5.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains("gmrelay-discord-bot:2.6.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains( Assert.Contains(
"v2.5.0", "v2.6.0",
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor"))); File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
} }
} }
@@ -1,6 +1,7 @@
using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups; using Telegram.Bot.Types.ReplyMarkups;
using GmRelay.Bot.Infrastructure.Telegram; using GmRelay.Bot.Infrastructure.Telegram;
@@ -1,4 +1,5 @@
using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
@@ -1,4 +1,4 @@
using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Shared.Features.Sessions.RescheduleSession;
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession; namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
@@ -54,7 +54,7 @@ public sealed class TelegramTopicIntegrationSmokeTests
Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); Assert.Contains("int? ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal); Assert.Contains("s.thread_id AS ThreadId", rescheduleDeadlineService, StringComparison.Ordinal);
Assert.Contains("TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId)", rescheduleDeadlineService, StringComparison.Ordinal); Assert.Contains("TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId)", rescheduleDeadlineService, StringComparison.Ordinal);
} }
private static async Task<string> ReadRepositoryFileAsync(string relativePath) private static async Task<string> ReadRepositoryFileAsync(string relativePath)