diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index b7b4cb3..27f14cb 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -4,22 +4,27 @@ 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 ButtonInteraction, type ChatInputCommandInteraction, type Client, Colors, - type Embed, - EmbedBuilder, + ContainerBuilder, type GuildTextBasedChannel, type MessageActionRowComponentBuilder, MessageFlags, type PublicThreadChannel, + SectionBuilder, + SeparatorBuilder, SlashCommandBuilder, - type StringSelectMenuBuilder, + StringSelectMenuBuilder, + type StringSelectMenuInteraction, + TextDisplayBuilder, } from "discord.js"; type ResourceLink = { label: string; url: string }; @@ -43,128 +48,210 @@ const productResources: Record = { ], }; -// Resolves the resources for a product from the label shown in the data embed. -function resourcesForProduct(productLabel: string): ResourceLink[] { - const option = productSelector.options.find( - (o) => o.data.label === productLabel, +// 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: (product: string) => + `What operating system are you running ${product} on?`, + }, +] as const; + +// 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 optionOf = (menu: StringSelectMenuBuilder, value?: string) => + menu.options.find((o) => o.data.value === value)?.data; + +const row = (...components: MessageActionRowComponentBuilder[]) => + new ActionRowBuilder().addComponents( + ...components, ); - 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." }, - ]); +// 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 = optionOf(menu, value); + + const button = new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setCustomId(`${CUSTOM_ID}:field:${field}`) + .setDisabled(!option) + .setLabel(option?.label ?? "N/A"); + + if (option?.emoji) { + button.setEmoji(option.emoji); + } + + return new SectionBuilder() + .addTextDisplayComponents(text(field)) + .setButtonAccessory(button); } -// 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.`, - ); +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 it.\nUse ${reopen} to reopen it if needed.`; } -// 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 }, +// 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 embeds: (EmbedBuilder | Embed)[] = [dataEmbed, resourcesEmbed]; - const components: ActionRowBuilder[] = []; + const info = new ContainerBuilder() + .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) { - embeds.push( - new EmbedBuilder().setColor(Colors.White).setDescription(step.question), - ); + const product = optionOf(productSelector, values[1])?.label ?? "N/A"; + components.push( - new ActionRowBuilder().addComponents( - step.selector, + new ContainerBuilder() + .setAccentColor(Colors.Blurple) + .addTextDisplayComponents(text(step.prompt(product))), + row( + StringSelectMenuBuilder.from(step.menu).setCustomId( + [CUSTOM_ID, ...values].join(":"), + ), ), ); } - const resources = resourcesForProduct( - dataEmbed.data.fields?.[1]?.value ?? "", - ); - if (resources.length > 0) { + const docs = productResources[values[1]] ?? []; + if (docs.length > 0) { components.push( - new ActionRowBuilder().addComponents( - resources.map((resource) => + row( + ...docs.map((doc) => new ButtonBuilder() .setStyle(ButtonStyle.Link) - .setLabel(resource.label) - .setURL(resource.url), + .setLabel(doc.label) + .setURL(doc.url), ), ), ); } - return { embeds, 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); - } - - const walkthroughMessage = buildWalkthroughMessage( - buildDataEmbed(channel.id), - await buildResourcesEmbed(channel.client), - { - question: "What are you creating this issue for?", - selector: issueCategorySelector, - }, + if (!(await isHelpThread(channel))) { + return; + } + + 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); + } + + const walkthroughMessage = await buildMessage(channel.client, channel.id, []); + + // 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), ); - // 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 - 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.embeds.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; - } - - await interaction.reply(walkthroughMessage); - } else { - await channel.send(walkthroughMessage); - } + 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 with the newly +// answered value appended. +export async function handleSelection( + interaction: StringSelectMenuInteraction, +) { + if (!interaction.customId.startsWith(CUSTOM_ID)) { + return; + } + + const values = [ + ...interaction.customId.split(":").slice(1), + interaction.values[0], + ]; + + await interaction.update( + await buildMessage(interaction.client, interaction.channelId, values), + ); + + if (values.length === steps.length) { + await interaction.message.pin(); + } +} + +// 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.reply({ + content: "This is just a summary of your answers, you can't edit it.", + flags: MessageFlags.Ephemeral, + }); } } diff --git a/src/events/walkthrough.ts b/src/events/walkthrough.ts index 1e49167..41a3d31 100644 --- a/src/events/walkthrough.ts +++ b/src/events/walkthrough.ts @@ -1,71 +1,23 @@ import { - buildWalkthroughMessage, doWalkthrough, + handleFieldButton, + 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; the answer buttons + // are no-ops. client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isStringSelectMenu()) { - return; - } - - const selector = selectors.find( - (element) => element.data.custom_id === interaction.customId, - ); - if (!selector) { - return; + if (interaction.isStringSelectMenu()) { + return handleSelection(interaction); } - - 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.isButton()) { + return handleFieldButton(interaction); } }); } diff --git a/src/ui/components/issueCategorySelector.ts b/src/ui/components/issueCategorySelector.ts index 397b209..7f200db 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()