Skip to content

Commit e77a96d

Browse files
committed
docs: booru command
1 parent da928f7 commit e77a96d

10 files changed

Lines changed: 646 additions & 1 deletion

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { ApplyOptions } from '@sapphire/decorators'
2+
import type { Command } from '@sapphire/framework'
3+
import { applyLocalizedBuilder, resolveKey } from '@sapphire/plugin-i18next'
4+
import type { Subcommand } from '@sapphire/plugin-subcommands'
5+
import { MessageFlags, type SlashCommandStringOption } from 'discord.js'
6+
import { TracedSubcommand } from '../../structures/subcommand.mjs'
7+
import {
8+
BOORU_QUERY_MAX_LENGTH,
9+
type BooruSiteName,
10+
booruSites,
11+
buildBooruPage,
12+
channelAllowsNsfw,
13+
isBooruSiteName,
14+
} from '../../utils/booru.mjs'
15+
16+
@ApplyOptions<Subcommand.Options>({
17+
cooldownDelay: 3_000,
18+
subcommands: [
19+
{ name: 'e621', chatInputRun: 'chatInputE621' },
20+
{ name: 'rule34', chatInputRun: 'chatInputRule34' },
21+
{ name: 'derpibooru', chatInputRun: 'chatInputDerpibooru' },
22+
],
23+
})
24+
export class BooruCommand extends TracedSubcommand {
25+
public override registerApplicationCommands(registry: Subcommand.Registry) {
26+
const queryOption = (option: SlashCommandStringOption) =>
27+
applyLocalizedBuilder(
28+
option,
29+
'commands/names:booruOptionQuery',
30+
'commands/descriptions:booruOptionQuery',
31+
)
32+
.setRequired(true)
33+
.setMaxLength(BOORU_QUERY_MAX_LENGTH)
34+
.setAutocomplete(true)
35+
36+
registry.registerChatInputCommand(
37+
(builder) => {
38+
applyLocalizedBuilder(
39+
builder,
40+
'commands/names:booru',
41+
'commands/descriptions:booru',
42+
)
43+
.addSubcommand((sub) =>
44+
applyLocalizedBuilder(
45+
sub,
46+
'commands/names:booruE621',
47+
'commands/descriptions:booruE621',
48+
).addStringOption(queryOption),
49+
)
50+
.addSubcommand((sub) =>
51+
applyLocalizedBuilder(
52+
sub,
53+
'commands/names:booruRule34',
54+
'commands/descriptions:booruRule34',
55+
).addStringOption(queryOption),
56+
)
57+
.addSubcommand((sub) =>
58+
applyLocalizedBuilder(
59+
sub,
60+
'commands/names:booruDerpibooru',
61+
'commands/descriptions:booruDerpibooru',
62+
).addStringOption(queryOption),
63+
)
64+
},
65+
{
66+
guildIds: ['1034462346908794910'],
67+
},
68+
)
69+
}
70+
71+
public override async autocompleteRun(
72+
interaction: Command.AutocompleteInteraction,
73+
) {
74+
const site = interaction.options.getSubcommand(false)
75+
if (!site || !isBooruSiteName(site)) {
76+
return interaction.respond([])
77+
}
78+
79+
// Complete the word being typed and keep the already-entered tags in
80+
// front, so multi-tag queries stay autocompletable.
81+
const chunks = interaction.options.getFocused().split(' ')
82+
const term = chunks.pop() ?? ''
83+
if (term.length === 0) {
84+
return interaction.respond([])
85+
}
86+
87+
const hits = await booruSites[site]
88+
.autocomplete(term, channelAllowsNsfw(interaction))
89+
.catch(() => [])
90+
return interaction.respond(
91+
hits
92+
.map((hit) => [...chunks, hit].join(' ').trim())
93+
.filter(
94+
(value) => value.length > 0 && value.length <= BOORU_QUERY_MAX_LENGTH,
95+
)
96+
.slice(0, 25)
97+
.map((value) => ({ name: value, value })),
98+
)
99+
}
100+
101+
public chatInputE621(interaction: Subcommand.ChatInputCommandInteraction) {
102+
return this.runSite(interaction, 'e621')
103+
}
104+
105+
public chatInputRule34(interaction: Subcommand.ChatInputCommandInteraction) {
106+
return this.runSite(interaction, 'rule34')
107+
}
108+
109+
public chatInputDerpibooru(
110+
interaction: Subcommand.ChatInputCommandInteraction,
111+
) {
112+
return this.runSite(interaction, 'derpibooru')
113+
}
114+
115+
private async runSite(
116+
interaction: Subcommand.ChatInputCommandInteraction,
117+
site: BooruSiteName,
118+
) {
119+
const nsfwAllowed = channelAllowsNsfw(interaction)
120+
if (booruSites[site].gated && !nsfwAllowed) {
121+
return interaction.reply({
122+
content: (await resolveKey(
123+
interaction,
124+
'commands/common:nsfwDisabled',
125+
)) as string,
126+
flags: MessageFlags.Ephemeral,
127+
})
128+
}
129+
130+
const query = interaction.options.getString('query', true)
131+
await interaction.deferReply()
132+
133+
const posts = await booruSites[site].search(query, nsfwAllowed)
134+
if (posts.length === 0) {
135+
return interaction.editReply(
136+
(await resolveKey(interaction, 'commands/common:noResultsFor', {
137+
query,
138+
})) as string,
139+
)
140+
}
141+
142+
return interaction.editReply(
143+
await buildBooruPage(interaction, site, query, posts, 0),
144+
)
145+
}
146+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { ApplyOptions } from '@sapphire/decorators'
2+
import {
3+
InteractionHandler,
4+
InteractionHandlerTypes,
5+
} from '@sapphire/framework'
6+
import { resolveKey } from '@sapphire/plugin-i18next'
7+
import { type ButtonInteraction, TextDisplayBuilder } from 'discord.js'
8+
import {
9+
BOORU_CUSTOM_ID_PREFIX,
10+
type BooruSiteName,
11+
booruSites,
12+
buildBooruPage,
13+
channelAllowsNsfw,
14+
isBooruSiteName,
15+
} from '../utils/booru.mjs'
16+
17+
interface BooruPageAction {
18+
site: BooruSiteName
19+
query: string
20+
position: number | 'random'
21+
}
22+
23+
/**
24+
* Pagination for /booru. Stateless like the Urban Dictionary pager: the
25+
* custom id (`booru:<site>:<page>:<query>`) carries everything needed to
26+
* refetch, so buttons work across restarts.
27+
*/
28+
@ApplyOptions<InteractionHandler.Options>({
29+
interactionHandlerType: InteractionHandlerTypes.Button,
30+
})
31+
export class BooruPagesHandler extends InteractionHandler {
32+
public override parse(interaction: ButtonInteraction) {
33+
if (!interaction.customId.startsWith(BOORU_CUSTOM_ID_PREFIX)) {
34+
return this.none()
35+
}
36+
const [site, target, ...queryParts] = interaction.customId
37+
.slice(BOORU_CUSTOM_ID_PREFIX.length)
38+
.split(':')
39+
if (!site || !isBooruSiteName(site) || target === undefined) {
40+
return this.none()
41+
}
42+
return this.some<BooruPageAction>({
43+
site,
44+
query: queryParts.join(':'),
45+
position: target === 'rnd' ? 'random' : Number(target),
46+
})
47+
}
48+
49+
public async run(interaction: ButtonInteraction, action: BooruPageAction) {
50+
await interaction.deferUpdate()
51+
52+
const nsfwAllowed = channelAllowsNsfw(interaction)
53+
const message = async (key: string) => ({
54+
// The message carries IsComponentsV2, which can't be unset, so
55+
// fallbacks must stay components rather than plain content.
56+
components: [
57+
new TextDisplayBuilder().setContent(
58+
(await resolveKey(interaction, key, {
59+
query: action.query,
60+
})) as string,
61+
),
62+
],
63+
})
64+
65+
// A message can outlive its channel being flipped back to SFW.
66+
if (booruSites[action.site].gated && !nsfwAllowed) {
67+
return interaction.editReply(
68+
await message('commands/common:nsfwDisabled'),
69+
)
70+
}
71+
72+
const posts = await booruSites[action.site].search(
73+
action.query,
74+
nsfwAllowed,
75+
)
76+
if (posts.length === 0) {
77+
return interaction.editReply(
78+
await message('commands/common:noResultsFor'),
79+
)
80+
}
81+
82+
const position =
83+
action.position === 'random'
84+
? Math.floor(Math.random() * posts.length)
85+
: action.position
86+
87+
return interaction.editReply(
88+
await buildBooruPage(
89+
interaction,
90+
action.site,
91+
action.query,
92+
posts,
93+
position,
94+
),
95+
)
96+
}
97+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { ApplyOptions } from '@sapphire/decorators'
2+
import {
3+
InteractionHandler,
4+
InteractionHandlerTypes,
5+
} from '@sapphire/framework'
6+
import {
7+
type APIActionRowComponent,
8+
type APIComponentInContainer,
9+
type APIComponentInMessageActionRow,
10+
type APIMessageTopLevelComponent,
11+
type ButtonInteraction,
12+
ButtonStyle,
13+
ComponentType,
14+
} from 'discord.js'
15+
16+
function keepOnlyLinkButtons(
17+
row: APIActionRowComponent<APIComponentInMessageActionRow>,
18+
): APIActionRowComponent<APIComponentInMessageActionRow>[] {
19+
const links = row.components.filter(
20+
(component) =>
21+
component.type === ComponentType.Button &&
22+
component.style === ButtonStyle.Link,
23+
)
24+
return links.length > 0 ? [{ ...row, components: links }] : []
25+
}
26+
27+
/**
28+
* The ✖️ button: strips interactive buttons from the message, keeping the
29+
* content and any link buttons. Works on any message that includes a
30+
* button with the `close` custom id.
31+
*/
32+
@ApplyOptions<InteractionHandler.Options>({
33+
interactionHandlerType: InteractionHandlerTypes.Button,
34+
})
35+
export class CloseButtonHandler extends InteractionHandler {
36+
public override parse(interaction: ButtonInteraction) {
37+
return interaction.customId === 'close' ? this.some() : this.none()
38+
}
39+
40+
public async run(interaction: ButtonInteraction) {
41+
const components = interaction.message.components
42+
.map((component) => component.toJSON())
43+
.flatMap((component): APIMessageTopLevelComponent[] => {
44+
if (component.type === ComponentType.ActionRow) {
45+
return keepOnlyLinkButtons(component)
46+
}
47+
if (component.type === ComponentType.Container) {
48+
return [
49+
{
50+
...component,
51+
components: component.components.flatMap(
52+
(inner): APIComponentInContainer[] =>
53+
inner.type === ComponentType.ActionRow
54+
? keepOnlyLinkButtons(inner)
55+
: [inner],
56+
),
57+
},
58+
]
59+
}
60+
return [component]
61+
})
62+
63+
return interaction.update({ components })
64+
}
65+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"nsfwDisabled": "This channel needs to be marked as NSFW before this command can be used",
3+
"noResultsFor": "No results found for `{{query}}`"
4+
}

‎apps/discord/src/languages/en-US/commands/descriptions.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
"ping": "Check if the bot is still alive",
33
"8ball": "Ask the magic 8-ball for advice",
44
"advice": "Get some helpful advice",
5+
"booru": "Search imageboards",
6+
"booruE621": "Query e621",
7+
"booruRule34": "Query rule34.paheal.net",
8+
"booruDerpibooru": "Query derpibooru.org",
9+
"booruOptionQuery": "What to search for",
510
"cat": "Sends a random cat image",
611
"dice": "Roll some dice",
712
"diceOptionDice": "The number of dice to roll (default: 1)",

‎apps/discord/src/languages/en-US/commands/names.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
"ping": "ping",
33
"8ball": "8ball",
44
"advice": "advice",
5+
"booru": "booru",
6+
"booruE621": "e621",
7+
"booruRule34": "rule34",
8+
"booruDerpibooru": "derpibooru",
9+
"booruOptionQuery": "query",
510
"cat": "cat",
611
"dice": "dice",
712
"diceOptionDice": "dice",

0 commit comments

Comments
 (0)