mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-10-07 08:19:05 -05:00
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:
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user