fix(discord): resolve user ID from DM interactions for approval clicks

Discord puts the clicking user at interaction.member.user for guild
interactions but interaction.user for DM interactions. The Gateway
handler only checked interaction.member, so DM button clicks resolved
to an empty user ID and were silently rejected as unauthorized.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
exe.dev user
2026-04-23 12:23:12 +00:00
parent dee7e0be32
commit 61ca43d193

View File

@@ -105,7 +105,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
let setupConfig: ChannelSetup; let setupConfig: ChannelSetup;
let gatewayAbort: AbortController | null = null; let gatewayAbort: AbortController | null = null;
async function messageToInbound(message: ChatMessage, isMention: boolean): Promise<InboundMessage> { async function messageToInbound(message: ChatMessage, isMention: boolean, isGroup?: boolean): Promise<InboundMessage> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const serialized = message.toJSON() as Record<string, any>; const serialized = message.toJSON() as Record<string, any>;
@@ -162,6 +162,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
content: serialized, content: serialized,
timestamp: message.metadata.dateSent.toISOString(), timestamp: message.metadata.dateSent.toISOString(),
isMention, isMention,
isGroup,
}; };
} }
@@ -195,13 +196,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
// wirings still fire on in-thread mentions. // wirings still fire on in-thread mentions.
chat.onSubscribedMessage(async (thread, message) => { chat.onSubscribedMessage(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id); const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true)); await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true, true));
}); });
// @mention in an unsubscribed thread — SDK-confirmed bot mention. // @mention in an unsubscribed thread — SDK-confirmed bot mention.
chat.onNewMention(async (thread, message) => { chat.onNewMention(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id); const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, true));
}); });
// DMs — by definition addressed to the bot. Thread id flows through // DMs — by definition addressed to the bot. Thread id flows through
@@ -216,7 +217,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown', sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown',
threadId: thread.id, threadId: thread.id,
}); });
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, false));
}); });
// Plain messages in unsubscribed threads. // Plain messages in unsubscribed threads.
@@ -231,7 +232,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
// flood gate. // flood gate.
chat.onNewMessage(/./, async (thread, message) => { chat.onNewMessage(/./, async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id); const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false)); await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false, true));
}); });
// Handle button clicks (ask_user_question) // Handle button clicks (ask_user_question)
@@ -501,7 +502,10 @@ async function handleForwardedEvent(
// type 3 = MessageComponent (button/select) // type 3 = MessageComponent (button/select)
if (interaction.type === 3) { if (interaction.type === 3) {
const customId = (interaction.data as Record<string, unknown>)?.custom_id as string; const customId = (interaction.data as Record<string, unknown>)?.custom_id as string;
const user = (interaction.member as Record<string, unknown>)?.user as Record<string, string> | undefined; // In guilds the clicker is at interaction.member.user; in DMs it's interaction.user directly.
const user =
((interaction.member as Record<string, unknown>)?.user as Record<string, string> | undefined) ??
(interaction.user as Record<string, string> | undefined);
const interactionId = interaction.id as string; const interactionId = interaction.id as string;
const interactionToken = interaction.token as string; const interactionToken = interaction.token as string;