156 lines
7.2 KiB
C#
156 lines
7.2 KiB
C#
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);
|