Skip to content

Commit 1bb5dc0

Browse files
authored
Merge pull request #17 from BenjammingKirby/master
Add ephemeral selectors for multiple matching results
2 parents f485124 + 0cebae3 commit 1bb5dc0

File tree

9 files changed

+514
-316
lines changed

9 files changed

+514
-316
lines changed

.eslintrc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"rules": {
1919
"indent": [
2020
"error",
21-
4
21+
4, { "SwitchCase": 1 }
2222
],
2323
"linebreak-style": [
2424
"error",

dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM node:17-alpine3.12
1+
FROM node:17-alpine3.14
22
RUN apk add --update git
33
WORKDIR /usr/app
44
COPY package*.json ./

package-lock.json

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

package.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@
1818
"@discordjs/builders": "^0.11.0",
1919
"@discordjs/rest": "^0.2.0-canary.0",
2020
"discord-api-types": "^0.26.1",
21-
"discord.js": "^13.5.1",
21+
"discord.js": "^13.6.0",
2222
"discord.js-docs": "github:BenjammingKirby/discord.js-docs#typescriptRewrite",
23-
"dotenv": "^10.0.0",
24-
"fast-xml-parser": "^4.0.0-beta.7",
23+
"dotenv": "^12.0.3",
24+
"fast-xml-parser": "^4.0.1",
2525
"flexsearch": "^0.7.21",
2626
"glob": "^7.2.0",
2727
"node-fetch": "^2.6.6",
@@ -30,14 +30,14 @@
3030
"devDependencies": {
3131
"@types/flexsearch": "^0.7.2",
3232
"@types/glob": "^7.2.0",
33-
"@types/node": "^16.11.11",
33+
"@types/node": "^17.0.8",
3434
"@types/node-fetch": "^2.5.12",
35-
"@typescript-eslint/eslint-plugin": "^5.5.0",
36-
"@typescript-eslint/parser": "^5.5.0",
35+
"@typescript-eslint/eslint-plugin": "^5.9.1",
36+
"@typescript-eslint/parser": "^5.9.1",
3737
"cross-env": "^7.0.3",
38-
"eslint": "^8.3.0",
39-
"prettier": "^2.5.0",
38+
"eslint": "^8.6.0",
39+
"prettier": "^2.5.1",
4040
"ts-node-dev": "^1.1.8",
4141
"typescript": "^4.5.4"
4242
}
43-
}
43+
}

src/bot.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import "dotenv/config";
22
import { Client, Collection, LimitedCollection } from "discord.js";
33
import { MyContext } from "./interfaces";
4-
import { loadCommands, commandHandler } from "./handlers/CommandHandler";
4+
import { loadCommands, interactionCreateHandler } from "./handlers/CommandHandler";
55
import { messageHandler } from "./handlers/MessageHandler";
66

77
(async function () {
@@ -42,7 +42,7 @@ import { messageHandler } from "./handlers/MessageHandler";
4242
});
4343

4444
docsBot.on("messageCreate", messageHandler);
45-
docsBot.on("interactionCreate", commandHandler.bind(null, context));
45+
docsBot.on("interactionCreate", interactionCreateHandler.bind(null, context));
4646

4747
docsBot.login(process.env.TOKEN);
4848
})();

src/commands/docs/djs.ts

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { Command } from "../../interfaces";
22
import { SlashCommandBuilder } from "@discordjs/builders";
33
import Doc, { sources } from "discord.js-docs";
44
import { checkEmbedLimits } from "../../utils/EmbedUtils";
5+
import { deleteButton } from "../../utils/CommandUtils";
6+
import { MessageActionRow, MessageSelectMenu } from "discord.js";
7+
import type { APIEmbed } from "discord-api-types";
58

69
const supportedBranches = Object.keys(sources).map((branch) => [capitalize(branch), branch] as [string, string]);
710

@@ -29,6 +32,7 @@ const command: Command = {
2932
.setRequired(false),
3033
),
3134
async execute(interaction) {
35+
const deleteButtonRow = new MessageActionRow().addComponents([deleteButton(interaction.user.id)]);
3236
const query = interaction.options.getString("query");
3337
// The Default source should be stable
3438
const source: keyof typeof sources =
@@ -37,20 +41,38 @@ const command: Command = {
3741
const searchPrivate = interaction.options.getBoolean("private") || false;
3842
const doc = await Doc.fetch(source, { force: true });
3943

40-
const resultEmbed = doc.resolveEmbed(query, { excludePrivateElements: !searchPrivate });
41-
42-
const notFoundEmbed = doc.baseEmbed();
43-
notFoundEmbed.description = "Didn't find any results for that query";
44+
// const resultEmbed = doc.resolveEmbed(query, { excludePrivateElements: !searchPrivate });
45+
const result = searchDJSDoc(doc, query, searchPrivate);
4446
// If a result wasn't found
45-
if (!resultEmbed || resultEmbed.description === "") {
47+
if (!result || (result as APIEmbed).description === "") {
48+
const notFoundEmbed = doc.baseEmbed();
49+
notFoundEmbed.description = "Didn't find any results for that query";
50+
4651
const timeStampDate = new Date(notFoundEmbed.timestamp);
4752
// Satisfies the method's MessageEmbedOption type
4853
const embedObj = { ...notFoundEmbed, timestamp: timeStampDate };
4954

50-
interaction.editReply({ embeds: [embedObj] }).catch(console.error);
55+
await interaction.reply({ embeds: [embedObj], ephemeral: true }).catch(console.error);
5156
return;
52-
}
57+
} else if (Array.isArray(result)) {
58+
// If there are multiple results, send a select menu from which the user can choose which one to send
5359

60+
const selectMenuRow = new MessageActionRow().addComponents(
61+
new MessageSelectMenu()
62+
.setCustomId(`djsselect/${source}/${searchPrivate}/${interaction.user.id}`)
63+
.addOptions(result)
64+
.setPlaceholder("Select documentation to send"),
65+
);
66+
await interaction
67+
.reply({
68+
content: "Didn't find an exact match, please select one from below",
69+
ephemeral: true,
70+
components: [selectMenuRow],
71+
})
72+
.catch(console.error);
73+
return;
74+
}
75+
const resultEmbed = result;
5476
const timeStampDate = new Date(resultEmbed.timestamp);
5577
const embedObj = { ...resultEmbed, timestamp: timeStampDate };
5678

@@ -60,7 +82,7 @@ const command: Command = {
6082
// The final field should be the View Source button
6183
embedObj.fields = [embedObj.fields?.at(-1)];
6284
}
63-
interaction.editReply({ embeds: [embedObj] }).catch(console.error);
85+
await interaction.reply({ embeds: [embedObj], components: [deleteButtonRow] }).catch(console.error);
6486
return;
6587
},
6688
};
@@ -72,4 +94,51 @@ function capitalize(str: string) {
7294
.join("-");
7395
}
7496

97+
// Export to reuse on the select menu handler
98+
export function searchDJSDoc(doc: Doc, query: string, searchPrivate?: boolean) {
99+
const options = { excludePrivateElements: !searchPrivate };
100+
101+
const singleElement = doc.get(...query.split(/\.|#/));
102+
// Return embed for the single element, the exact match
103+
if (singleElement) return singleElement.embed(options);
104+
105+
const searchResults = doc.search(query, options);
106+
if (!searchResults) return null;
107+
return searchResults.map((res) => {
108+
// Labels and values have a limit of 100 characters
109+
const description = res.description.length >= 99 ? res.description.slice(0, 96) + "..." : res.description;
110+
return {
111+
label: res.formattedName,
112+
description,
113+
emoji: resolveRegionalEmoji(res.embedPrefix),
114+
value: res.formattedName,
115+
};
116+
});
117+
}
118+
/**
119+
* Return the unicode version of the regional emojis
120+
* @param regionalEmoji
121+
* @returns The unicode version of the emoji
122+
*/
123+
function resolveRegionalEmoji(regionalEmoji: string) {
124+
const character = regionalEmoji.match(/:regional_indicator_(.):/)?.[1];
125+
if (!character) return null;
126+
switch (character) {
127+
case "c":
128+
return "🇨";
129+
case "e":
130+
return "🇪";
131+
case "i":
132+
return "🇮";
133+
case "m":
134+
return "🇲";
135+
case "t":
136+
return "🇹";
137+
case "p":
138+
return "🇵";
139+
default:
140+
return null;
141+
}
142+
}
143+
75144
export default command;

src/commands/docs/mdn.ts

Lines changed: 59 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { SlashCommandBuilder } from "@discordjs/builders";
2-
import { MessageEmbed } from "discord.js";
2+
import { deleteButton } from "../../utils/CommandUtils";
3+
import { MessageActionRow, MessageEmbed, MessageSelectMenu } from "discord.js";
34
import { gunzipSync } from "zlib";
45
import { XMLParser } from "fast-xml-parser";
56
import { Command } from "../../interfaces";
@@ -20,7 +21,7 @@ let sources = {
2021

2122
const MDN_BASE_URL = "https://developer.mozilla.org/en-US/docs/" as const;
2223
const MDN_ICON_URL = "https://i.imgur.com/1P4wotC.png" as const;
23-
const MDN_BLUE_COLOR = 0x83BFFF as const;
24+
const MDN_BLUE_COLOR = 0x83bfff as const;
2425

2526
const command: Command = {
2627
data: new SlashCommandBuilder()
@@ -33,52 +34,81 @@ const command: Command = {
3334
.setRequired(true),
3435
),
3536
async execute(interaction) {
37+
const deleteButtonRow = new MessageActionRow().addComponents([deleteButton(interaction.user.id)]);
3638
const query = interaction.options.getString("query");
3739
const { index, sitemap } = await getSources();
3840
const search: string[] = index.search(query, { limit: 10 }).map((id) => sitemap[<number>id].loc);
3941
const embed = new MessageEmbed()
4042
.setColor(MDN_BLUE_COLOR)
41-
.setAuthor("MDN Documentation", MDN_ICON_URL)
42-
.setTitle(`Search for: ${query}`);
43+
.setAuthor({ name: "MDN Documentation", iconURL: MDN_ICON_URL })
44+
.setTitle(`Search for: ${query.slice(0, 243)}`);
4345

4446
if (!search.length) {
45-
embed.setColor(0xFF0000).setDescription("No results found...");
46-
interaction.editReply({ embeds: [embed] });
47+
embed.setColor(0xff0000).setDescription("No results found...");
48+
await interaction.reply({ embeds: [embed], ephemeral: true });
4749
return;
48-
}
50+
} else if (search.length === 1) {
51+
const resultEmbed = await getSingleMDNSearchResults(search[0]);
52+
await interaction
53+
.reply({
54+
embeds: [resultEmbed],
55+
components: [deleteButtonRow],
56+
})
57+
.catch(console.error);
4958

50-
if (search.length === 1) {
51-
const res = await fetch(`${MDN_BASE_URL + search[0]}/index.json`);
52-
const doc: MdnDoc = (await res.json()).doc;
53-
const docEmbed = embed
54-
.setColor(0xFFFFFF)
55-
.setTitle(doc.pageTitle)
56-
.setURL(`https://developer.mozilla.org/${doc.mdn_url}`)
57-
.setThumbnail(this.MDN_ICON_URL)
58-
.setDescription(doc.summary);
59-
interaction.editReply({ embeds: [docEmbed] });
6059
return;
61-
}
60+
} else {
61+
// If there are multiple results, send a select menu from which the user can choose which one to send
62+
const results = search.map((path) => `**• [${path.replace(/_|-/g, " ")}](${MDN_BASE_URL}${path})**`);
6263

63-
const results = search.map((path) => `**• [${path.replace(/_|-/g, " ")}](${MDN_BASE_URL}${path})**`);
64-
embed.setDescription(results.join("\n"));
65-
interaction.editReply({ embeds: [embed] });
66-
return;
64+
embed.setDescription(results.join("\n"));
65+
const selectMenuRow = new MessageActionRow().addComponents(
66+
new MessageSelectMenu()
67+
.setCustomId("mdnselect/" + interaction.user.id)
68+
.addOptions(
69+
search.map((val) => {
70+
const parsed = val.length >= 99 ? val.split("/").at(-1) : val;
71+
return { label: parsed, value: parsed };
72+
}),
73+
)
74+
.setPlaceholder("Select documentation to send"),
75+
);
76+
await interaction
77+
.reply({
78+
content: "Didn't find an exact match, please select one from below",
79+
ephemeral: true,
80+
components: [selectMenuRow],
81+
})
82+
.catch(console.error);
83+
return;
84+
}
6785
},
6886
};
6987

88+
// Export to reuse on the select menu handler
89+
export async function getSingleMDNSearchResults(searchQuery: string) {
90+
const res = await fetch(`${MDN_BASE_URL + searchQuery}/index.json`);
91+
const doc: MdnDoc = (await res.json()).doc;
92+
93+
return new MessageEmbed()
94+
.setColor(MDN_BLUE_COLOR)
95+
.setAuthor({ name: "MDN Documentation", iconURL: MDN_ICON_URL })
96+
.setColor(0xffffff)
97+
.setTitle(doc.pageTitle)
98+
.setURL(`https://developer.mozilla.org/${doc.mdn_url}`)
99+
.setThumbnail(MDN_ICON_URL)
100+
.setDescription(doc.summary);
101+
}
70102
async function getSources(): Promise<typeof sources> {
71103
if (sources.lastUpdated && Date.now() - sources.lastUpdated < 43200000 /* 12 hours */) return sources;
72104

73105
const res = await fetch("https://developer.mozilla.org/sitemaps/en-us/sitemap.xml.gz");
74106
if (!res.ok) return sources; // Fallback to old sources if the new ones are not available for any reason
75-
76-
const sitemap: Sitemap<number> = new XMLParser()
77-
.parse(gunzipSync(await res.buffer()).toString())
78-
.urlset.url.map((entry: SitemapEntry<string>) => ({
79-
loc: entry.loc.slice(MDN_BASE_URL.length),
80-
lastmod: new Date(entry.lastmod).valueOf(),
81-
}));
107+
const something = new XMLParser().parse(gunzipSync(await res.buffer()).toString());
108+
const sitemap: Sitemap<number> = something.urlset.url.map((entry: SitemapEntry<string>) => ({
109+
loc: entry.loc.slice(MDN_BASE_URL.length),
110+
lastmod: new Date(entry.lastmod).valueOf(),
111+
}));
82112

83113
const index = new flexsearch.Index();
84114
sitemap.forEach((entry, idx) => index.add(idx, entry.loc));

0 commit comments

Comments
 (0)