Skip to content

Commit 787c5b0

Browse files
committed
Migrate article command to componentsv2
1 parent 3a99947 commit 787c5b0

12 files changed

Lines changed: 259 additions & 133 deletions

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export default [
2222
languageOptions: {
2323
globals: {
2424
...globals.node,
25+
...globals.browser,
2526
},
2627

2728
ecmaVersion: 'latest',

package-lock.json

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
},
3838
"devDependencies": {
3939
"@types/node": "^22.5.2",
40+
"@types/turndown": "^5.0.6",
4041
"eslint": "^9.9.1",
4142
"eslint-plugin-jsdoc": "^61.1.4",
4243
"eslint-plugin-json": "^4.0.1",

src/commands/external/ArticleCommand.js

Lines changed: 69 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,18 @@ import Command from '../Command.js';
22
import GuildSettings from '../../settings/GuildSettings.js';
33
import {
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';
1611
import icons from '../../util/icons.js';
1712
import {SELECT_MENU_OPTIONS_LIMIT, SELECT_MENU_TITLE_LIMIT} from '../../util/apiLimits.js';
1813
import Cache from '../../bot/Cache.js';
1914
import 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

2623
const completions = new Cache();
2724
const CACHE_DURATION = 60 * 60 * 1000;
28-
const ARTICLE_EMBED_PREVIEW_LENGTH = 1000;
25+
const ARTICLE_EMBED_PREVIEW_LENGTH = 1300;
2926

3027
export 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(/^\/\/(?:www\.)?youtube(?:-nocookie)?\.com\/embed\/(.*)/);
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 (!/^https?:\/\//.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
}

src/formatting/MessageBuilder.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
ActionRowBuilder,
1212
TextDisplayBuilder,
1313
SeparatorBuilder,
14-
MediaGalleryBuilder,
14+
MediaGalleryBuilder, subtext,
1515
} from 'discord.js';
1616
import {inlineEmojiIfExists} from '../util/format.js';
1717

@@ -126,6 +126,15 @@ export default class MessageBuilder {
126126
return this;
127127
}
128128

129+
/**
130+
* Add subtext to the content.
131+
* @param {string} content
132+
* @returns {MessageBuilder}
133+
*/
134+
subtext(content) {
135+
return this.text(subtext(content));
136+
}
137+
129138
/**
130139
* Add items as a list
131140
* @param {string} items
@@ -176,7 +185,7 @@ export default class MessageBuilder {
176185
*/
177186
pair(key, value) {
178187
return this.bold(key)
179-
.text(":")
188+
.text(':')
180189
.space()
181190
.text(value)
182191
.newLine();
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import {codeBlock} from 'discord.js';
2+
import TurndownRule from './TurndownRule.js';
3+
4+
export default class CodeblockRule extends TurndownRule {
5+
filter = ['pre'];
6+
7+
replacement(content, node) {
8+
return codeBlock(this.unescapeMarkdown(this.removeMarkdown(content)));
9+
};
10+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {bold, escapeBold} from 'discord.js';
2+
import TurndownRule from './TurndownRule.js';
3+
4+
export default class HeadingRule extends TurndownRule {
5+
filter = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
6+
7+
replacement(content, node) {
8+
if (!content) {
9+
return '';
10+
}
11+
12+
// Check if the heading is inside a list
13+
let parent = node.parentNode;
14+
while (parent) {
15+
if (parent.nodeName === 'UL' || parent.nodeName === 'OL') {
16+
node.localName = 'b';
17+
break;
18+
}
19+
parent = parent.parentNode;
20+
}
21+
22+
switch (node.localName) {
23+
case 'h1':
24+
return '\n# ' + content + '\n';
25+
case 'h2':
26+
return '\n## ' + content + '\n';
27+
case 'h3':
28+
return '\n### ' + content + '\n';
29+
default:
30+
return '\n' + bold(escapeBold(content)) + '\n';
31+
}
32+
}
33+
}

0 commit comments

Comments
 (0)