@@ -2,21 +2,18 @@ import Command from '../Command.js';
22import GuildSettings from '../../settings/GuildSettings.js' ;
33import {
44 ActionRowBuilder ,
5- bold ,
65 ButtonBuilder ,
76 ButtonStyle ,
8- codeBlock ,
9- EmbedBuilder ,
10- escapeBold ,
7+ MessageFlags ,
118 StringSelectMenuBuilder ,
12- userMention ,
13- hyperlink
9+ userMention
1410} from 'discord.js' ;
15- import Turndown from 'turndown' ;
1611import icons from '../../util/icons.js' ;
1712import { SELECT_MENU_OPTIONS_LIMIT , SELECT_MENU_TITLE_LIMIT } from '../../util/apiLimits.js' ;
1813import Cache from '../../bot/Cache.js' ;
1914import ErrorEmbed from '../../formatting/embeds/ErrorEmbed.js' ;
15+ import MessageBuilder from '../../formatting/MessageBuilder.js' ;
16+ import MarkdownConverter from '../../formatting/markdown/MarkdownConverter.js' ;
2017
2118/**
2219 * @import {ZendeskArticle} from '../../apis/Zendesk.js';
@@ -25,9 +22,10 @@ import ErrorEmbed from '../../formatting/embeds/ErrorEmbed.js';
2522
2623const completions = new Cache ( ) ;
2724const CACHE_DURATION = 60 * 60 * 1000 ;
28- const ARTICLE_EMBED_PREVIEW_LENGTH = 1000 ;
25+ const ARTICLE_EMBED_PREVIEW_LENGTH = 1300 ;
2926
3027export default class ArticleCommand extends Command {
28+ #markdownConverter = new MarkdownConverter ( ) ;
3129
3230 getName ( ) {
3331 return 'article' ;
@@ -91,15 +89,42 @@ export default class ArticleCommand extends Command {
9189 return ;
9290 }
9391
92+ // TODO: Update this
9493 const selectMenu = /** @type {import('discord.js').SelectMenuComponent } */
95- interaction . message . components [ 0 ] . components [ 0 ] ;
94+ this . findComponentWithCustomIdPrefix ( interaction . message . components , 'article:' ) ;
9695 const index = selectMenu . options
9796 . findIndex ( option => option . value === interaction . values [ 0 ] ) ;
9897 const article = await ( await GuildSettings . get ( interaction . guildId ) ) . getZendesk ( )
9998 . getArticle ( selectMenu . options [ index ] . value ) ;
10099 await interaction . update ( this . generateMessage ( selectMenu . options , article , interaction . user . id , index , mentionedUser ) ) ;
101100 }
102101
102+ /**
103+ * Find a component with a specific custom id
104+ * @param {import('discord.js').AnyComponent } components
105+ * @param {string } customId
106+ * @returns {import('discord.js').AnyComponent|null }
107+ */
108+ findComponentWithCustomIdPrefix ( components , customId ) {
109+ for ( const component of components ) {
110+ if ( 'customId' in component && component . customId ?. startsWith ( customId ) ) {
111+ return component ;
112+ }
113+
114+ if ( 'components' in component ) {
115+ const found = this . findComponentWithCustomIdPrefix ( component . components , customId ) ;
116+ if ( found ) {
117+ return found ;
118+ }
119+ }
120+
121+ if ( 'component' in component && component . component ?. customId ?. startsWith ( customId ) ) {
122+ return component . component ;
123+ }
124+ }
125+ return null ;
126+ }
127+
103128 async complete ( interaction ) {
104129 const zendesk = ( await GuildSettings . get ( interaction . guild . id ) ) . getZendesk ( ) ;
105130 if ( ! zendesk ) {
@@ -138,143 +163,56 @@ export default class ArticleCommand extends Command {
138163 }
139164 results [ index ] . default = true ;
140165
141- /** @type {import('discord.js').InteractionReplyOptions } */
142- const message = {
143- embeds : [ this . createEmbed ( results [ index ] , article . body ) ] ,
144- components : [
145- new ActionRowBuilder ( )
146- . addComponents (
147- // eslint-disable-next-line jsdoc/reject-any-type
148- /** @type {any } */ new StringSelectMenuBuilder ( )
149- // eslint-disable-next-line jsdoc/reject-any-type
150- . setOptions ( /** @type {any } */ results )
151- . setCustomId ( `article:${ userId } ` + ( mention ? `:${ mention } ` : '' ) )
152- )
153- . toJSON ( ) ,
154- new ActionRowBuilder ( )
155- . addComponents (
156- // eslint-disable-next-line jsdoc/reject-any-type
157- /** @type {any } */ new ButtonBuilder ( )
158- . setStyle ( ButtonStyle . Link )
159- . setURL ( article . html_url )
160- . setLabel ( 'View Article' )
161- )
162- . toJSON ( ) ,
163- ]
164- } ;
166+ const message = new MessageBuilder ( ) ;
165167
166168 if ( mention ) {
167169 message . content = `${ userMention ( mention ) } this article from our help center might help you:` ;
168170 }
169171
170- return message ;
172+ const container = this . appendContent ( message , results [ index ] , article . body )
173+ . endComponent ( )
174+ . addSeparatorComponents ( )
175+ . addActionRowComponents (
176+ new ActionRowBuilder ( ) . addComponents (
177+ new StringSelectMenuBuilder ( )
178+ . setOptions ( results )
179+ . setCustomId ( `article:${ userId } ` + ( mention ? `:${ mention } ` : '' ) )
180+ ) ,
181+ new ActionRowBuilder ( ) . addComponents (
182+ new ButtonBuilder ( )
183+ . setStyle ( ButtonStyle . Link )
184+ . setURL ( article . html_url )
185+ . setLabel ( 'View Article' )
186+ ) ,
187+ )
188+ . toJSON ( ) ;
189+
190+ return {
191+ components : [ container ] ,
192+ flags : MessageFlags . IsComponentsV2 ,
193+ } ;
171194 }
172195
173196 /**
174197 * get a description from the HTML body of an article
198+ * @param {MessageBuilder } message
175199 * @param {import('discord.js').APISelectMenuOption } result
176200 * @param {string } body website body
177- * @returns {EmbedBuilder }
201+ * @returns {MessageBuilder }
178202 */
179- createEmbed ( result , body ) {
180- const embed = new EmbedBuilder ( )
181- . setTitle ( result . label ) ;
182-
183- //set up turndown
184- const turndown = new Turndown ( {
185- bulletListMarker : '-' ,
186- hr : '' ,
187- } )
188- //convert headings to bold
189- . addRule ( 'headings' , {
190- filter : [ 'h1' , 'h2' , 'h3' , 'h4' , 'h5' , 'h6' ] ,
191- replacement ( content , node ) {
192- if ( ! content ) {
193- return '' ;
194- }
195-
196- // Check if the heading is inside a list
197- let parent = node . parentNode ;
198- while ( parent ) {
199- if ( parent . nodeName === 'UL' || parent . nodeName === 'OL' ) {
200- node . localName = "b" ;
201- break ;
202- }
203- parent = parent . parentNode ;
204- }
205-
206- switch ( node . localName ) {
207- case 'h1' :
208- return '\n# ' + content + '\n' ;
209- case 'h2' :
210- return '\n## ' + content + '\n' ;
211- case 'h3' :
212- return '\n### ' + content + '\n' ;
213- default :
214- return '\n' + bold ( escapeBold ( content ) ) + '\n' ;
215- }
216- }
217- } )
218- //ignore pre tags
219- . addRule ( 'codeblocks' , {
220- filter : [ 'pre' ] ,
221- replacement ( content ) {
222- return codeBlock ( content
223- . replace ( / (?< ! \\ ) [ * _ ~ ` ] + / g, '' ) // remove unescaped markdown
224- . replace ( / \\ ( [ * _ ~ ` > [ \] ] ) / g, '$1' ) ) ; // unescape escaped markdown
225- }
226- } )
227- //remove unsupported tags
228- . addRule ( 'remove' , {
229- filter : [ 'img' , 'script' , 'youtube-video' , 'minecraft-edition' , 'highlight-box' ] ,
230- replacement ( ) {
231- return '' ;
232- }
233- } )
234- . addRule ( 'iframes' , {
235- filter : [ 'iframe' ] ,
236- replacement ( content , node ) {
237- const url = node . _attrsByQName . src . data ;
238- const result = url . match ( / ^ \/ \/ (?: w w w \. ) ? y o u t u b e (?: - n o c o o k i e ) ? \. c o m \/ e m b e d \/ ( .* ) / ) ;
239- if ( result ) {
240- return 'https://youtu.be/' + result [ 1 ] ;
241- } else {
242- return '' ;
243- }
244- }
245- } )
246- . addRule ( 'links' , {
247- filter : [ 'a' ] ,
248- replacement ( content , node ) {
249- const href = node . _attrsByQName . href . data ;
250- if ( href === content ) {
251- return href ;
252- }
253-
254- if ( content . includes ( 'https://' ) || content . includes ( 'http://' ) ) {
255- return href ;
256- }
257-
258- if ( ! / ^ h t t p s ? : \/ \/ / . test ( href ) ) {
259- // Remove non-http links and local links
260- return content ;
261- }
262-
263- return hyperlink ( content , href ) ;
264- }
265- } ) ;
266- //convert string
267- let string = turndown . turndown ( body )
268- . replaceAll ( / \n \n + / g, '\n' ) ;
203+ appendContent ( message , result , body ) {
204+ message . heading ( result . label ) ;
205+
206+ let string = this . #markdownConverter. generate ( body ) ;
269207 if ( string . length > ARTICLE_EMBED_PREVIEW_LENGTH ) {
270208 string = string . substring ( 0 , ARTICLE_EMBED_PREVIEW_LENGTH ) ;
271- string = string . replace ( / \. ? \n + .* $ / , '' ) ;
272- embed . setFooter ( {
273- text : 'To read more, click \'View Article\' below.' ,
274- } ) ;
209+ message . text ( string . replace ( / \. ? \n + .* $ / , '' ) )
210+ . newLine ( )
211+ . subtext ( 'To read more, click \'View Article\' below.' ) ;
212+ } else {
213+ message . text ( string ) ;
275214 }
276215
277- embed . setDescription ( string ) ;
278- return embed ;
216+ return message ;
279217 }
280218}
0 commit comments