From 8007efcdf6b6563cba710d4ebbd8ed8f6016a7be Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Fri, 4 Sep 2026 21:59:48 -0600 Subject: [PATCH] Let admins write multiline /reply emails and close the thread on approval (#92) * Let /reply collect multiline review emails through a Discord modal. Co-authored-by: Cursor * Archive the review forum post when a hack is approved. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- src/app/api/discord/interactions/route.ts | 289 ++++++++++++++++------ src/utils/discord-commands.mjs | 9 - src/utils/discord-rest.ts | 5 +- 3 files changed, 219 insertions(+), 84 deletions(-) diff --git a/src/app/api/discord/interactions/route.ts b/src/app/api/discord/interactions/route.ts index e2a5d27..2687a92 100644 --- a/src/app/api/discord/interactions/route.ts +++ b/src/app/api/discord/interactions/route.ts @@ -15,6 +15,21 @@ import { } from "@/utils/hack-review"; import { createServiceClient } from "@/utils/supabase/server"; +const REPLY_MODAL_ID = "hackdex_reply"; +const REPLY_MESSAGE_ID = "message"; +const REPLY_MESSAGE_MAX_LENGTH = 1800; +const DISCORD_MODAL_TITLE_MAX = 45; +const DISCORD_MODAL_LABEL_MAX = 45; +const DISCORD_MODAL_DESCRIPTION_MAX = 100; + +type DiscordModalComponent = { + type?: number; + custom_id?: string; + value?: string; + component?: DiscordModalComponent; + components?: DiscordModalComponent[]; +}; + type DiscordInteraction = { application_id: string; token: string; @@ -23,7 +38,9 @@ type DiscordInteraction = { data?: { name?: string; type?: number; + custom_id?: string; options?: Array<{ name: string; value?: string }>; + components?: DiscordModalComponent[]; }; member?: { nick?: string | null; @@ -47,6 +64,127 @@ function ephemeral(content: string): Response { }); } +function discordText(value: string, max: number): string { + const trimmed = value.trim(); + if (trimmed.length <= max) return trimmed; + return `${trimmed.slice(0, Math.max(0, max - 1))}…`; +} + +function adminDisplayName(interaction: DiscordInteraction): string { + return interaction.member?.nick + || interaction.member?.user?.global_name + || interaction.member?.user?.username + || "Hackdex admin"; +} + +function replyModal(context?: { + title: string; + author: string; + adminName: string; +}): Response { + return Response.json({ + type: InteractionResponseType.MODAL, + data: { + custom_id: REPLY_MODAL_ID, + title: context + ? discordText(context.title, DISCORD_MODAL_TITLE_MAX) + : "Email the submitter", + components: [ + { + type: 18, + label: context + ? discordText(`Message to ${context.author}`, DISCORD_MODAL_LABEL_MAX) + : "Message", + description: context + ? discordText(`This will be sent as ${context.adminName}.`, DISCORD_MODAL_DESCRIPTION_MAX) + : "This is emailed to the hack creator.", + component: { + type: 4, + custom_id: REPLY_MESSAGE_ID, + style: 2, + required: true, + max_length: REPLY_MESSAGE_MAX_LENGTH, + placeholder: "Write the review reply…", + }, + }, + ], + }, + }); +} + +async function loadReplyModalContext(channelId: string): Promise<{ + title: string; + author: string; +} | null> { + const serviceClient = await createServiceClient(); + const { data: row, error } = await serviceClient + .from("hack_review_threads") + .select("hacks!inner(title, created_by)") + .eq("discord_thread_id", channelId) + .maybeSingle(); + if (error) throw error; + if (!row) return null; + + const { data: profile, error: profileError } = await serviceClient + .from("profiles") + .select("username") + .eq("id", row.hacks.created_by) + .maybeSingle(); + if (profileError) { + console.warn( + "[HackReview] Failed to load the hack creator profile:", + profileError, + ); + } + + return { + title: row.hacks.title, + author: profile?.username ?? "the hack creator", + }; +} + +function configuredReplyRoleIds(): string[] { + return (process.env.DISCORD_REPLY_ROLE_IDS ?? "") + .split(",") + .map((roleId) => roleId.trim()) + .filter(Boolean); +} + +function replyAccessError(interaction: DiscordInteraction): string | null { + const replyRoleIds = configuredReplyRoleIds(); + if (replyRoleIds.length === 0) { + console.error( + "[HackReview] DISCORD_REPLY_ROLE_IDS is missing or empty; refusing /reply.", + ); + return "This command is not configured."; + } + const memberRoles = interaction.member?.roles ?? []; + if (!replyRoleIds.some((roleId) => memberRoles.includes(roleId))) { + return "You do not have permission to use this command."; + } + return null; +} + +/** Walk Label or Action Row payloads to find a text input by custom_id. */ +function findTextInputValue( + components: DiscordModalComponent[] | undefined, + customId: string, +): string | undefined { + if (!components) return undefined; + for (const component of components) { + if (component.custom_id === customId && typeof component.value === "string") { + return component.value; + } + const nested = component.component + ? findTextInputValue([component.component], customId) + : undefined; + if (nested !== undefined) return nested; + const fromList = findTextInputValue(component.components, customId); + if (fromList !== undefined) return fromList; + } + return undefined; +} + async function editDeferredResponse( interaction: DiscordInteraction, content: string, @@ -64,56 +202,17 @@ async function editDeferredResponse( } } -export async function POST(request: Request) { - const rawBody = await request.text(); - const isValid = await verifyDiscordRequest( - rawBody, - request.headers.get("x-signature-ed25519"), - request.headers.get("x-signature-timestamp"), - ); - if (!isValid) { - return new Response("Invalid request signature", { status: 401 }); - } - - let interaction: DiscordInteraction; - try { - interaction = JSON.parse(rawBody) as DiscordInteraction; - } catch { - return new Response("Invalid JSON", { status: 400 }); - } - - if (interaction.type === InteractionType.PING) { - return Response.json({ type: InteractionResponseType.PONG }); - } - - if ( - interaction.type !== InteractionType.APPLICATION_COMMAND - || interaction.data?.name !== "reply" - || interaction.data.type !== 1 - ) { - return ephemeral("Unsupported command."); - } - - const message = interaction.data.options - ?.find((option) => option.name === "message") - ?.value - ?.trim(); - if (!message || !interaction.channel_id) { - return ephemeral("A message is required."); - } - if (message.length > 1800) { - return ephemeral("The message must be 1,800 characters or fewer."); - } - - const deferredResponse = Response.json({ - type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, - data: { flags: InteractionResponseFlags.EPHEMERAL }, - }); - +function deferReplyAndEmail(interaction: DiscordInteraction, message: string): Response { after(async () => { let emailSentConfirmation: string | null = null; let threadPostSucceeded = false; try { + const accessError = replyAccessError(interaction); + if (accessError) { + await editDeferredResponse(interaction, accessError); + return; + } + const serviceClient = await createServiceClient(); const { data: reviewThread, error } = await serviceClient .from("hack_review_threads") @@ -137,33 +236,7 @@ export async function POST(request: Request) { return; } - const replyRoleIds = (process.env.DISCORD_REPLY_ROLE_IDS ?? "") - .split(",") - .map((roleId) => roleId.trim()) - .filter(Boolean); - if (replyRoleIds.length === 0) { - console.error( - "[HackReview] DISCORD_REPLY_ROLE_IDS is missing or empty; refusing /reply.", - ); - await editDeferredResponse( - interaction, - "This command is not configured.", - ); - return; - } - const memberRoles = interaction.member?.roles ?? []; - if (!replyRoleIds.some((roleId) => memberRoles.includes(roleId))) { - await editDeferredResponse( - interaction, - "You do not have permission to use this command.", - ); - return; - } - - const adminName = interaction.member?.nick - || interaction.member?.user?.global_name - || interaction.member?.user?.username - || "Hackdex admin"; + const adminName = adminDisplayName(interaction); const discordUser = interaction.member?.user; const avatarUrl = discordUser?.id && discordUser.avatar ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` @@ -219,5 +292,73 @@ export async function POST(request: Request) { } }); - return deferredResponse; + return Response.json({ + type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + data: { flags: InteractionResponseFlags.EPHEMERAL }, + }); +} + +export async function POST(request: Request) { + const rawBody = await request.text(); + const isValid = await verifyDiscordRequest( + rawBody, + request.headers.get("x-signature-ed25519"), + request.headers.get("x-signature-timestamp"), + ); + if (!isValid) { + return new Response("Invalid request signature", { status: 401 }); + } + + let interaction: DiscordInteraction; + try { + interaction = JSON.parse(rawBody) as DiscordInteraction; + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + + if (interaction.type === InteractionType.PING) { + return Response.json({ type: InteractionResponseType.PONG }); + } + + if ( + interaction.type === InteractionType.APPLICATION_COMMAND + && interaction.data?.name === "reply" + && interaction.data.type === 1 + ) { + const accessError = replyAccessError(interaction); + if (accessError) return ephemeral(accessError); + if (!interaction.channel_id) { + return ephemeral("This command can only be used in a mapped Hackdex review thread."); + } + try { + const context = await loadReplyModalContext(interaction.channel_id); + if (!context) { + return ephemeral("This command can only be used in a mapped Hackdex review thread."); + } + return replyModal({ + ...context, + adminName: adminDisplayName(interaction), + }); + } catch (error) { + console.error("[HackReview] Failed to load /reply modal context:", error); + return replyModal(); + } + } + + if ( + interaction.type === InteractionType.MODAL_SUBMIT + && interaction.data?.custom_id === REPLY_MODAL_ID + ) { + const message = findTextInputValue(interaction.data.components, REPLY_MESSAGE_ID) + ?.trim(); + if (!message || !interaction.channel_id) { + return ephemeral("A message is required."); + } + if (message.length > REPLY_MESSAGE_MAX_LENGTH) { + return ephemeral("The message must be 1,800 characters or fewer."); + } + return deferReplyAndEmail(interaction, message); + } + + return ephemeral("Unsupported command."); } diff --git a/src/utils/discord-commands.mjs b/src/utils/discord-commands.mjs index e6be058..6d7adca 100644 --- a/src/utils/discord-commands.mjs +++ b/src/utils/discord-commands.mjs @@ -3,14 +3,5 @@ export const discordGuildCommands = [ type: 1, name: "reply", description: "Email the submitter from this review thread", - options: [ - { - type: 3, - name: "message", - description: "Message to email to the submitter", - required: true, - max_length: 1800, - }, - ], }, ]; diff --git a/src/utils/discord-rest.ts b/src/utils/discord-rest.ts index 68359c0..4f5879e 100644 --- a/src/utils/discord-rest.ts +++ b/src/utils/discord-rest.ts @@ -182,7 +182,10 @@ export async function approveDiscordReviewThread(threadId: string): Promise(`/channels/${threadId}`, { method: "PATCH", - body: JSON.stringify({ applied_tags: Array.from(appliedTags) }), + body: JSON.stringify({ + applied_tags: Array.from(appliedTags), + archived: true, + }), }); return updated !== null; }