import config from "@/config"; import type { Bug } from "@/database"; import { text, thumbnail } from "@/utils/discord/components"; import { ActionRowBuilder, type AttachmentBuilder, ButtonBuilder, ButtonStyle, type Client, ContainerBuilder, FileUploadBuilder, LabelBuilder, MediaGalleryBuilder, MediaGalleryItemBuilder, ModalBuilder, SectionBuilder, type Snowflake, StringSelectMenuBuilder, TextDisplayBuilder, TextInputBuilder, TextInputStyle, ThumbnailBuilder, } from "discord.js"; // -- types ---------------------------------------------------------------------------------------- type ProjectChoice = T extends "name" ? { name: string; value: string } : { label: string; value: string; default: boolean }; // -- constants ------------------------------------------------------------------------------------ export const PROJECT_MAP = config.projects; export const INPUT_IDS = { AFFECTED: "affectedInput", TITLE: "titleInput", DESCRIPTION: "descriptionInput", MEDIA: "fileUploadInput", } as const; export const MODAL_IDS = { REPORT: "bug:report", EDIT: "bug:edit", } as const; export const VALIDATION = { TITLE_MAX_LENGTH: 100, DESCRIPTION_MAX_LENGTH: 1000, THREAD_NAME_MAX_LENGTH: 50, AUTO_ARCHIVE_DURATION: 1440, } as const; // -- local helpres -------------------------------------------------------------------------------- export function getProjectChoices( label: T = "name" as T, defaultProjectKey?: string, ): ProjectChoice[] { return Object.entries(PROJECT_MAP).map(([key, project]) => label === "name" ? { name: project.displayName, value: key } : { label: project.displayName, value: key, default: key === defaultProjectKey, }, ) as ProjectChoice[]; } // -- main ----------------------------------------------------------------------------------------- export async function constructContainer( bug: Bug, userId: Snowflake, file?: AttachmentBuilder, ): Promise { const project = PROJECT_MAP[bug.projects[0]]; if (!project) return undefined; const { title, description, id } = bug; const bodyText = text(`### ${title}\n${description}`); const footerText = text( [`-# #${id}`, project.displayName, `Reported by <@${userId}>`].join(" • "), ); const icon = thumbnail(project.iconURL); const section = new SectionBuilder().addTextDisplayComponents(bodyText); if (icon) section.setThumbnailAccessory(icon); const buttons = await constructButtons(bug); const gallery = file ? new MediaGalleryBuilder().addItems( new MediaGalleryItemBuilder().setURL(`attachment://${file.name}`), ) : undefined; const container = new ContainerBuilder(); container.addSectionComponents(section); if (gallery) container.addMediaGalleryComponents(gallery); container.addTextDisplayComponents(footerText); container.addActionRowComponents(buttons); return container; } export function buildReportModal(bugId?: string, bug?: Bug) { const terminology = config.terminology; const capitalizedTerminology = terminology.charAt(0).toUpperCase() + terminology.slice(1); const projectOptions = bug ? getProjectChoices("label", bug.projects[0]) : getProjectChoices("label"); const selectedProject = bug ? projectOptions.find((opt) => opt.value === bug.projects[0]) : undefined; const gameLabel = new LabelBuilder() .setLabel(capitalizedTerminology) .setDescription(`The ${terminology} affected by this bug`) .setStringSelectMenuComponent( new StringSelectMenuBuilder() .setMaxValues(1) .setMinValues(1) .setCustomId(INPUT_IDS.AFFECTED) .setPlaceholder(selectedProject?.label || `Select the ${terminology}`) .addOptions(projectOptions), ); const titleLabel = new LabelBuilder() .setLabel("Bug Title") .setDescription(bug ? "Update the bug title" : "A short summary of the bug") .setTextInputComponent( (() => { const input = new TextInputBuilder() .setCustomId(INPUT_IDS.TITLE) .setStyle(TextInputStyle.Short) .setRequired(true) .setMaxLength(VALIDATION.TITLE_MAX_LENGTH); if (bug) input.setValue(bug.title); return input; })(), ); const descriptionLabel = new LabelBuilder() .setLabel("Bug Description") .setDescription(bug ? "Update the bug description" : "Describe the bug in detail") .setTextInputComponent( (() => { const input = new TextInputBuilder() .setCustomId(INPUT_IDS.DESCRIPTION) .setStyle(TextInputStyle.Paragraph) .setRequired(true) .setMaxLength(VALIDATION.DESCRIPTION_MAX_LENGTH); if (bug) input.setValue(bug.description); return input; })(), ); const modal = new ModalBuilder() .setCustomId(bug ? `${MODAL_IDS.EDIT}:${bugId}` : `${MODAL_IDS.REPORT}:NEW`) .setTitle(bug ? `Edit Bug #${bugId}` : "Report a Bug"); if (bug) { modal.setLabelComponents(gameLabel, titleLabel, descriptionLabel); } else { const mediaLabel = new LabelBuilder() .setLabel("Media") .setDescription("Provide any screenshots or videos that demonstrate the bug") .setFileUploadComponent( new FileUploadBuilder().setCustomId(INPUT_IDS.MEDIA).setRequired(false), ); modal.setLabelComponents(gameLabel, titleLabel, descriptionLabel, mediaLabel); } return modal; } export async function updateBugEmbed( client: Client, bug: Bug, messageId: Snowflake, channelId: Snowflake, ) { const channel = await client.channels.fetch(channelId); if (!channel || !channel.isTextBased() || !channel.isSendable()) return; const message = await channel.messages.fetch(messageId); const project = PROJECT_MAP[bug.projects[0]]; if (!project) return; const container = await constructContainer(bug, bug.authorId); if (!container) return; await message.edit({ components: [container], flags: ["IsComponentsV2"], }); } export async function constructButtons(bug: Bug) { const row = new ActionRowBuilder(); const isOpen = bug.status === "open"; const statusId = isOpen ? `bug:close:${bug.id}` : `bug:open:${bug.id}`; const statusLabel = isOpen ? "Close" : "Reopen"; const statusToggle = new ButtonBuilder() .setCustomId(statusId) .setLabel(statusLabel) .setStyle(isOpen ? ButtonStyle.Danger : ButtonStyle.Success); const editButton = new ButtonBuilder() .setCustomId(`bug:edit:${bug.id}`) .setLabel("Edit") .setStyle(ButtonStyle.Secondary); const deleteButton = new ButtonBuilder() .setCustomId(`bug:delete:${bug.id}`) .setLabel("Delete") .setStyle(ButtonStyle.Secondary); const trelloButton = new ButtonBuilder() .setCustomId(`bug:trello:${bug.id}`) .setLabel("Trello") .setStyle(ButtonStyle.Secondary); row.addComponents(statusToggle, editButton, deleteButton, trelloButton); return row; }