refactor(discord): dedupe component context + reaction timing

This commit is contained in:
Peter Steinberger
2026-02-15 06:27:16 +00:00
parent 6491182a79
commit 2bd672f3ab
2 changed files with 147 additions and 109 deletions

View File

@@ -71,6 +71,70 @@ function resolveDiscordChannelContext(
return { channelName, channelSlug, channelType, isThread, parentId, parentName, parentSlug }; return { channelName, channelSlug, channelType, isThread, parentId, parentName, parentSlug };
} }
async function resolveComponentInteractionContext(params: {
interaction: AgentComponentInteraction;
label: string;
}): Promise<{
channelId: string;
user: DiscordUser;
username: string;
userId: string;
replyOpts: { ephemeral?: boolean };
rawGuildId: string | undefined;
isDirectMessage: boolean;
memberRoleIds: string[];
} | null> {
const { interaction, label } = params;
// Use interaction's actual channel_id (trusted source from Discord)
// This prevents channel spoofing attacks
const channelId = interaction.rawData.channel_id;
if (!channelId) {
logError(`${label}: missing channel_id in interaction`);
return null;
}
const user = interaction.user;
if (!user) {
logError(`${label}: missing user in interaction`);
return null;
}
let didDefer = false;
// Defer immediately to satisfy Discord's 3-second interaction ACK requirement.
// We use an ephemeral deferred reply so subsequent interaction.reply() calls
// can safely edit the original deferred response.
try {
await interaction.defer({ ephemeral: true });
didDefer = true;
} catch (err) {
logError(`${label}: failed to defer interaction: ${String(err)}`);
}
const replyOpts = didDefer ? {} : { ephemeral: true };
const username = formatUsername(user);
const userId = user.id;
// P1 FIX: Use rawData.guild_id as source of truth - interaction.guild can be null
// when guild is not cached even though guild_id is present in rawData
const rawGuildId = interaction.rawData.guild_id;
const isDirectMessage = !rawGuildId;
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
return {
channelId,
user,
username,
userId,
replyOpts,
rawGuildId,
isDirectMessage,
memberRoleIds,
};
}
async function ensureGuildComponentMemberAllowed(params: { async function ensureGuildComponentMemberAllowed(params: {
interaction: AgentComponentInteraction; interaction: AgentComponentInteraction;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>; guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
@@ -314,43 +378,23 @@ export class AgentComponentButton extends Button {
const { componentId } = parsed; const { componentId } = parsed;
// P1 FIX: Use interaction's actual channel_id instead of trusting customId const interactionCtx = await resolveComponentInteractionContext({
// This prevents channel ID spoofing attacks where an attacker crafts a button interaction,
// with a different channelId to inject events into other sessions label: "agent button",
const channelId = interaction.rawData.channel_id; });
if (!channelId) { if (!interactionCtx) {
logError("agent button: missing channel_id in interaction");
return; return;
} }
const {
const user = interaction.user; channelId,
if (!user) { user,
logError("agent button: missing user in interaction"); username,
return; userId,
} replyOpts,
rawGuildId,
let didDefer = false; isDirectMessage,
// Defer immediately to satisfy Discord's 3-second interaction ACK requirement. memberRoleIds,
// We use an ephemeral deferred reply so subsequent interaction.reply() calls } = interactionCtx;
// can safely edit the original deferred response.
try {
await interaction.defer({ ephemeral: true });
didDefer = true;
} catch (err) {
logError(`agent button: failed to defer interaction: ${String(err)}`);
}
const replyOpts = didDefer ? {} : { ephemeral: true };
const username = formatUsername(user);
const userId = user.id;
// P1 FIX: Use rawData.guild_id as source of truth - interaction.guild can be null
// when guild is not cached even though guild_id is present in rawData
const rawGuildId = interaction.rawData.guild_id;
const isDirectMessage = !rawGuildId;
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
if (isDirectMessage) { if (isDirectMessage) {
const authorized = await ensureDmComponentAuthorized({ const authorized = await ensureDmComponentAuthorized({
@@ -452,42 +496,23 @@ export class AgentSelectMenu extends StringSelectMenu {
const { componentId } = parsed; const { componentId } = parsed;
// Use interaction's actual channel_id (trusted source from Discord) const interactionCtx = await resolveComponentInteractionContext({
// This prevents channel spoofing attacks interaction,
const channelId = interaction.rawData.channel_id; label: "agent select",
if (!channelId) { });
logError("agent select: missing channel_id in interaction"); if (!interactionCtx) {
return; return;
} }
const {
const user = interaction.user; channelId,
if (!user) { user,
logError("agent select: missing user in interaction"); username,
return; userId,
} replyOpts,
rawGuildId,
let didDefer = false; isDirectMessage,
// Defer immediately to satisfy Discord's 3-second interaction ACK requirement. memberRoleIds,
// We use an ephemeral deferred reply so subsequent interaction.reply() calls } = interactionCtx;
// can safely edit the original deferred response.
try {
await interaction.defer({ ephemeral: true });
didDefer = true;
} catch (err) {
logError(`agent select: failed to defer interaction: ${String(err)}`);
}
const replyOpts = didDefer ? {} : { ephemeral: true };
const username = formatUsername(user);
const userId = user.id;
// P1 FIX: Use rawData.guild_id as source of truth - interaction.guild can be null
// when guild is not cached even though guild_id is present in rawData
const rawGuildId = interaction.rawData.guild_id;
const isDirectMessage = !rawGuildId;
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
if (isDirectMessage) { if (isDirectMessage) {
const authorized = await ensureDmComponentAuthorized({ const authorized = await ensureDmComponentAuthorized({

View File

@@ -108,26 +108,14 @@ export class DiscordReactionListener extends MessageReactionAddListener {
} }
async handle(data: DiscordReactionEvent, client: Client) { async handle(data: DiscordReactionEvent, client: Client) {
const startedAt = Date.now(); await runDiscordReactionHandler({
try { data,
await handleDiscordReactionEvent({ client,
data, action: "added",
client, handlerParams: this.params,
action: "added", listener: this.constructor.name,
cfg: this.params.cfg, event: this.type,
accountId: this.params.accountId, });
botUserId: this.params.botUserId,
guildEntries: this.params.guildEntries,
logger: this.params.logger,
});
} finally {
logSlowDiscordListener({
logger: this.params.logger,
listener: this.constructor.name,
event: this.type,
durationMs: Date.now() - startedAt,
});
}
} }
} }
@@ -146,26 +134,51 @@ export class DiscordReactionRemoveListener extends MessageReactionRemoveListener
} }
async handle(data: DiscordReactionEvent, client: Client) { async handle(data: DiscordReactionEvent, client: Client) {
const startedAt = Date.now(); await runDiscordReactionHandler({
try { data,
await handleDiscordReactionEvent({ client,
data, action: "removed",
client, handlerParams: this.params,
action: "removed", listener: this.constructor.name,
cfg: this.params.cfg, event: this.type,
accountId: this.params.accountId, });
botUserId: this.params.botUserId, }
guildEntries: this.params.guildEntries, }
logger: this.params.logger,
}); async function runDiscordReactionHandler(params: {
} finally { data: DiscordReactionEvent;
logSlowDiscordListener({ client: Client;
logger: this.params.logger, action: "added" | "removed";
listener: this.constructor.name, handlerParams: {
event: this.type, cfg: LoadedConfig;
durationMs: Date.now() - startedAt, accountId: string;
}); runtime: RuntimeEnv;
} botUserId?: string;
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
logger: Logger;
};
listener: string;
event: string;
}): Promise<void> {
const startedAt = Date.now();
try {
await handleDiscordReactionEvent({
data: params.data,
client: params.client,
action: params.action,
cfg: params.handlerParams.cfg,
accountId: params.handlerParams.accountId,
botUserId: params.handlerParams.botUserId,
guildEntries: params.handlerParams.guildEntries,
logger: params.handlerParams.logger,
});
} finally {
logSlowDiscordListener({
logger: params.handlerParams.logger,
listener: params.listener,
event: params.event,
durationMs: Date.now() - startedAt,
});
} }
} }