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 <cursoragent@cursor.com>

* Archive the review forum post when a hack is approved.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jared Schoeny
2026-09-04 21:59:48 -06:00
committed by GitHub
parent fbe25aa4cd
commit 8007efcdf6
3 changed files with 219 additions and 84 deletions

View File

@@ -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.");
}

View File

@@ -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,
},
],
},
];

View File

@@ -182,7 +182,10 @@ export async function approveDiscordReviewThread(threadId: string): Promise<bool
const updated = await discordRequest<DiscordThread>(`/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;
}