From d168d91b8b3737fd6de3211cf0116428333a1ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phorcys=20=F0=9F=90=BE?= <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:37:34 +0000 Subject: [PATCH 01/13] feat(walkthrough): rebuild the walkthrough with components v2 --- src/commands/util/walkthrough.ts | 220 +++++++++++++++++++++++-------- src/events/walkthrough.ts | 66 +--------- 2 files changed, 170 insertions(+), 116 deletions(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index b7b4cb3..2438724 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -4,22 +4,26 @@ import { isHelpPost as isHelpThread } from "@lib/discord/channels.js"; import { getCommandMention } from "@lib/discord/commands.js"; import issueCategorySelector from "@components/issueCategorySelector.js"; import productSelector from "@components/productSelector.js"; +import operatingSystemFamilySelector from "@components/operatingSystemFamilySelector.js"; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type ChatInputCommandInteraction, - type Client, Colors, - type Embed, - EmbedBuilder, + ComponentType, + ContainerBuilder, type GuildTextBasedChannel, - type MessageActionRowComponentBuilder, + type Message, MessageFlags, type PublicThreadChannel, + SectionBuilder, + SeparatorBuilder, SlashCommandBuilder, type StringSelectMenuBuilder, + type StringSelectMenuInteraction, + TextDisplayBuilder, } from "discord.js"; type ResourceLink = { label: string; url: string }; @@ -43,7 +47,23 @@ const productResources: Record = { ], }; -// Resolves the resources for a product from the label shown in the data embed. +// The walkthrough steps, in order. Each selector fills the data field with the +// matching name. +const steps = [ + { field: "Category", selector: issueCategorySelector }, + { field: "Product", selector: productSelector }, + { field: "Platform", selector: operatingSystemFamilySelector }, +]; + +function getLabelFromValue( + value: string, + selector: (typeof steps)[number]["selector"], +) { + return selector.options.find((option) => option.data.value === value)?.data + .label; +} + +// Resolves the resources for a product from the label shown in the data text. function resourcesForProduct(productLabel: string): ResourceLink[] { const option = productSelector.options.find( (o) => o.data.label === productLabel, @@ -51,67 +71,111 @@ function resourcesForProduct(productLabel: string): ResourceLink[] { return (option && productResources[option.data.value ?? ""]) || []; } -// The data embed tracks the walkthrough answers. Its fields line up with the -// walkthrough selectors (Category, Product, Platform) so each step fills the -// matching field in place. -export function buildDataEmbed(channelId: string) { - return new EmbedBuilder().setTitle(`<#${channelId}>`).addFields([ - { name: "Category", value: "N/A", inline: true }, - { name: "Product", value: "N/A", inline: true }, - { name: "Platform", value: "N/A", inline: true }, - { name: "Logs", value: "Please post any relevant logs/error messages." }, - ]); +// The data text summarizes the answers so far. Each field lives on its own line +// so a step can update just its line in place. +function buildDataText(channelId: string) { + return [ + `### <#${channelId}>`, + ...steps.map((step) => `**${step.field}:** N/A`), + "", + "Please post any relevant logs/error messages.", + ].join("\n"); } -// The resources embed points users at the post lifecycle commands. It stays the -// same for the whole walkthrough. -export async function buildResourcesEmbed(client: Client) { - return new EmbedBuilder() - .setColor(Colors.White) - .setDescription( - `When your issue is resolved, use ${await getCommandMention(client, "close")} to close this issue. Use ${await getCommandMention(client, "reopen")} to reopen it if needed.`, - ); +function setDataField(dataText: string, field: string, value: string) { + return dataText.replace( + new RegExp(`\\*\\*${field}:\\*\\* .*`), + `**${field}:** ${value}`, + ); } -// Assembles the single walkthrough message from its current state: the data and -// resources embeds, the current question and selector (while the walkthrough is -// running), and a documentation button per resource of the selected product. -export function buildWalkthroughMessage( - dataEmbed: EmbedBuilder, - resourcesEmbed: EmbedBuilder | Embed, - step?: { question: string; selector: StringSelectMenuBuilder }, +function productFromDataText(dataText: string) { + return dataText.match(/\*\*Product:\*\* (.+)/)?.[1] ?? ""; +} + +// Reads the text-display contents (data, then resources) back out of a +// walkthrough message so the next step can rebuild it. +function readTextDisplays(message: Message) { + const contents: string[] = []; + + // biome-ignore lint/suspicious/noExplicitAny: walking nested V2 components + const walk = (components: readonly any[]) => { + for (const component of components) { + if (component.type === ComponentType.TextDisplay) { + contents.push(component.content); + } else if (Array.isArray(component.components)) { + walk(component.components); + } + } + }; + + walk(message.components); + return contents; +} + +async function buildResourcesText( + client: ChatInputCommandInteraction["client"], ) { - const embeds: (EmbedBuilder | Embed)[] = [dataEmbed, resourcesEmbed]; - const components: ActionRowBuilder[] = []; + return `When your issue is resolved, use ${await getCommandMention(client, "close")} to close this issue. Use ${await getCommandMention(client, "reopen")} to reopen it if needed.`; +} - if (step) { - embeds.push( - new EmbedBuilder().setColor(Colors.White).setDescription(step.question), - ); - components.push( - new ActionRowBuilder().addComponents( - step.selector, - ), +// Assembles the walkthrough message from its current state: an info container +// with the data summary, the lifecycle commands, and (once complete) a +// documentation button per product resource, plus the current question and +// selector while the walkthrough is running. +function buildWalkthroughMessage( + dataText: string, + resourcesText: string, + step?: { question: string; selector: StringSelectMenuBuilder }, + resources: ResourceLink[] = [], +) { + const info = new ContainerBuilder() + .addTextDisplayComponents(new TextDisplayBuilder({ content: dataText })) + .addSeparatorComponents(new SeparatorBuilder()) + .addTextDisplayComponents( + new TextDisplayBuilder({ content: resourcesText }), ); - } - const resources = resourcesForProduct( - dataEmbed.data.fields?.[1]?.value ?? "", - ); if (resources.length > 0) { + info.addSeparatorComponents(new SeparatorBuilder()); + for (const resource of resources) { + info.addSectionComponents( + new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder({ content: resource.label }), + ) + .setButtonAccessory( + new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setLabel("Docs") + .setURL(resource.url), + ), + ); + } + } + + const components: ( + | ContainerBuilder + | ActionRowBuilder + )[] = [info]; + + if (step) { components.push( - new ActionRowBuilder().addComponents( - resources.map((resource) => - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel(resource.label) - .setURL(resource.url), + new ContainerBuilder() + .setAccentColor(Colors.White) + .addTextDisplayComponents( + new TextDisplayBuilder({ content: step.question }), ), + new ActionRowBuilder().addComponents( + step.selector, ), ); } - return { embeds, components }; + return { + flags: MessageFlags.IsComponentsV2 as const, + components, + }; } export async function doWalkthrough( @@ -129,8 +193,8 @@ export async function doWalkthrough( } const walkthroughMessage = buildWalkthroughMessage( - buildDataEmbed(channel.id), - await buildResourcesEmbed(channel.client), + buildDataText(channel.id), + await buildResourcesText(channel.client), { question: "What are you creating this issue for?", selector: issueCategorySelector, @@ -139,7 +203,7 @@ export async function doWalkthrough( // Send the walkthrough message (or reply to the user if they're running the command) if (interaction) { - // If the bot has sent a message that contains an embed in the first 30 messages, then we assume it's the walkthrough message + // If the bot has sent a message with components in the first 30 messages, then we assume it's the walkthrough message const firstMessage = await threadChannel.fetchStarterMessage(); const existingWalkthrough = await threadChannel.messages .fetch({ around: firstMessage.id, limit: 30 }) @@ -148,7 +212,7 @@ export async function doWalkthrough( .filter( (message) => message.author.id === interaction.client.user.id && - message.embeds.length > 0, + message.components.length > 0, ) .at(0), ); @@ -168,6 +232,52 @@ export async function doWalkthrough( } } +// Advances the walkthrough one step by editing the same message: fills the +// answered field, asks the next question, or on the last step drops the +// question/selector and shows the product's documentation buttons. +export async function handleSelection( + interaction: StringSelectMenuInteraction, +) { + const index = steps.findIndex( + (step) => step.selector.data.custom_id === interaction.customId, + ); + if (index === -1) { + return; + } + + const lastStep = index + 1 === steps.length; + const [dataText, resourcesText] = readTextDisplays(interaction.message); + + const label = getLabelFromValue(interaction.values[0], steps[index].selector); + const updatedData = setDataField( + dataText, + steps[index].field, + label ?? "N/A", + ); + + const nextStep = steps[index + 1]; + const messageData = lastStep + ? buildWalkthroughMessage( + updatedData, + resourcesText, + undefined, + resourcesForProduct(productFromDataText(updatedData)), + ) + : buildWalkthroughMessage(updatedData, resourcesText, { + question: + nextStep.selector === productSelector + ? "What product are you using?" + : `What operating system are you running ${label} on?`, + selector: nextStep.selector, + }); + + await interaction.update(messageData); + + if (lastStep) { + await interaction.message.pin(); + } +} + export default { data: new SlashCommandBuilder() .setName("walkthrough") diff --git a/src/events/walkthrough.ts b/src/events/walkthrough.ts index 1e49167..7f252fb 100644 --- a/src/events/walkthrough.ts +++ b/src/events/walkthrough.ts @@ -1,71 +1,15 @@ -import { - buildWalkthroughMessage, - doWalkthrough, -} from "@commands/util/walkthrough.js"; +import { doWalkthrough, handleSelection } from "@commands/util/walkthrough.js"; -import issueCategorySelector from "@components/issueCategorySelector.js"; -import productSelector from "@components/productSelector.js"; -import operatingSystemFamilySelector from "@components/operatingSystemFamilySelector.js"; - -import { type Client, EmbedBuilder, Events } from "discord.js"; - -// This has to follow the order of the walkthrough steps -const selectors = [ - issueCategorySelector, - productSelector, - operatingSystemFamilySelector, -]; - -function getLabelFromValue(value, selector: (typeof selectors)[number]) { - return selector.options.filter((option) => option.data.value === value)[0] - .data.label; -} +import { type Client, Events } from "discord.js"; export default function registerEvents(client: Client) { // Do walkthrough whenever a thread is opened client.on(Events.ThreadCreate, async (channel) => doWalkthrough(channel)); - // Each selection edits the single walkthrough message in place. + // Each selection advances the single walkthrough message client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isStringSelectMenu()) { - return; - } - - const selector = selectors.find( - (element) => element.data.custom_id === interaction.customId, - ); - if (!selector) { - return; - } - - const index = selectors.indexOf(selector); - const lastStep = index + 1 === selectors.length; - - // Fill the answered field in the data embed with its human-readable label. - const dataEmbed = EmbedBuilder.from(interaction.message.embeds[0]); - dataEmbed.data.fields[index].value = getLabelFromValue( - interaction.values[0], - selector, - ); - - const nextSelector = selectors[index + 1]; - const step = lastStep - ? undefined - : { - question: - nextSelector === productSelector - ? "What product are you using?" - : `What operating system are you running ${dataEmbed.data.fields[index].value} on?`, - selector: nextSelector, - }; - - await interaction.update( - buildWalkthroughMessage(dataEmbed, interaction.message.embeds[1], step), - ); - - // If this is the last step of the walkthrough, we pin the message - if (lastStep) { - await interaction.message.pin(); + if (interaction.isStringSelectMenu()) { + return handleSelection(interaction); } }); } From 9104a8af3bc1adc4c4ed3abe81274e6910dd1abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phorcys=20=F0=9F=90=BE?= <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:43:38 +0000 Subject: [PATCH 02/13] refactor(walkthrough): reduce noise in the components v2 builder --- src/commands/util/walkthrough.ts | 289 +++++++++++++++---------------- 1 file changed, 141 insertions(+), 148 deletions(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index 2438724..2ac5029 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -11,6 +11,7 @@ import { ButtonBuilder, ButtonStyle, type ChatInputCommandInteraction, + type Client, Colors, ComponentType, ContainerBuilder, @@ -47,57 +48,56 @@ const productResources: Record = { ], }; -// The walkthrough steps, in order. Each selector fills the data field with the -// matching name. +// The walkthrough asks one selector per field, in this order. const steps = [ - { field: "Category", selector: issueCategorySelector }, - { field: "Product", selector: productSelector }, - { field: "Platform", selector: operatingSystemFamilySelector }, -]; - -function getLabelFromValue( - value: string, - selector: (typeof steps)[number]["selector"], -) { - return selector.options.find((option) => option.data.value === value)?.data - .label; -} + { field: "Category", menu: issueCategorySelector }, + { field: "Product", menu: productSelector }, + { field: "Platform", menu: operatingSystemFamilySelector }, +] as const; + +type Field = (typeof steps)[number]["field"]; -// Resolves the resources for a product from the label shown in the data text. +const text = (content: string) => new TextDisplayBuilder({ content }); + +const labelForValue = (menu: StringSelectMenuBuilder, value: string) => + menu.options.find((o) => o.data.value === value)?.data.label ?? "N/A"; + +// Maps the product label shown in the summary back to its resource links. function resourcesForProduct(productLabel: string): ResourceLink[] { - const option = productSelector.options.find( + const value = productSelector.options.find( (o) => o.data.label === productLabel, - ); - return (option && productResources[option.data.value ?? ""]) || []; + )?.data.value; + return (value && productResources[value]) || []; } -// The data text summarizes the answers so far. Each field lives on its own line -// so a step can update just its line in place. -function buildDataText(channelId: string) { +// The answers summary is stored as the info container's first text display, so +// it survives between steps. render/parse keep that text and the answer state +// in sync. +function renderSummary(channelId: string, answers: Record) { return [ `### <#${channelId}>`, - ...steps.map((step) => `**${step.field}:** N/A`), + ...steps.map((step) => `**${step.field}:** ${answers[step.field]}`), "", "Please post any relevant logs/error messages.", ].join("\n"); } -function setDataField(dataText: string, field: string, value: string) { - return dataText.replace( - new RegExp(`\\*\\*${field}:\\*\\* .*`), - `**${field}:** ${value}`, - ); -} - -function productFromDataText(dataText: string) { - return dataText.match(/\*\*Product:\*\* (.+)/)?.[1] ?? ""; +function parseSummary(summary: string) { + const channelId = summary.match(/<#(\d+)>/)?.[1] ?? ""; + const answers = Object.fromEntries( + steps.map((step) => [ + step.field, + summary.match(new RegExp(`\\*\\*${step.field}:\\*\\* (.+)`))?.[1] ?? + "N/A", + ]), + ) as Record; + return { channelId, answers }; } -// Reads the text-display contents (data, then resources) back out of a -// walkthrough message so the next step can rebuild it. -function readTextDisplays(message: Message) { +// Reads the container text displays (summary, then lifecycle text) back out of +// a walkthrough message. +function textDisplays(message: Message): string[] { const contents: string[] = []; - // biome-ignore lint/suspicious/noExplicitAny: walking nested V2 components const walk = (components: readonly any[]) => { for (const component of components) { @@ -108,50 +108,43 @@ function readTextDisplays(message: Message) { } } }; - walk(message.components); return contents; } -async function buildResourcesText( - client: ChatInputCommandInteraction["client"], -) { - return `When your issue is resolved, use ${await getCommandMention(client, "close")} to close this issue. Use ${await getCommandMention(client, "reopen")} to reopen it if needed.`; +async function lifecycleText(client: Client) { + const close = await getCommandMention(client, "close"); + const reopen = await getCommandMention(client, "reopen"); + return `When your issue is resolved, use ${close} to close this issue. Use ${reopen} to reopen it if needed.`; } -// Assembles the walkthrough message from its current state: an info container -// with the data summary, the lifecycle commands, and (once complete) a -// documentation button per product resource, plus the current question and -// selector while the walkthrough is running. -function buildWalkthroughMessage( - dataText: string, - resourcesText: string, - step?: { question: string; selector: StringSelectMenuBuilder }, - resources: ResourceLink[] = [], +const docSection = ({ label, url }: ResourceLink) => + new SectionBuilder() + .addTextDisplayComponents(text(label)) + .setButtonAccessory( + new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setLabel("Docs") + .setURL(url), + ); + +// Assembles the single walkthrough message: an info container (summary, +// lifecycle commands, and documentation buttons once complete) plus the current +// question and selector while the walkthrough is running. +function buildMessage( + summary: string, + lifecycle: string, + question?: { prompt: string; menu: StringSelectMenuBuilder }, + docs: ResourceLink[] = [], ) { const info = new ContainerBuilder() - .addTextDisplayComponents(new TextDisplayBuilder({ content: dataText })) + .addTextDisplayComponents(text(summary)) .addSeparatorComponents(new SeparatorBuilder()) - .addTextDisplayComponents( - new TextDisplayBuilder({ content: resourcesText }), - ); + .addTextDisplayComponents(text(lifecycle)); - if (resources.length > 0) { + if (docs.length > 0) { info.addSeparatorComponents(new SeparatorBuilder()); - for (const resource of resources) { - info.addSectionComponents( - new SectionBuilder() - .addTextDisplayComponents( - new TextDisplayBuilder({ content: resource.label }), - ) - .setButtonAccessory( - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel("Docs") - .setURL(resource.url), - ), - ); - } + info.addSectionComponents(...docs.map(docSection)); } const components: ( @@ -159,121 +152,121 @@ function buildWalkthroughMessage( | ActionRowBuilder )[] = [info]; - if (step) { + if (question) { components.push( new ContainerBuilder() .setAccentColor(Colors.White) - .addTextDisplayComponents( - new TextDisplayBuilder({ content: step.question }), - ), + .addTextDisplayComponents(text(question.prompt)), new ActionRowBuilder().addComponents( - step.selector, + question.menu, ), ); } - return { - flags: MessageFlags.IsComponentsV2 as const, - components, - }; + return { flags: MessageFlags.IsComponentsV2 as const, components }; } export async function doWalkthrough( channel: GuildTextBasedChannel, interaction?: ChatInputCommandInteraction, ) { - if (await isHelpThread(channel)) { - const threadChannel = channel as PublicThreadChannel; // necessary type cast, isHelpThread does the check already - - // Check for tags in the forum post - const appliedTags = threadChannel.appliedTags ?? []; - if (!appliedTags.includes(config.helpChannel.openedTag)) { - appliedTags.push(config.helpChannel.openedTag); - threadChannel.setAppliedTags(appliedTags); - } + if (!(await isHelpThread(channel))) { + return; + } - const walkthroughMessage = buildWalkthroughMessage( - buildDataText(channel.id), - await buildResourcesText(channel.client), - { - question: "What are you creating this issue for?", - selector: issueCategorySelector, - }, - ); + const threadChannel = channel as PublicThreadChannel; // necessary type cast, isHelpThread does the check already - // Send the walkthrough message (or reply to the user if they're running the command) - if (interaction) { - // If the bot has sent a message with components in the first 30 messages, then we assume it's the walkthrough message - const firstMessage = await threadChannel.fetchStarterMessage(); - const existingWalkthrough = await threadChannel.messages - .fetch({ around: firstMessage.id, limit: 30 }) - .then((messages) => - messages - .filter( - (message) => - message.author.id === interaction.client.user.id && - message.components.length > 0, - ) - .at(0), - ); - - if (existingWalkthrough) { - await interaction.reply({ - content: `You cannot run the walkthrough command because a walkthrough already exists in this channel.\n(${existingWalkthrough.url})`, - flags: MessageFlags.Ephemeral, - }); - return; - } + // Check for tags in the forum post + const appliedTags = threadChannel.appliedTags ?? []; + if (!appliedTags.includes(config.helpChannel.openedTag)) { + appliedTags.push(config.helpChannel.openedTag); + threadChannel.setAppliedTags(appliedTags); + } - await interaction.reply(walkthroughMessage); - } else { - await channel.send(walkthroughMessage); - } + const emptyAnswers = Object.fromEntries( + steps.map((step) => [step.field, "N/A"]), + ) as Record; + + const walkthroughMessage = buildMessage( + renderSummary(channel.id, emptyAnswers), + await lifecycleText(channel.client), + { + prompt: "What are you creating this issue for?", + menu: issueCategorySelector, + }, + ); + + // Slash-command runs reply to the user; auto-runs post to the thread. + if (!interaction) { + await channel.send(walkthroughMessage); + return; } + + // If the bot already posted a walkthrough (a message with components) near the + // start of the thread, don't post another one. + const firstMessage = await threadChannel.fetchStarterMessage(); + const existing = await threadChannel.messages + .fetch({ around: firstMessage.id, limit: 30 }) + .then((messages) => + messages + .filter( + (message) => + message.author.id === interaction.client.user.id && + message.components.length > 0, + ) + .at(0), + ); + + if (existing) { + await interaction.reply({ + content: `You cannot run the walkthrough command because a walkthrough already exists in this channel.\n(${existing.url})`, + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.reply(walkthroughMessage); } // Advances the walkthrough one step by editing the same message: fills the -// answered field, asks the next question, or on the last step drops the -// question/selector and shows the product's documentation buttons. +// answered field and either asks the next question or, on the last step, drops +// the question/selector and shows the product's documentation buttons. export async function handleSelection( interaction: StringSelectMenuInteraction, ) { const index = steps.findIndex( - (step) => step.selector.data.custom_id === interaction.customId, + (step) => step.menu.data.custom_id === interaction.customId, ); if (index === -1) { return; } - const lastStep = index + 1 === steps.length; - const [dataText, resourcesText] = readTextDisplays(interaction.message); - - const label = getLabelFromValue(interaction.values[0], steps[index].selector); - const updatedData = setDataField( - dataText, - steps[index].field, - label ?? "N/A", + const [summary, lifecycle] = textDisplays(interaction.message); + const { channelId, answers } = parseSummary(summary); + answers[steps[index].field] = labelForValue( + steps[index].menu, + interaction.values[0], ); - const nextStep = steps[index + 1]; - const messageData = lastStep - ? buildWalkthroughMessage( - updatedData, - resourcesText, - undefined, - resourcesForProduct(productFromDataText(updatedData)), - ) - : buildWalkthroughMessage(updatedData, resourcesText, { - question: - nextStep.selector === productSelector + const next = steps[index + 1]; + const message = next + ? buildMessage(renderSummary(channelId, answers), lifecycle, { + prompt: + next.menu === productSelector ? "What product are you using?" - : `What operating system are you running ${label} on?`, - selector: nextStep.selector, - }); + : `What operating system are you running ${answers.Product} on?`, + menu: next.menu, + }) + : buildMessage( + renderSummary(channelId, answers), + lifecycle, + undefined, + resourcesForProduct(answers.Product), + ); - await interaction.update(messageData); + await interaction.update(message); - if (lastStep) { + if (!next) { await interaction.message.pin(); } } From 78648300024b71b5f4993744a1441454c984f91f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phorcys=20=F0=9F=90=BE?= <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:47:42 +0000 Subject: [PATCH 03/13] refactor(walkthrough): carry state in custom id instead of parsing text --- src/commands/util/walkthrough.ts | 211 ++++++++++++------------------- 1 file changed, 78 insertions(+), 133 deletions(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index 2ac5029..c5ec705 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -13,16 +13,14 @@ import { type ChatInputCommandInteraction, type Client, Colors, - ComponentType, ContainerBuilder, type GuildTextBasedChannel, - type Message, MessageFlags, type PublicThreadChannel, SectionBuilder, SeparatorBuilder, SlashCommandBuilder, - type StringSelectMenuBuilder, + StringSelectMenuBuilder, type StringSelectMenuInteraction, TextDisplayBuilder, } from "discord.js"; @@ -48,76 +46,36 @@ const productResources: Record = { ], }; -// The walkthrough asks one selector per field, in this order. +// The walkthrough asks one selector per field, in this order. The prompt for a +// step may reference the labels chosen in earlier steps. const steps = [ - { field: "Category", menu: issueCategorySelector }, - { field: "Product", menu: productSelector }, - { field: "Platform", menu: operatingSystemFamilySelector }, + { + field: "Category", + menu: issueCategorySelector, + prompt: () => "What are you creating this issue for?", + }, + { + field: "Product", + menu: productSelector, + prompt: () => "What product are you using?", + }, + { + field: "Platform", + menu: operatingSystemFamilySelector, + prompt: (labels: string[]) => + `What operating system are you running ${labels[1]} on?`, + }, ] as const; -type Field = (typeof steps)[number]["field"]; +// Answered values are carried between steps in the selector's custom id, so the +// walkthrough never has to read its state back out of the message. +const CUSTOM_ID = "walkthrough"; const text = (content: string) => new TextDisplayBuilder({ content }); const labelForValue = (menu: StringSelectMenuBuilder, value: string) => menu.options.find((o) => o.data.value === value)?.data.label ?? "N/A"; -// Maps the product label shown in the summary back to its resource links. -function resourcesForProduct(productLabel: string): ResourceLink[] { - const value = productSelector.options.find( - (o) => o.data.label === productLabel, - )?.data.value; - return (value && productResources[value]) || []; -} - -// The answers summary is stored as the info container's first text display, so -// it survives between steps. render/parse keep that text and the answer state -// in sync. -function renderSummary(channelId: string, answers: Record) { - return [ - `### <#${channelId}>`, - ...steps.map((step) => `**${step.field}:** ${answers[step.field]}`), - "", - "Please post any relevant logs/error messages.", - ].join("\n"); -} - -function parseSummary(summary: string) { - const channelId = summary.match(/<#(\d+)>/)?.[1] ?? ""; - const answers = Object.fromEntries( - steps.map((step) => [ - step.field, - summary.match(new RegExp(`\\*\\*${step.field}:\\*\\* (.+)`))?.[1] ?? - "N/A", - ]), - ) as Record; - return { channelId, answers }; -} - -// Reads the container text displays (summary, then lifecycle text) back out of -// a walkthrough message. -function textDisplays(message: Message): string[] { - const contents: string[] = []; - // biome-ignore lint/suspicious/noExplicitAny: walking nested V2 components - const walk = (components: readonly any[]) => { - for (const component of components) { - if (component.type === ComponentType.TextDisplay) { - contents.push(component.content); - } else if (Array.isArray(component.components)) { - walk(component.components); - } - } - }; - walk(message.components); - return contents; -} - -async function lifecycleText(client: Client) { - const close = await getCommandMention(client, "close"); - const reopen = await getCommandMention(client, "reopen"); - return `When your issue is resolved, use ${close} to close this issue. Use ${reopen} to reopen it if needed.`; -} - const docSection = ({ label, url }: ResourceLink) => new SectionBuilder() .addTextDisplayComponents(text(label)) @@ -128,42 +86,60 @@ const docSection = ({ label, url }: ResourceLink) => .setURL(url), ); -// Assembles the single walkthrough message: an info container (summary, -// lifecycle commands, and documentation buttons once complete) plus the current -// question and selector while the walkthrough is running. -function buildMessage( - summary: string, - lifecycle: string, - question?: { prompt: string; menu: StringSelectMenuBuilder }, - docs: ResourceLink[] = [], +async function lifecycleText(client: Client) { + const close = await getCommandMention(client, "close"); + const reopen = await getCommandMention(client, "reopen"); + return `When your issue is resolved, use ${close} to close this issue. Use ${reopen} to reopen it if needed.`; +} + +// Builds the walkthrough message from the answered values so far. While steps +// remain it shows the next question and selector; once complete it drops those +// and shows the selected product's documentation buttons. +async function buildMessage( + client: Client, + channelId: string, + values: string[], ) { + const labels = values.map((value, i) => labelForValue(steps[i].menu, value)); + const info = new ContainerBuilder() - .addTextDisplayComponents(text(summary)) + .addTextDisplayComponents( + text( + [ + `### <#${channelId}>`, + ...steps.map((step, i) => `**${step.field}:** ${labels[i] ?? "N/A"}`), + "", + "Please post any relevant logs/error messages.", + ].join("\n"), + ), + ) .addSeparatorComponents(new SeparatorBuilder()) - .addTextDisplayComponents(text(lifecycle)); + .addTextDisplayComponents(text(await lifecycleText(client))); + + const step = steps[values.length]; - if (docs.length > 0) { - info.addSeparatorComponents(new SeparatorBuilder()); - info.addSectionComponents(...docs.map(docSection)); + if (!step) { + for (const resource of productResources[values[1]] ?? []) { + info.addSeparatorComponents(new SeparatorBuilder()); + info.addSectionComponents(docSection(resource)); + } + return { flags: MessageFlags.IsComponentsV2 as const, components: [info] }; } - const components: ( - | ContainerBuilder - | ActionRowBuilder - )[] = [info]; + const menu = StringSelectMenuBuilder.from(step.menu).setCustomId( + [CUSTOM_ID, ...values].join(":"), + ); - if (question) { - components.push( + return { + flags: MessageFlags.IsComponentsV2 as const, + components: [ + info, new ContainerBuilder() .setAccentColor(Colors.White) - .addTextDisplayComponents(text(question.prompt)), - new ActionRowBuilder().addComponents( - question.menu, - ), - ); - } - - return { flags: MessageFlags.IsComponentsV2 as const, components }; + .addTextDisplayComponents(text(step.prompt(labels))), + new ActionRowBuilder().addComponents(menu), + ], + }; } export async function doWalkthrough( @@ -183,18 +159,7 @@ export async function doWalkthrough( threadChannel.setAppliedTags(appliedTags); } - const emptyAnswers = Object.fromEntries( - steps.map((step) => [step.field, "N/A"]), - ) as Record; - - const walkthroughMessage = buildMessage( - renderSummary(channel.id, emptyAnswers), - await lifecycleText(channel.client), - { - prompt: "What are you creating this issue for?", - menu: issueCategorySelector, - }, - ); + const walkthroughMessage = await buildMessage(channel.client, channel.id, []); // Slash-command runs reply to the user; auto-runs post to the thread. if (!interaction) { @@ -228,45 +193,25 @@ export async function doWalkthrough( await interaction.reply(walkthroughMessage); } -// Advances the walkthrough one step by editing the same message: fills the -// answered field and either asks the next question or, on the last step, drops -// the question/selector and shows the product's documentation buttons. +// Advances the walkthrough one step by editing the same message with the newly +// answered value appended. export async function handleSelection( interaction: StringSelectMenuInteraction, ) { - const index = steps.findIndex( - (step) => step.menu.data.custom_id === interaction.customId, - ); - if (index === -1) { + if (!interaction.customId.startsWith(CUSTOM_ID)) { return; } - const [summary, lifecycle] = textDisplays(interaction.message); - const { channelId, answers } = parseSummary(summary); - answers[steps[index].field] = labelForValue( - steps[index].menu, + const values = [ + ...interaction.customId.split(":").slice(1), interaction.values[0], - ); + ]; - const next = steps[index + 1]; - const message = next - ? buildMessage(renderSummary(channelId, answers), lifecycle, { - prompt: - next.menu === productSelector - ? "What product are you using?" - : `What operating system are you running ${answers.Product} on?`, - menu: next.menu, - }) - : buildMessage( - renderSummary(channelId, answers), - lifecycle, - undefined, - resourcesForProduct(answers.Product), - ); - - await interaction.update(message); + await interaction.update( + await buildMessage(interaction.client, interaction.channelId, values), + ); - if (!next) { + if (values.length === steps.length) { await interaction.message.pin(); } } From 0c9903dbc24e069d645985ac7472ceb451091244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phorcys=20=F0=9F=90=BE?= <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:52:28 +0000 Subject: [PATCH 04/13] refactor(walkthrough): write summary fields explicitly --- src/commands/util/walkthrough.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index c5ec705..68591fd 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -50,17 +50,14 @@ const productResources: Record = { // step may reference the labels chosen in earlier steps. const steps = [ { - field: "Category", menu: issueCategorySelector, prompt: () => "What are you creating this issue for?", }, { - field: "Product", menu: productSelector, prompt: () => "What product are you using?", }, { - field: "Platform", menu: operatingSystemFamilySelector, prompt: (labels: string[]) => `What operating system are you running ${labels[1]} on?`, @@ -101,13 +98,16 @@ async function buildMessage( values: string[], ) { const labels = values.map((value, i) => labelForValue(steps[i].menu, value)); + const [category = "N/A", product = "N/A", platform = "N/A"] = labels; const info = new ContainerBuilder() .addTextDisplayComponents( text( [ `### <#${channelId}>`, - ...steps.map((step, i) => `**${step.field}:** ${labels[i] ?? "N/A"}`), + `**Category:** ${category}`, + `**Product:** ${product}`, + `**Platform:** ${platform}`, "", "Please post any relevant logs/error messages.", ].join("\n"), From 67783660b0ab4366d54d1e9d119bbada06a70429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phorcys=20=F0=9F=90=BE?= <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:10:56 +0000 Subject: [PATCH 05/13] feat(walkthrough): show answers as disabled emoji buttons --- src/commands/util/walkthrough.ts | 125 ++++++++++++++++++------------- 1 file changed, 73 insertions(+), 52 deletions(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index 68591fd..f488aea 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -15,6 +15,7 @@ import { Colors, ContainerBuilder, type GuildTextBasedChannel, + type MessageActionRowComponentBuilder, MessageFlags, type PublicThreadChannel, SectionBuilder, @@ -46,21 +47,23 @@ const productResources: Record = { ], }; -// The walkthrough asks one selector per field, in this order. The prompt for a -// step may reference the labels chosen in earlier steps. +// The walkthrough asks one selector per field, in this order. const steps = [ { + field: "Category", menu: issueCategorySelector, prompt: () => "What are you creating this issue for?", }, { + field: "Product", menu: productSelector, prompt: () => "What product are you using?", }, { + field: "Platform", menu: operatingSystemFamilySelector, - prompt: (labels: string[]) => - `What operating system are you running ${labels[1]} on?`, + prompt: (product: string) => + `What operating system are you running ${product} on?`, }, ] as const; @@ -70,18 +73,29 @@ const CUSTOM_ID = "walkthrough"; const text = (content: string) => new TextDisplayBuilder({ content }); -const labelForValue = (menu: StringSelectMenuBuilder, value: string) => - menu.options.find((o) => o.data.value === value)?.data.label ?? "N/A"; - -const docSection = ({ label, url }: ResourceLink) => - new SectionBuilder() - .addTextDisplayComponents(text(label)) - .setButtonAccessory( - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel("Docs") - .setURL(url), - ); +// A field row: the field name with a disabled button showing the chosen option +// (label and emoji), or "N/A" until it is answered. +function fieldSection( + field: string, + menu: StringSelectMenuBuilder, + value?: string, +) { + const option = menu.options.find((o) => o.data.value === value)?.data; + + const button = new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setCustomId(`${CUSTOM_ID}:field:${field}`) + .setDisabled(true) + .setLabel(option?.label ?? "N/A"); + + if (option?.emoji) { + button.setEmoji(option.emoji); + } + + return new SectionBuilder() + .addTextDisplayComponents(text(field)) + .setButtonAccessory(button); +} async function lifecycleText(client: Client) { const close = await getCommandMention(client, "close"); @@ -89,57 +103,64 @@ async function lifecycleText(client: Client) { return `When your issue is resolved, use ${close} to close this issue. Use ${reopen} to reopen it if needed.`; } -// Builds the walkthrough message from the answered values so far. While steps -// remain it shows the next question and selector; once complete it drops those -// and shows the selected product's documentation buttons. +// Builds the walkthrough message from the answered values so far: an info +// container with a field row per answer, the current question and selector while +// steps remain, and the selected product's documentation buttons at the bottom. async function buildMessage( client: Client, channelId: string, values: string[], ) { - const labels = values.map((value, i) => labelForValue(steps[i].menu, value)); - const [category = "N/A", product = "N/A", platform = "N/A"] = labels; - const info = new ContainerBuilder() - .addTextDisplayComponents( - text( - [ - `### <#${channelId}>`, - `**Category:** ${category}`, - `**Product:** ${product}`, - `**Platform:** ${platform}`, - "", - "Please post any relevant logs/error messages.", - ].join("\n"), - ), + .setAccentColor(Colors.Blurple) + .addTextDisplayComponents(text(`<#${channelId}>`)) + .addSeparatorComponents(new SeparatorBuilder()) + .addSectionComponents( + fieldSection("Category", issueCategorySelector, values[0]), + fieldSection("Product", productSelector, values[1]), + fieldSection("Platform", operatingSystemFamilySelector, values[2]), ) .addSeparatorComponents(new SeparatorBuilder()) .addTextDisplayComponents(text(await lifecycleText(client))); + const components: ( + | ContainerBuilder + | ActionRowBuilder + )[] = [info]; + const step = steps[values.length]; + if (step) { + const product = productSelector.options.find( + (o) => o.data.value === values[1], + )?.data.label; - if (!step) { - for (const resource of productResources[values[1]] ?? []) { - info.addSeparatorComponents(new SeparatorBuilder()); - info.addSectionComponents(docSection(resource)); - } - return { flags: MessageFlags.IsComponentsV2 as const, components: [info] }; + components.push( + new ContainerBuilder() + .setAccentColor(Colors.Blurple) + .addTextDisplayComponents(text(step.prompt(product ?? "N/A"))), + new ActionRowBuilder().addComponents( + StringSelectMenuBuilder.from(step.menu).setCustomId( + [CUSTOM_ID, ...values].join(":"), + ), + ), + ); } - const menu = StringSelectMenuBuilder.from(step.menu).setCustomId( - [CUSTOM_ID, ...values].join(":"), - ); + const docs = productResources[values[1]] ?? []; + if (docs.length > 0) { + components.push( + new ActionRowBuilder().addComponents( + docs.map((doc) => + new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setLabel(doc.label) + .setURL(doc.url), + ), + ), + ); + } - return { - flags: MessageFlags.IsComponentsV2 as const, - components: [ - info, - new ContainerBuilder() - .setAccentColor(Colors.White) - .addTextDisplayComponents(text(step.prompt(labels))), - new ActionRowBuilder().addComponents(menu), - ], - }; + return { flags: MessageFlags.IsComponentsV2 as const, components }; } export async function doWalkthrough( From 4741cb9d7718495ff11d16e2587fc04daa8270b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phorcys=20=F0=9F=90=BE?= <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:15:37 +0000 Subject: [PATCH 06/13] feat(walkthrough): split lifecycle text onto two lines --- src/commands/util/walkthrough.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index f488aea..7216e27 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -100,7 +100,7 @@ function fieldSection( async function lifecycleText(client: Client) { const close = await getCommandMention(client, "close"); const reopen = await getCommandMention(client, "reopen"); - return `When your issue is resolved, use ${close} to close this issue. Use ${reopen} to reopen it if needed.`; + return `When your issue is resolved, use ${close} to close this issue.\nUse ${reopen} to reopen it if needed.`; } // Builds the walkthrough message from the answered values so far: an info From 6c84daf6d6b41ea221dc7c92a3f9b9207b31444a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phorcys=20=F0=9F=90=BE?= <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:18:20 +0000 Subject: [PATCH 07/13] feat(walkthrough): shorten lifecycle close wording --- src/commands/util/walkthrough.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index 7216e27..643d110 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -100,7 +100,7 @@ function fieldSection( async function lifecycleText(client: Client) { const close = await getCommandMention(client, "close"); const reopen = await getCommandMention(client, "reopen"); - return `When your issue is resolved, use ${close} to close this issue.\nUse ${reopen} to reopen it if needed.`; + return `When your issue is resolved, use ${close} to close it.\nUse ${reopen} to reopen it if needed.`; } // Builds the walkthrough message from the answered values so far: an info From 2114852f92929356c58dec0c1e14de28a522ffb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phorcys=20=F0=9F=90=BE?= <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:23:32 +0000 Subject: [PATCH 08/13] feat(walkthrough): enable answer buttons as no-ops --- src/commands/util/walkthrough.ts | 10 +++++++++- src/events/walkthrough.ts | 12 ++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index 643d110..a31e6d9 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -10,6 +10,7 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, + type ButtonInteraction, type ChatInputCommandInteraction, type Client, Colors, @@ -85,7 +86,6 @@ function fieldSection( const button = new ButtonBuilder() .setStyle(ButtonStyle.Secondary) .setCustomId(`${CUSTOM_ID}:field:${field}`) - .setDisabled(true) .setLabel(option?.label ?? "N/A"); if (option?.emoji) { @@ -237,6 +237,14 @@ export async function handleSelection( } } +// The answer buttons are interactive but intentionally do nothing; just +// acknowledge the click so Discord doesn't show an error. +export async function handleFieldButton(interaction: ButtonInteraction) { + if (interaction.customId.startsWith(`${CUSTOM_ID}:field:`)) { + await interaction.deferUpdate(); + } +} + export default { data: new SlashCommandBuilder() .setName("walkthrough") diff --git a/src/events/walkthrough.ts b/src/events/walkthrough.ts index 7f252fb..41a3d31 100644 --- a/src/events/walkthrough.ts +++ b/src/events/walkthrough.ts @@ -1,4 +1,8 @@ -import { doWalkthrough, handleSelection } from "@commands/util/walkthrough.js"; +import { + doWalkthrough, + handleFieldButton, + handleSelection, +} from "@commands/util/walkthrough.js"; import { type Client, Events } from "discord.js"; @@ -6,10 +10,14 @@ export default function registerEvents(client: Client) { // Do walkthrough whenever a thread is opened client.on(Events.ThreadCreate, async (channel) => doWalkthrough(channel)); - // Each selection advances the single walkthrough message + // Each selection advances the single walkthrough message; the answer buttons + // are no-ops. client.on(Events.InteractionCreate, async (interaction) => { if (interaction.isStringSelectMenu()) { return handleSelection(interaction); } + if (interaction.isButton()) { + return handleFieldButton(interaction); + } }); } From a8730104d31e4e5644296c14cd50b0de6e742f66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phorcys=20=F0=9F=90=BE?= <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:28:07 +0000 Subject: [PATCH 09/13] feat(walkthrough): tell users the answer buttons can't be edited --- src/commands/util/walkthrough.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index a31e6d9..96c9086 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -237,11 +237,14 @@ export async function handleSelection( } } -// The answer buttons are interactive but intentionally do nothing; just -// acknowledge the click so Discord doesn't show an error. +// The answer buttons only summarize the walkthrough answers, so a click just +// tells the user they can't be edited. export async function handleFieldButton(interaction: ButtonInteraction) { if (interaction.customId.startsWith(`${CUSTOM_ID}:field:`)) { - await interaction.deferUpdate(); + await interaction.reply({ + content: "This is just a summary of your answers, you can't edit it.", + flags: MessageFlags.Ephemeral, + }); } } From 5833386ed6bae740b5d2867ac056ff74bfabf664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phorcys=20=F0=9F=90=BE?= <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:28:38 +0000 Subject: [PATCH 10/13] feat(walkthrough): disable answer buttons until answered --- src/commands/util/walkthrough.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index 96c9086..4960495 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -86,6 +86,7 @@ function fieldSection( const button = new ButtonBuilder() .setStyle(ButtonStyle.Secondary) .setCustomId(`${CUSTOM_ID}:field:${field}`) + .setDisabled(!option) .setLabel(option?.label ?? "N/A"); if (option?.emoji) { From 0dc5615e75b2cf3f0a68f1597945343a2ea34066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phorcys=20=F0=9F=90=BE?= <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:31:25 +0000 Subject: [PATCH 11/13] feat(ui): add emojis to issue category options --- src/ui/components/issueCategorySelector.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/ui/components/issueCategorySelector.ts b/src/ui/components/issueCategorySelector.ts index 397b209..b5ce09b 100644 --- a/src/ui/components/issueCategorySelector.ts +++ b/src/ui/components/issueCategorySelector.ts @@ -4,15 +4,25 @@ import { } from "discord.js"; const options = [ - new StringSelectMenuOptionBuilder().setLabel("Help needed").setValue("help"), + new StringSelectMenuOptionBuilder() + .setLabel("Help needed") + .setValue("help") + .setEmoji("🙋"), - new StringSelectMenuOptionBuilder().setLabel("Bug report").setValue("bug"), + new StringSelectMenuOptionBuilder() + .setLabel("Bug report") + .setValue("bug") + .setEmoji("🧩"), new StringSelectMenuOptionBuilder() .setLabel("Feature request") - .setValue("feature"), + .setValue("feature") + .setEmoji("💡"), - new StringSelectMenuOptionBuilder().setLabel("Other").setValue("other"), + new StringSelectMenuOptionBuilder() + .setLabel("Other") + .setValue("other") + .setEmoji("❓"), ]; export default new StringSelectMenuBuilder() From 9c2c9374b00bdd65cad64d67c1ccd848a97bdc23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phorcys=20=F0=9F=90=BE?= <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:32:00 +0000 Subject: [PATCH 12/13] feat(ui): use sparkles for feature request --- src/ui/components/issueCategorySelector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/components/issueCategorySelector.ts b/src/ui/components/issueCategorySelector.ts index b5ce09b..7f200db 100644 --- a/src/ui/components/issueCategorySelector.ts +++ b/src/ui/components/issueCategorySelector.ts @@ -17,7 +17,7 @@ const options = [ new StringSelectMenuOptionBuilder() .setLabel("Feature request") .setValue("feature") - .setEmoji("💡"), + .setEmoji("✨"), new StringSelectMenuOptionBuilder() .setLabel("Other") From 826e808ccae91747fbe8c09655d0dfbf08a993a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phorcys=20=F0=9F=90=BE?= <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:45:17 +0000 Subject: [PATCH 13/13] refactor(walkthrough): extract optionOf and row helpers --- src/commands/util/walkthrough.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index 4960495..27f14cb 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -74,6 +74,14 @@ const CUSTOM_ID = "walkthrough"; const text = (content: string) => new TextDisplayBuilder({ content }); +const optionOf = (menu: StringSelectMenuBuilder, value?: string) => + menu.options.find((o) => o.data.value === value)?.data; + +const row = (...components: MessageActionRowComponentBuilder[]) => + new ActionRowBuilder().addComponents( + ...components, + ); + // A field row: the field name with a disabled button showing the chosen option // (label and emoji), or "N/A" until it is answered. function fieldSection( @@ -81,7 +89,7 @@ function fieldSection( menu: StringSelectMenuBuilder, value?: string, ) { - const option = menu.options.find((o) => o.data.value === value)?.data; + const option = optionOf(menu, value); const button = new ButtonBuilder() .setStyle(ButtonStyle.Secondary) @@ -131,15 +139,13 @@ async function buildMessage( const step = steps[values.length]; if (step) { - const product = productSelector.options.find( - (o) => o.data.value === values[1], - )?.data.label; + const product = optionOf(productSelector, values[1])?.label ?? "N/A"; components.push( new ContainerBuilder() .setAccentColor(Colors.Blurple) - .addTextDisplayComponents(text(step.prompt(product ?? "N/A"))), - new ActionRowBuilder().addComponents( + .addTextDisplayComponents(text(step.prompt(product))), + row( StringSelectMenuBuilder.from(step.menu).setCustomId( [CUSTOM_ID, ...values].join(":"), ), @@ -150,8 +156,8 @@ async function buildMessage( const docs = productResources[values[1]] ?? []; if (docs.length > 0) { components.push( - new ActionRowBuilder().addComponents( - docs.map((doc) => + row( + ...docs.map((doc) => new ButtonBuilder() .setStyle(ButtonStyle.Link) .setLabel(doc.label)