@@ -4,22 +4,27 @@ import { isHelpPost as isHelpThread } from "@lib/discord/channels.js";
44import { getCommandMention } from "@lib/discord/commands.js" ;
55import issueCategorySelector from "@components/issueCategorySelector.js" ;
66import productSelector from "@components/productSelector.js" ;
7+ import operatingSystemFamilySelector from "@components/operatingSystemFamilySelector.js" ;
78
89import {
910 ActionRowBuilder ,
1011 ButtonBuilder ,
1112 ButtonStyle ,
13+ type ButtonInteraction ,
1214 type ChatInputCommandInteraction ,
1315 type Client ,
1416 Colors ,
15- type Embed ,
16- EmbedBuilder ,
17+ ContainerBuilder ,
1718 type GuildTextBasedChannel ,
1819 type MessageActionRowComponentBuilder ,
1920 MessageFlags ,
2021 type PublicThreadChannel ,
22+ SectionBuilder ,
23+ SeparatorBuilder ,
2124 SlashCommandBuilder ,
22- type StringSelectMenuBuilder ,
25+ StringSelectMenuBuilder ,
26+ type StringSelectMenuInteraction ,
27+ TextDisplayBuilder ,
2328} from "discord.js" ;
2429
2530type ResourceLink = { label : string ; url : string } ;
@@ -43,128 +48,210 @@ const productResources: Record<string, ResourceLink[]> = {
4348 ] ,
4449} ;
4550
46- // Resolves the resources for a product from the label shown in the data embed.
47- function resourcesForProduct ( productLabel : string ) : ResourceLink [ ] {
48- const option = productSelector . options . find (
49- ( o ) => o . data . label === productLabel ,
51+ // The walkthrough asks one selector per field, in this order.
52+ const steps = [
53+ {
54+ field : "Category" ,
55+ menu : issueCategorySelector ,
56+ prompt : ( ) => "What are you creating this issue for?" ,
57+ } ,
58+ {
59+ field : "Product" ,
60+ menu : productSelector ,
61+ prompt : ( ) => "What product are you using?" ,
62+ } ,
63+ {
64+ field : "Platform" ,
65+ menu : operatingSystemFamilySelector ,
66+ prompt : ( product : string ) =>
67+ `What operating system are you running ${ product } on?` ,
68+ } ,
69+ ] as const ;
70+
71+ // Answered values are carried between steps in the selector's custom id, so the
72+ // walkthrough never has to read its state back out of the message.
73+ const CUSTOM_ID = "walkthrough" ;
74+
75+ const text = ( content : string ) => new TextDisplayBuilder ( { content } ) ;
76+
77+ const optionOf = ( menu : StringSelectMenuBuilder , value ?: string ) =>
78+ menu . options . find ( ( o ) => o . data . value === value ) ?. data ;
79+
80+ const row = ( ...components : MessageActionRowComponentBuilder [ ] ) =>
81+ new ActionRowBuilder < MessageActionRowComponentBuilder > ( ) . addComponents (
82+ ...components ,
5083 ) ;
51- return ( option && productResources [ option . data . value ?? "" ] ) || [ ] ;
52- }
5384
54- // The data embed tracks the walkthrough answers. Its fields line up with the
55- // walkthrough selectors (Category, Product, Platform) so each step fills the
56- // matching field in place.
57- export function buildDataEmbed ( channelId : string ) {
58- return new EmbedBuilder ( ) . setTitle ( `<#${ channelId } >` ) . addFields ( [
59- { name : "Category" , value : "N/A" , inline : true } ,
60- { name : "Product" , value : "N/A" , inline : true } ,
61- { name : "Platform" , value : "N/A" , inline : true } ,
62- { name : "Logs" , value : "Please post any relevant logs/error messages." } ,
63- ] ) ;
85+ // A field row: the field name with a disabled button showing the chosen option
86+ // (label and emoji), or "N/A" until it is answered.
87+ function fieldSection (
88+ field : string ,
89+ menu : StringSelectMenuBuilder ,
90+ value ?: string ,
91+ ) {
92+ const option = optionOf ( menu , value ) ;
93+
94+ const button = new ButtonBuilder ( )
95+ . setStyle ( ButtonStyle . Secondary )
96+ . setCustomId ( `${ CUSTOM_ID } :field:${ field } ` )
97+ . setDisabled ( ! option )
98+ . setLabel ( option ?. label ?? "N/A" ) ;
99+
100+ if ( option ?. emoji ) {
101+ button . setEmoji ( option . emoji ) ;
102+ }
103+
104+ return new SectionBuilder ( )
105+ . addTextDisplayComponents ( text ( field ) )
106+ . setButtonAccessory ( button ) ;
64107}
65108
66- // The resources embed points users at the post lifecycle commands. It stays the
67- // same for the whole walkthrough.
68- export async function buildResourcesEmbed ( client : Client ) {
69- return new EmbedBuilder ( )
70- . setColor ( Colors . White )
71- . setDescription (
72- `When your issue is resolved, use ${ await getCommandMention ( client , "close" ) } to close this issue. Use ${ await getCommandMention ( client , "reopen" ) } to reopen it if needed.` ,
73- ) ;
109+ async function lifecycleText ( client : Client ) {
110+ const close = await getCommandMention ( client , "close" ) ;
111+ const reopen = await getCommandMention ( client , "reopen" ) ;
112+ return `When your issue is resolved, use ${ close } to close it.\nUse ${ reopen } to reopen it if needed.` ;
74113}
75114
76- // Assembles the single walkthrough message from its current state: the data and
77- // resources embeds , the current question and selector ( while the walkthrough is
78- // running) , and a documentation button per resource of the selected product .
79- export function buildWalkthroughMessage (
80- dataEmbed : EmbedBuilder ,
81- resourcesEmbed : EmbedBuilder | Embed ,
82- step ?: { question : string ; selector : StringSelectMenuBuilder } ,
115+ // Builds the walkthrough message from the answered values so far: an info
116+ // container with a field row per answer , the current question and selector while
117+ // steps remain , and the selected product's documentation buttons at the bottom .
118+ async function buildMessage (
119+ client : Client ,
120+ channelId : string ,
121+ values : string [ ] ,
83122) {
84- const embeds : ( EmbedBuilder | Embed ) [ ] = [ dataEmbed , resourcesEmbed ] ;
85- const components : ActionRowBuilder < MessageActionRowComponentBuilder > [ ] = [ ] ;
123+ const info = new ContainerBuilder ( )
124+ . setAccentColor ( Colors . Blurple )
125+ . addTextDisplayComponents ( text ( `<#${ channelId } >` ) )
126+ . addSeparatorComponents ( new SeparatorBuilder ( ) )
127+ . addSectionComponents (
128+ fieldSection ( "Category" , issueCategorySelector , values [ 0 ] ) ,
129+ fieldSection ( "Product" , productSelector , values [ 1 ] ) ,
130+ fieldSection ( "Platform" , operatingSystemFamilySelector , values [ 2 ] ) ,
131+ )
132+ . addSeparatorComponents ( new SeparatorBuilder ( ) )
133+ . addTextDisplayComponents ( text ( await lifecycleText ( client ) ) ) ;
134+
135+ const components : (
136+ | ContainerBuilder
137+ | ActionRowBuilder < MessageActionRowComponentBuilder >
138+ ) [ ] = [ info ] ;
86139
140+ const step = steps [ values . length ] ;
87141 if ( step ) {
88- embeds . push (
89- new EmbedBuilder ( ) . setColor ( Colors . White ) . setDescription ( step . question ) ,
90- ) ;
142+ const product = optionOf ( productSelector , values [ 1 ] ) ?. label ?? "N/A" ;
143+
91144 components . push (
92- new ActionRowBuilder < MessageActionRowComponentBuilder > ( ) . addComponents (
93- step . selector ,
145+ new ContainerBuilder ( )
146+ . setAccentColor ( Colors . Blurple )
147+ . addTextDisplayComponents ( text ( step . prompt ( product ) ) ) ,
148+ row (
149+ StringSelectMenuBuilder . from ( step . menu ) . setCustomId (
150+ [ CUSTOM_ID , ...values ] . join ( ":" ) ,
151+ ) ,
94152 ) ,
95153 ) ;
96154 }
97155
98- const resources = resourcesForProduct (
99- dataEmbed . data . fields ?. [ 1 ] ?. value ?? "" ,
100- ) ;
101- if ( resources . length > 0 ) {
156+ const docs = productResources [ values [ 1 ] ] ?? [ ] ;
157+ if ( docs . length > 0 ) {
102158 components . push (
103- new ActionRowBuilder < MessageActionRowComponentBuilder > ( ) . addComponents (
104- resources . map ( ( resource ) =>
159+ row (
160+ ... docs . map ( ( doc ) =>
105161 new ButtonBuilder ( )
106162 . setStyle ( ButtonStyle . Link )
107- . setLabel ( resource . label )
108- . setURL ( resource . url ) ,
163+ . setLabel ( doc . label )
164+ . setURL ( doc . url ) ,
109165 ) ,
110166 ) ,
111167 ) ;
112168 }
113169
114- return { embeds , components } ;
170+ return { flags : MessageFlags . IsComponentsV2 as const , components } ;
115171}
116172
117173export async function doWalkthrough (
118174 channel : GuildTextBasedChannel ,
119175 interaction ?: ChatInputCommandInteraction ,
120176) {
121- if ( await isHelpThread ( channel ) ) {
122- const threadChannel = channel as PublicThreadChannel ; // necessary type cast, isHelpThread does the check already
123-
124- // Check for tags in the forum post
125- const appliedTags = threadChannel . appliedTags ?? [ ] ;
126- if ( ! appliedTags . includes ( config . helpChannel . openedTag ) ) {
127- appliedTags . push ( config . helpChannel . openedTag ) ;
128- threadChannel . setAppliedTags ( appliedTags ) ;
129- }
130-
131- const walkthroughMessage = buildWalkthroughMessage (
132- buildDataEmbed ( channel . id ) ,
133- await buildResourcesEmbed ( channel . client ) ,
134- {
135- question : "What are you creating this issue for?" ,
136- selector : issueCategorySelector ,
137- } ,
177+ if ( ! ( await isHelpThread ( channel ) ) ) {
178+ return ;
179+ }
180+
181+ const threadChannel = channel as PublicThreadChannel ; // necessary type cast, isHelpThread does the check already
182+
183+ // Check for tags in the forum post
184+ const appliedTags = threadChannel . appliedTags ?? [ ] ;
185+ if ( ! appliedTags . includes ( config . helpChannel . openedTag ) ) {
186+ appliedTags . push ( config . helpChannel . openedTag ) ;
187+ threadChannel . setAppliedTags ( appliedTags ) ;
188+ }
189+
190+ const walkthroughMessage = await buildMessage ( channel . client , channel . id , [ ] ) ;
191+
192+ // Slash-command runs reply to the user; auto-runs post to the thread.
193+ if ( ! interaction ) {
194+ await channel . send ( walkthroughMessage ) ;
195+ return ;
196+ }
197+
198+ // If the bot already posted a walkthrough (a message with components) near the
199+ // start of the thread, don't post another one.
200+ const firstMessage = await threadChannel . fetchStarterMessage ( ) ;
201+ const existing = await threadChannel . messages
202+ . fetch ( { around : firstMessage . id , limit : 30 } )
203+ . then ( ( messages ) =>
204+ messages
205+ . filter (
206+ ( message ) =>
207+ message . author . id === interaction . client . user . id &&
208+ message . components . length > 0 ,
209+ )
210+ . at ( 0 ) ,
138211 ) ;
139212
140- // Send the walkthrough message (or reply to the user if they're running the command)
141- if ( interaction ) {
142- // If the bot has sent a message that contains an embed in the first 30 messages, then we assume it's the walkthrough message
143- const firstMessage = await threadChannel . fetchStarterMessage ( ) ;
144- const existingWalkthrough = await threadChannel . messages
145- . fetch ( { around : firstMessage . id , limit : 30 } )
146- . then ( ( messages ) =>
147- messages
148- . filter (
149- ( message ) =>
150- message . author . id === interaction . client . user . id &&
151- message . embeds . length > 0 ,
152- )
153- . at ( 0 ) ,
154- ) ;
155-
156- if ( existingWalkthrough ) {
157- await interaction . reply ( {
158- content : `You cannot run the walkthrough command because a walkthrough already exists in this channel.\n(${ existingWalkthrough . url } )` ,
159- flags : MessageFlags . Ephemeral ,
160- } ) ;
161- return ;
162- }
163-
164- await interaction . reply ( walkthroughMessage ) ;
165- } else {
166- await channel . send ( walkthroughMessage ) ;
167- }
213+ if ( existing ) {
214+ await interaction . reply ( {
215+ content : `You cannot run the walkthrough command because a walkthrough already exists in this channel.\n(${ existing . url } )` ,
216+ flags : MessageFlags . Ephemeral ,
217+ } ) ;
218+ return ;
219+ }
220+
221+ await interaction . reply ( walkthroughMessage ) ;
222+ }
223+
224+ // Advances the walkthrough one step by editing the same message with the newly
225+ // answered value appended.
226+ export async function handleSelection (
227+ interaction : StringSelectMenuInteraction ,
228+ ) {
229+ if ( ! interaction . customId . startsWith ( CUSTOM_ID ) ) {
230+ return ;
231+ }
232+
233+ const values = [
234+ ...interaction . customId . split ( ":" ) . slice ( 1 ) ,
235+ interaction . values [ 0 ] ,
236+ ] ;
237+
238+ await interaction . update (
239+ await buildMessage ( interaction . client , interaction . channelId , values ) ,
240+ ) ;
241+
242+ if ( values . length === steps . length ) {
243+ await interaction . message . pin ( ) ;
244+ }
245+ }
246+
247+ // The answer buttons only summarize the walkthrough answers, so a click just
248+ // tells the user they can't be edited.
249+ export async function handleFieldButton ( interaction : ButtonInteraction ) {
250+ if ( interaction . customId . startsWith ( `${ CUSTOM_ID } :field:` ) ) {
251+ await interaction . reply ( {
252+ content : "This is just a summary of your answers, you can't edit it." ,
253+ flags : MessageFlags . Ephemeral ,
254+ } ) ;
168255 }
169256}
170257
0 commit comments