f6d5281af8
PR Checks / test-and-build (pull_request) Successful in 8m46s
Context.Guild in NetCord resolves the Guild object from the gateway client cache (cache.Guilds.GetValueOrDefault(guildId)), not from the interaction JSON payload. After a bot restart, the guild may not yet be cached when the first slash command arrives, causing Context.Guild to be null even though the command is invoked inside a guild channel. This produced "This command can only be used in a guild." Changes: - DiscordListSessionsCommand: use Context.Interaction.GuildId instead of Context.Guild.Id - DiscordNewSessionCommand: use Context.Interaction.GuildId + REST GetGuildAsync/GetGuildUserAsync - DiscordRescheduleCommand: same as above - DiscordSessionInteractionModule: same fix for button interactions (CreateInput) - Add null guard in GetResolvedPermissions for safety - Bump version to 3.0.5 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
202 lines
7.5 KiB
C#
202 lines
7.5 KiB
C#
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;
|
|
|
|
namespace GmRelay.DiscordBot.Features.Sessions;
|
|
|
|
public sealed class DiscordSessionInteractionModule(
|
|
JoinSessionHandler joinSessionHandler,
|
|
LeaveSessionHandler leaveSessionHandler,
|
|
HandleRsvpHandler rsvpHandler,
|
|
DiscordRescheduleVoteHandler voteHandler,
|
|
DiscordInteractionReplyCache interactionReplies,
|
|
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
|
|
{
|
|
[ComponentInteraction("join_session")]
|
|
public async Task JoinAsync(string sessionId)
|
|
{
|
|
if (!Guid.TryParse(sessionId, out var parsedSessionId))
|
|
{
|
|
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
|
|
return;
|
|
}
|
|
|
|
var input = CreateInput(parsedSessionId);
|
|
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
|
try
|
|
{
|
|
await joinSessionHandler.HandleAsync(
|
|
DiscordSessionInteractionMapper.CreateJoinCommand(input),
|
|
CancellationToken.None);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
|
|
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
|
return;
|
|
}
|
|
|
|
await CompleteWithStoredReplyAsync(input.InteractionId);
|
|
}
|
|
|
|
[ComponentInteraction("leave_session")]
|
|
public async Task LeaveAsync(string sessionId)
|
|
{
|
|
if (!Guid.TryParse(sessionId, out var parsedSessionId))
|
|
{
|
|
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
|
|
return;
|
|
}
|
|
|
|
var input = CreateInput(parsedSessionId);
|
|
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
|
try
|
|
{
|
|
await leaveSessionHandler.HandleAsync(
|
|
DiscordSessionInteractionMapper.CreateLeaveCommand(input),
|
|
CancellationToken.None);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
|
|
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
|
return;
|
|
}
|
|
|
|
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)
|
|
{
|
|
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)
|
|
{
|
|
var guildId = Context.Interaction.GuildId?.ToString(CultureInfo.InvariantCulture)
|
|
?? throw new InvalidOperationException("Session buttons can only be used in a guild.");
|
|
var message = Context.Interaction.Message
|
|
?? throw new InvalidOperationException("Session button interaction must include a message.");
|
|
|
|
return new DiscordSessionInteractionInput(
|
|
SessionId: sessionId,
|
|
InteractionId: Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
GuildId: guildId,
|
|
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);
|
|
}
|
|
|
|
private async Task CompleteWithStoredReplyAsync(string interactionId)
|
|
{
|
|
var reply = interactionReplies.Take(interactionId);
|
|
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
|
|
}
|
|
|
|
private Task CompleteResponseAsync(string text) =>
|
|
ModifyResponseAsync(options => options.Content = text);
|
|
|
|
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
|
|
InteractionCallback.Message(
|
|
new InteractionMessageProperties()
|
|
.WithContent(text)
|
|
.WithFlags(MessageFlags.Ephemeral));
|
|
}
|