diff --git a/src/commands/debug.ts b/src/commands/debug.ts index 8a6651562..32a7ba4a2 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -5,7 +5,7 @@ import { tankAverages } from '../core/blitzstars/tankAverages.js'; import cleanTable from '../core/interaction/cleanTable.js'; import infoEmbed from '../core/interaction/infoEmbed.js'; import getClientId from '../core/process/getClientId.js'; -import { CommandRegistry } from '../events/interactionCreate.js'; +import { CommandRegistry } from '../events/interactionCreate/index.js'; import { client } from '../index.js'; const executionStart = new Date().getTime(); diff --git a/src/commands/eligible.ts b/src/commands/eligible.ts index 9804867be..bd038a85a 100644 --- a/src/commands/eligible.ts +++ b/src/commands/eligible.ts @@ -13,7 +13,7 @@ import positiveEmbed from '../core/interaction/positiveEmbed.js'; import addUsernameOption from '../core/options/addUsernameOption.js'; import resolvePlayer from '../core/options/resolvePlayer.js'; import { WARGAMING_APPLICATION_ID } from '../core/process/args.js'; -import { CommandRegistry } from '../events/interactionCreate.js'; +import { CommandRegistry } from '../events/interactionCreate/index.js'; import { AccountInfo } from '../types/accountInfo.js'; export type SkilledClan = 'SKLLD' | 'SMRI'; diff --git a/src/commands/evolution.tsx b/src/commands/evolution.tsx index a6843aecd..318cf28d3 100644 --- a/src/commands/evolution.tsx +++ b/src/commands/evolution.tsx @@ -5,7 +5,7 @@ import resolveTankId from '../core/blitz/resolveTankId.js'; import addStatsSubCommandGroups from '../core/options/addStatsSubCommandGroups.js'; import resolvePeriod from '../core/options/resolvePeriod.js'; import resolvePlayer from '../core/options/resolvePlayer.js'; -import { CommandRegistry } from '../events/interactionCreate.js'; +import { CommandRegistry } from '../events/interactionCreate/index.js'; import evolution from '../renderers/evolution.js'; import { StatType } from '../renderers/stats.js'; diff --git a/src/commands/help.ts b/src/commands/help.ts index 671298988..cecaa41f8 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -5,7 +5,7 @@ import { CommandRegistry, guildCommands, publicCommands, -} from '../events/interactionCreate.js'; +} from '../events/interactionCreate/index.js'; import { client } from '../index.js'; export default { diff --git a/src/commands/inactive.tsx b/src/commands/inactive.tsx index d1db738dc..4a45dc54a 100644 --- a/src/commands/inactive.tsx +++ b/src/commands/inactive.tsx @@ -10,7 +10,7 @@ import getBlitzClan from '../core/blitz/getBlitzClan.js'; import getWargamingResponse from '../core/blitz/getWargamingResponse.js'; import addClanChoices from '../core/options/addClanChoices.js'; import { WARGAMING_APPLICATION_ID } from '../core/process/args.js'; -import { CommandRegistry } from '../events/interactionCreate.js'; +import { CommandRegistry } from '../events/interactionCreate/index.js'; import { AccountInfo } from '../types/accountInfo.js'; import { ClanInfo } from '../types/clanInfo.js'; diff --git a/src/commands/ownedtanks.tsx b/src/commands/ownedtanks.tsx index d73a14a70..14b0dfc52 100644 --- a/src/commands/ownedtanks.tsx +++ b/src/commands/ownedtanks.tsx @@ -16,7 +16,7 @@ import { import addUsernameOption from '../core/options/addUsernameOption.js'; import resolvePlayer from '../core/options/resolvePlayer.js'; import { WARGAMING_APPLICATION_ID } from '../core/process/args.js'; -import { CommandRegistry } from '../events/interactionCreate.js'; +import { CommandRegistry } from '../events/interactionCreate/index.js'; import { AccountInfo } from '../types/accountInfo.js'; import { PlayerClanData } from '../types/playerClanData.js'; diff --git a/src/commands/ping.ts b/src/commands/ping.ts index 4e0f77fe9..dd0b41471 100644 --- a/src/commands/ping.ts +++ b/src/commands/ping.ts @@ -1,5 +1,5 @@ import { SlashCommandBuilder } from 'discord.js'; -import { CommandRegistry } from '../events/interactionCreate.js'; +import { CommandRegistry } from '../events/interactionCreate/index.js'; export default { inProduction: true, diff --git a/src/commands/playerachievements.ts b/src/commands/playerachievements.ts index fb52b3a50..43b888103 100644 --- a/src/commands/playerachievements.ts +++ b/src/commands/playerachievements.ts @@ -8,7 +8,7 @@ import infoEmbed from '../core/interaction/infoEmbed.js'; import addUsernameOption from '../core/options/addUsernameOption.js'; import resolvePlayer from '../core/options/resolvePlayer.js'; import { WARGAMING_APPLICATION_ID } from '../core/process/args.js'; -import { CommandRegistry } from '../events/interactionCreate.js'; +import { CommandRegistry } from '../events/interactionCreate/index.js'; import { AccountAchievements } from '../types/accountAchievements.js'; import { AccountInfo } from '../types/accountInfo.js'; diff --git a/src/commands/playerinfo.ts b/src/commands/playerinfo.ts index e34426edc..f7dfde492 100644 --- a/src/commands/playerinfo.ts +++ b/src/commands/playerinfo.ts @@ -7,7 +7,7 @@ import infoEmbed from '../core/interaction/infoEmbed.js'; import addUsernameOption from '../core/options/addUsernameOption.js'; import resolvePlayer from '../core/options/resolvePlayer.js'; import { WARGAMING_APPLICATION_ID } from '../core/process/args.js'; -import { CommandRegistry } from '../events/interactionCreate.js'; +import { CommandRegistry } from '../events/interactionCreate/index.js'; import { AccountInfo } from '../types/accountInfo.js'; export default { diff --git a/src/commands/searchclans.ts b/src/commands/searchclans.ts index 7d811ed16..5e005d6f1 100644 --- a/src/commands/searchclans.ts +++ b/src/commands/searchclans.ts @@ -6,7 +6,7 @@ import infoEmbed from '../core/interaction/infoEmbed.js'; import addClanChoices from '../core/options/addClanChoices.js'; import addServerChoices from '../core/options/addServerChoices.js'; import { WARGAMING_APPLICATION_ID } from '../core/process/args.js'; -import { CommandRegistry } from '../events/interactionCreate.js'; +import { CommandRegistry } from '../events/interactionCreate/index.js'; import { ClanList } from '../types/clanList.js'; export default { diff --git a/src/commands/searchplayers.ts b/src/commands/searchplayers.ts index 35aa16395..d9b1ea2c7 100644 --- a/src/commands/searchplayers.ts +++ b/src/commands/searchplayers.ts @@ -7,7 +7,7 @@ import infoEmbed from '../core/interaction/infoEmbed.js'; import addServerChoices from '../core/options/addServerChoices.js'; import addUsernameOption from '../core/options/addUsernameOption.js'; import { WARGAMING_APPLICATION_ID } from '../core/process/args.js'; -import { CommandRegistry } from '../events/interactionCreate.js'; +import { CommandRegistry } from '../events/interactionCreate/index.js'; import { AccountList } from '../types/accountList.js'; export default { diff --git a/src/commands/searchtanks.ts b/src/commands/searchtanks.ts index d19bf7762..bb4260b88 100644 --- a/src/commands/searchtanks.ts +++ b/src/commands/searchtanks.ts @@ -4,7 +4,7 @@ import markdownEscape from 'markdown-escape'; import { TANK_NAMES } from '../core/blitz/tankopedia.js'; import infoEmbed from '../core/interaction/infoEmbed.js'; import addTankChoices from '../core/options/addTankChoices.js'; -import { CommandRegistry } from '../events/interactionCreate.js'; +import { CommandRegistry } from '../events/interactionCreate/index.js'; export default { inProduction: true, diff --git a/src/commands/stats.tsx b/src/commands/stats.tsx index 5cbab0a08..f036f0239 100644 --- a/src/commands/stats.tsx +++ b/src/commands/stats.tsx @@ -1,11 +1,16 @@ import { SlashCommandBuilder } from 'discord.js'; +import { CYCLIC_API } from '../constants/cyclic.js'; import tanksAutocomplete from '../core/autocomplete/tanks.js'; import usernameAutocomplete from '../core/autocomplete/username.js'; import resolveTankId from '../core/blitz/resolveTankId.js'; +import interactionToURL from '../core/interaction/interactionToURL.js'; +import linkButton from '../core/interaction/linkButton.js'; +import primaryButton from '../core/interaction/primaryButton.js'; +import { Period } from '../core/options/addPeriodSubCommands.js'; import addStatsSubCommandGroups from '../core/options/addStatsSubCommandGroups.js'; import resolvePeriod from '../core/options/resolvePeriod.js'; import resolvePlayer from '../core/options/resolvePlayer.js'; -import { CommandRegistry } from '../events/interactionCreate.js'; +import { CommandRegistry } from '../events/interactionCreate/index.js'; import stats, { StatType } from '../renderers/stats.js'; export default { @@ -23,16 +28,43 @@ export default { const commandGroup = interaction.options.getSubcommandGroup( true, ) as StatType; + const subcommand = interaction.options.getSubcommand() as Period; const player = await resolvePlayer(interaction); const period = resolvePeriod(interaction); const tankIdRaw = interaction.options.getString('tank')!; const tankId = commandGroup === 'tank' ? resolveTankId(tankIdRaw) : 0; + const start = interaction.options.getInteger('start'); + const end = interaction.options.getInteger('end'); + const path = `stats/${commandGroup}/${subcommand}?server=${ + player.server + }&id=${player.id}&tankId=${tankId}&start=${start ?? 0}&end=${end ?? 0}`; - return await stats(commandGroup, period, player, tankId); + interactionToURL(interaction); + + return [ + await stats(commandGroup, period, player, tankId), + primaryButton(path, 'Refresh'), + linkButton(path, 'Embed'), + ]; }, autocomplete: (interaction) => { usernameAutocomplete(interaction); tanksAutocomplete(interaction); }, + + async button(interaction) { + const url = new URL(`${CYCLIC_API}/${interaction.customId}`); + const path = url.pathname.split('/').filter(Boolean); + const commandGroup = path[1] as StatType; + const period = resolvePeriod(interaction); + const player = await resolvePlayer(interaction); + + return await stats( + commandGroup, + period, + player, + parseInt(url.searchParams.get('tankId')!), + ); + }, } satisfies CommandRegistry; diff --git a/src/commands/today.tsx b/src/commands/today.tsx index 70207cd1e..41988e021 100644 --- a/src/commands/today.tsx +++ b/src/commands/today.tsx @@ -2,7 +2,7 @@ import { SlashCommandBuilder } from 'discord.js'; import usernameAutocomplete from '../core/autocomplete/username.js'; import addUsernameOption from '../core/options/addUsernameOption.js'; import resolvePlayer from '../core/options/resolvePlayer.js'; -import { CommandRegistry } from '../events/interactionCreate.js'; +import { CommandRegistry } from '../events/interactionCreate/index.js'; import today from '../renderers/today.js'; export default { diff --git a/src/commands/verify.ts b/src/commands/verify.ts index d05b64d89..6d923e032 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -8,7 +8,7 @@ import positiveEmbed from '../core/interaction/positiveEmbed.js'; import addUsernameOption from '../core/options/addUsernameOption.js'; import resolvePlayer from '../core/options/resolvePlayer.js'; import { WARGAMING_APPLICATION_ID } from '../core/process/args.js'; -import { CommandRegistry } from '../events/interactionCreate.js'; +import { CommandRegistry } from '../events/interactionCreate/index.js'; import { AccountInfo } from '../types/accountInfo.js'; import { PlayerClanData } from '../types/playerClanData.js'; diff --git a/src/constants/cyclic.ts b/src/constants/cyclic.ts new file mode 100644 index 000000000..c23a76e7d --- /dev/null +++ b/src/constants/cyclic.ts @@ -0,0 +1 @@ +export const CYCLIC_API = 'https://blitzkrieg.cyclic.app'; diff --git a/src/core/interaction/interactionToURL.ts b/src/core/interaction/interactionToURL.ts new file mode 100644 index 000000000..1dc5041a4 --- /dev/null +++ b/src/core/interaction/interactionToURL.ts @@ -0,0 +1,20 @@ +import { CacheType, ChatInputCommandInteraction } from 'discord.js'; + +export default function interactionToURL( + interaction: ChatInputCommandInteraction, +) { + const pathname = [ + interaction.commandName, + interaction.options.getSubcommandGroup(), + interaction.options.getSubcommand(), + ] + .filter(Boolean) + .join('/'); + const urlSearchParams = new URLSearchParams(); + + interaction.options.data.forEach((option) => { + if (option.value) urlSearchParams.set(option.name, `${option.value}`); + }); + + console.log(`${urlSearchParams}`); +} diff --git a/src/core/interaction/linkButton.ts b/src/core/interaction/linkButton.ts new file mode 100644 index 000000000..1215518a2 --- /dev/null +++ b/src/core/interaction/linkButton.ts @@ -0,0 +1,21 @@ +import { + ButtonBuilder, + ButtonStyle, + ComponentEmojiResolvable, +} from 'discord.js'; +import { CYCLIC_API } from '../../constants/cyclic.js'; + +export default function linkButton( + id: string, + label: string, + emoji?: ComponentEmojiResolvable, +) { + const button = new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setURL(`${CYCLIC_API}/${id}`) + .setLabel(label); + + if (emoji) button.setEmoji(emoji); + + return button; +} diff --git a/src/core/interaction/normalizeInteractionReturnable.ts b/src/core/interaction/normalizeInteractionReturnable.ts new file mode 100644 index 000000000..9303aec47 --- /dev/null +++ b/src/core/interaction/normalizeInteractionReturnable.ts @@ -0,0 +1,40 @@ +import { + ActionRowBuilder, + ButtonBuilder, + EmbedBuilder, + InteractionEditReplyOptions, +} from 'discord.js'; +import { InteractionReturnable } from '../../events/interactionCreate/index.js'; +import render from '../ui/render.js'; + +export default async function normalizeInteractionReturnable( + returnable: InteractionReturnable, +) { + const reply: InteractionEditReplyOptions = {}; + + const awaitedReturnable = await returnable; + const normalizedReturnable = Array.isArray(awaitedReturnable) + ? awaitedReturnable + : [awaitedReturnable]; + + await Promise.all( + normalizedReturnable.map(async (item) => { + if (item instanceof EmbedBuilder) { + if (!reply.embeds) reply.embeds = []; + reply.embeds.push(item); + } else if (item instanceof ButtonBuilder) { + if (!reply.components) + reply.components = [new ActionRowBuilder()]; + (reply.components[0] as ActionRowBuilder).addComponents( + item, + ); + } else { + const image = await render(item); + if (!reply.files) reply.files = []; + reply.files.push(image); + } + }), + ); + + return reply; +} diff --git a/src/core/interaction/primaryButton.ts b/src/core/interaction/primaryButton.ts new file mode 100644 index 000000000..eee16a2f4 --- /dev/null +++ b/src/core/interaction/primaryButton.ts @@ -0,0 +1,20 @@ +import { + ButtonBuilder, + ButtonStyle, + ComponentEmojiResolvable, +} from 'discord.js'; + +export default function primaryButton( + id: string, + label: string, + emoji?: ComponentEmojiResolvable, +) { + const button = new ButtonBuilder() + .setCustomId(id) + .setStyle(ButtonStyle.Primary) + .setLabel(label); + + if (emoji) button.setEmoji(emoji); + + return button; +} diff --git a/src/core/options/resolvePeriod.ts b/src/core/options/resolvePeriod.ts index 7a3b78722..18045401b 100644 --- a/src/core/options/resolvePeriod.ts +++ b/src/core/options/resolvePeriod.ts @@ -1,4 +1,9 @@ -import { CacheType, ChatInputCommandInteraction } from 'discord.js'; +import { + ButtonInteraction, + CacheType, + ChatInputCommandInteraction, +} from 'discord.js'; +import { CYCLIC_API } from '../../constants/cyclic.js'; import getPeriodNow from '../blitzstars/getPeriodNow.js'; import getPeriodStart from '../blitzstars/getPeriodStart.js'; import getTimeDaysAgo from '../blitzstars/getTimeDaysAgo.js'; @@ -30,17 +35,24 @@ export interface ResolvedPeriod { } export default function resolvePeriod( - interaction: ChatInputCommandInteraction, + interaction: + | ChatInputCommandInteraction + | ButtonInteraction, ) { - const period = interaction.options.getSubcommand(true) as Period; let statsName: string; let evolutionName: string; let start: number; let end: number; if (period === 'custom') { - const startRaw = interaction.options.getInteger('start')!; - const endRaw = interaction.options.getInteger('end')!; + const startRaw = + interaction instanceof ChatInputCommandInteraction + ? interaction.options.getInteger('start')! + : parseInt(url!.searchParams.get('start')!); + const endRaw = + interaction instanceof ChatInputCommandInteraction + ? interaction.options.getInteger('end')! + : parseInt(url!.searchParams.get('end')!); const startMin = Math.min(startRaw, endRaw); const endMax = Math.max(startRaw, endRaw); diff --git a/src/core/options/resolvePlayer.ts b/src/core/options/resolvePlayer.ts index 487a24f07..4e2d344d3 100644 --- a/src/core/options/resolvePlayer.ts +++ b/src/core/options/resolvePlayer.ts @@ -1,4 +1,9 @@ -import { CacheType, ChatInputCommandInteraction } from 'discord.js'; +import { + ButtonInteraction, + CacheType, + ChatInputCommandInteraction, +} from 'discord.js'; +import { CYCLIC_API } from '../../constants/cyclic.js'; import { BlitzServer } from '../../constants/servers.js'; import listAccountsPanServer, { usernamePatternWithoutPosition, @@ -13,40 +18,50 @@ export interface ResolvedPlayer { } export default async function resolvePlayer( - interaction: ChatInputCommandInteraction, + interaction: + | ChatInputCommandInteraction + | ButtonInteraction, ) { - const commandUsername = interaction.options.getString('username'); - const displayName = interaction.guild?.members.cache - .get(interaction.user.id) - ?.displayName.match(usernamePatternWithoutPosition)?.[0]; - const username = commandUsername ?? displayName; + if (interaction instanceof ChatInputCommandInteraction) { + const commandUsername = interaction.options.getString('username'); + const displayName = interaction.guild?.members.cache + .get(interaction.user.id) + ?.displayName.match(usernamePatternWithoutPosition)?.[0]; + const username = commandUsername ?? displayName; - if (username === undefined) { - throw errorWithCause( - 'Username could not be inferred', - "You didn't provide me a username in the command nor was I able to infer your Blitz username from your Discord nickname. Try providing a username manually in the command or using the `/verify` command to set your Discord nickname to your Blitz username.", - ); - } - - if (serverAndIdPattern.test(username)) { - const [server, accountId] = username.split('/'); - return { - server: server as BlitzServer, - id: Number(accountId), - } satisfies ResolvedPlayer; - } else { - const accounts = await listAccountsPanServer(username); - - if (accounts[0]) { - return { - server: accounts[0].server, - id: accounts[0].account_id, - } satisfies ResolvedPlayer; - } else { + if (username === undefined) { throw errorWithCause( - 'Could not find user', - `I couldn't find user \`${username}\`. Try providing a valid username manually or using the \`/verify\` command to set your Discord nickname to your Blitz username.`, + 'Username could not be inferred', + "You didn't provide me a username in the command nor was I able to infer your Blitz username from your Discord nickname. Try providing a username manually in the command or using the `/verify` command to set your Discord nickname to your Blitz username.", ); } + + if (serverAndIdPattern.test(username)) { + const [server, accountId] = username.split('/'); + return { + server: server as BlitzServer, + id: Number(accountId), + } satisfies ResolvedPlayer; + } else { + const accounts = await listAccountsPanServer(username); + + if (accounts[0]) { + return { + server: accounts[0].server, + id: accounts[0].account_id, + } satisfies ResolvedPlayer; + } else { + throw errorWithCause( + 'Could not find user', + `I couldn't find user \`${username}\`. Try providing a valid username manually or using the \`/verify\` command to set your Discord nickname to your Blitz username.`, + ); + } + } + } else { + const url = new URL(`${CYCLIC_API}/${interaction.customId}`); + return { + id: parseInt(url.searchParams.get('id')!), + server: url.searchParams.get('server') as BlitzServer, + } satisfies ResolvedPlayer; } } diff --git a/src/core/process/psa.ts b/src/core/process/psa.ts index a08e90812..48252aca0 100644 --- a/src/core/process/psa.ts +++ b/src/core/process/psa.ts @@ -4,10 +4,10 @@ export interface PSAData { } export interface PSA { - data?: PSAData; + data: PSAData | null; } -export const psa: PSA = {}; +export const psa: PSA = { data: null }; console.log('Caching PSA...'); fetch( diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts deleted file mode 100644 index 87497e573..000000000 --- a/src/events/interactionCreate.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { - ActionRowBuilder, - AutocompleteInteraction, - ButtonBuilder, - ButtonInteraction, - ButtonStyle, - CacheType, - ChatInputCommandInteraction, - EmbedBuilder, - Interaction, - InteractionEditReplyOptions, - REST, - RESTPostAPIChatInputApplicationCommandsJSONBody, - Routes, - SlashCommandBuilder, - SlashCommandSubcommandsOnlyBuilder, -} from 'discord.js'; -import discord from '../../discord.json' assert { type: 'json' }; -import debug from '../commands/debug.js'; -import eligible from '../commands/eligible.js'; -import evolution from '../commands/evolution.js'; -import help from '../commands/help.js'; -import inactive from '../commands/inactive.js'; -import ownedtanks from '../commands/ownedtanks.js'; -import ping from '../commands/ping.js'; -import playerachievements from '../commands/playerachievements.js'; -import playerinfo from '../commands/playerinfo.js'; -import searchclans from '../commands/searchclans.js'; -import searchplayers from '../commands/searchplayers.js'; -import searchtanks from '../commands/searchtanks.js'; -import stats from '../commands/stats.js'; -import today from '../commands/today.js'; -import verify from '../commands/verify.js'; -import negativeEmbed from '../core/interaction/negativeEmbed.js'; -import warningEmbed from '../core/interaction/warningEmbed.js'; -import { DISCORD_TOKEN } from '../core/process/args.js'; -import getClientId from '../core/process/getClientId.js'; -import isDev from '../core/process/isDev.js'; -import { psa } from '../core/process/psa.js'; -import render from '../core/ui/render.js'; -import { handleError } from './error.js'; - -export type CommandReturnable = EmbedBuilder | ButtonBuilder | JSX.Element; -export type RichCommandReturnable = CommandReturnable | CommandReturnable[]; - -export type CommandRegistry = { - inDevelopment: boolean; - inProduction: boolean; - inPublic: boolean; - - command: - | SlashCommandBuilder - | SlashCommandSubcommandsOnlyBuilder - | Omit; - autocomplete?: (interaction: AutocompleteInteraction) => void; -} & ( - | { - handlesInteraction?: false; - execute: ( - interaction: ChatInputCommandInteraction, - ) => RichCommandReturnable | Promise; - } - | { - handlesInteraction: true; - execute: (interaction: ChatInputCommandInteraction) => void; - } -); - -const rest = new REST().setToken(DISCORD_TOKEN); - -const commands = ( - [ - debug, - eligible, - help, - inactive, - ownedtanks, - playerachievements, - playerinfo, - searchclans, - searchplayers, - searchtanks, - stats, - today, - verify, - ping, - evolution, - ] as CommandRegistry[] -).reduce>((accumulator, registry) => { - registry.command.setName( - isDev() ? `${registry.command.name}dev` : registry.command.name, - ); - - return { ...accumulator, [registry.command.name]: registry }; -}, {}); - -export const guildCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = - []; -export const publicCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = - []; - -Object.entries(commands).forEach(([, registry]) => { - if (isDev() ? registry.inDevelopment : registry.inProduction) { - const json = registry.command.toJSON(); - - if (registry.inPublic) { - publicCommands.push(json); - } else { - guildCommands.push(json); - } - } -}); - -try { - console.log(`Refreshing ${publicCommands.length} public command(s).`); - rest - .put(Routes.applicationCommands(getClientId()), { body: publicCommands }) - .then((publicData) => { - console.log( - `Successfully refreshed ${ - (publicData as RESTPostAPIChatInputApplicationCommandsJSONBody[]) - .length - } public command(s).`, - ); - }); - - console.log(`Refreshing ${guildCommands.length} guild command(s).`); - rest - .put( - Routes.applicationGuildCommands(getClientId(), discord.tres_guild_id), - { body: guildCommands }, - ) - .then((guildData) => { - console.log( - `Successfully refreshed ${ - (guildData as unknown[]).length - } guild command(s).`, - ); - }); -} catch (error) { - console.error(error); -} - -//#region Event type handlers -function handleAutocomplete(interaction: AutocompleteInteraction) { - const command = commands[interaction.commandName]; - - if (!command) { - console.error(`No command matching ${interaction.commandName} was found.`); - return; - } - - if (command.autocomplete) command.autocomplete(interaction); -} - -async function handleChatInputCommand( - interaction: ChatInputCommandInteraction, -) { - const command = commands[interaction.commandName]; - - if (!command) { - console.error(`No command matching ${interaction.commandName} was found.`); - return; - } - - console.log(interaction.toString()); - await interaction.deferReply(); - - try { - const result = await command.execute(interaction); - - if (command.handlesInteraction) return; - - const normalizedResult = Array.isArray(result) ? result : [result]; - const reply: InteractionEditReplyOptions = {}; - - if (psa.data) { - normalizedResult.push(warningEmbed(psa.data.title, psa.data.description)); - } - - await Promise.all( - normalizedResult.map(async (item) => { - if (item instanceof EmbedBuilder) { - if (!reply.embeds) reply.embeds = []; - reply.embeds.push(item); - } else if (item instanceof ButtonBuilder) { - if (!reply.components) - reply.components = [new ActionRowBuilder()]; - ( - reply.components[0] as ActionRowBuilder - ).addComponents(item); - } else { - const image = await render(item!); - if (!reply.files) reply.files = []; - reply.files.push(image); - } - }), - ); - - await interaction.editReply(reply); - } catch (error) { - const actionRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setLabel('Get Help on Discord Server') - .setURL('https://discord.gg/nDt7AjGJQH') - .setStyle(ButtonStyle.Link), - ); - - await interaction.editReply({ - embeds: [ - negativeEmbed( - (error as Error).message, - `${(error as Error).cause ?? 'No further information is available.'}`, - ), - ], - components: [actionRow], - }); - - handleError(error as Error, interaction.commandName); - } -} - -function handleButton(interaction: ButtonInteraction) { - console.log(interaction.customId); - interaction.message.edit({ - files: [], - }); -} -//#endregion - -export default async function interactionCreate( - interaction: Interaction, -) { - if (interaction.isAutocomplete()) { - handleAutocomplete(interaction); - } else if (interaction.isChatInputCommand()) { - handleChatInputCommand(interaction); - } else if (interaction.isButton()) { - handleButton(interaction); - } -} diff --git a/src/events/interactionCreate/handlers/autocomplete.ts b/src/events/interactionCreate/handlers/autocomplete.ts new file mode 100644 index 000000000..0a9383bed --- /dev/null +++ b/src/events/interactionCreate/handlers/autocomplete.ts @@ -0,0 +1,15 @@ +import { AutocompleteInteraction, CacheType } from 'discord.js'; +import { commands } from '../index.js'; + +export default function handleAutocomplete( + interaction: AutocompleteInteraction, +) { + const command = commands[interaction.commandName]; + + if (!command) { + console.error(`No command matching ${interaction.commandName} was found.`); + return; + } + + if (command.autocomplete) command.autocomplete(interaction); +} diff --git a/src/events/interactionCreate/handlers/button.tsx b/src/events/interactionCreate/handlers/button.tsx new file mode 100644 index 000000000..7f2a87014 --- /dev/null +++ b/src/events/interactionCreate/handlers/button.tsx @@ -0,0 +1,20 @@ +import { ButtonInteraction, CacheType } from 'discord.js'; +import normalizeInteractionReturnable from '../../../core/interaction/normalizeInteractionReturnable.js'; +import { commands } from '../index.js'; + +export default async function handleButton( + interaction: ButtonInteraction, +) { + const commandName = interaction.customId.split('/')[0]; + const button = commands[commandName]?.button; + + if (!button) { + console.error(`Button handler not found for "${commandName}"`); + return; + } + + await interaction.deferUpdate(); + await interaction.message.edit( + await normalizeInteractionReturnable(button(interaction)), + ); +} diff --git a/src/events/interactionCreate/handlers/chatInputCommand.ts b/src/events/interactionCreate/handlers/chatInputCommand.ts new file mode 100644 index 000000000..6907bf516 --- /dev/null +++ b/src/events/interactionCreate/handlers/chatInputCommand.ts @@ -0,0 +1,56 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + CacheType, + ChatInputCommandInteraction, +} from 'discord.js'; +import negativeEmbed from '../../../core/interaction/negativeEmbed.js'; +import normalizeInteractionReturnable from '../../../core/interaction/normalizeInteractionReturnable.js'; +import warningEmbed from '../../../core/interaction/warningEmbed.js'; +import { psa } from '../../../core/process/psa.js'; +import { handleError } from '../../error.js'; +import { commands } from '../index.js'; + +export default async function handleChatInputCommand( + interaction: ChatInputCommandInteraction, +) { + const command = commands[interaction.commandName]; + + console.log(interaction.toString()); + await interaction.deferReply(); + + try { + const returnable = await command.execute(interaction); + + if (command.handlesInteraction) return; + + const reply = await normalizeInteractionReturnable(returnable!); + + if (psa.data) { + if (!reply.embeds) reply.embeds = []; + reply.embeds.push(warningEmbed(psa.data.title, psa.data.description)); + } + + await interaction.editReply(reply); + } catch (error) { + const actionRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel('Get Help on Discord Server') + .setURL('https://discord.gg/nDt7AjGJQH') + .setStyle(ButtonStyle.Link), + ); + + await interaction.editReply({ + embeds: [ + negativeEmbed( + (error as Error).message, + `${(error as Error).cause ?? 'No further information is available.'}`, + ), + ], + components: [actionRow], + }); + + handleError(error as Error, interaction.commandName); + } +} diff --git a/src/events/interactionCreate/index.ts b/src/events/interactionCreate/index.ts new file mode 100644 index 000000000..2b3d35c04 --- /dev/null +++ b/src/events/interactionCreate/index.ts @@ -0,0 +1,156 @@ +import { + AutocompleteInteraction, + ButtonBuilder, + ButtonInteraction, + CacheType, + ChatInputCommandInteraction, + EmbedBuilder, + Interaction, + REST, + RESTPostAPIChatInputApplicationCommandsJSONBody, + Routes, + SlashCommandBuilder, + SlashCommandSubcommandsOnlyBuilder, +} from 'discord.js'; +import discord from '../../../discord.json' assert { type: 'json' }; +import debug from '../../commands/debug.js'; +import eligible from '../../commands/eligible.js'; +import evolution from '../../commands/evolution.js'; +import help from '../../commands/help.js'; +import inactive from '../../commands/inactive.js'; +import ownedtanks from '../../commands/ownedtanks.js'; +import ping from '../../commands/ping.js'; +import playerachievements from '../../commands/playerachievements.js'; +import playerinfo from '../../commands/playerinfo.js'; +import searchclans from '../../commands/searchclans.js'; +import searchplayers from '../../commands/searchplayers.js'; +import searchtanks from '../../commands/searchtanks.js'; +import stats from '../../commands/stats.js'; +import today from '../../commands/today.js'; +import verify from '../../commands/verify.js'; +import { DISCORD_TOKEN } from '../../core/process/args.js'; +import getClientId from '../../core/process/getClientId.js'; +import isDev from '../../core/process/isDev.js'; +import handleAutocomplete from './handlers/autocomplete.js'; +import handleButton from './handlers/button.js'; +import handleChatInputCommand from './handlers/chatInputCommand.js'; + +export type InteractionRawReturnable = + | EmbedBuilder + | ButtonBuilder + | JSX.Element; +export type InteractionIterableReturnable = + | InteractionRawReturnable + | InteractionRawReturnable[]; +export type InteractionReturnable = + | InteractionIterableReturnable + | Promise; + +export type CommandRegistry = { + inDevelopment: boolean; + inProduction: boolean; + inPublic: boolean; + + command: + | SlashCommandBuilder + | SlashCommandSubcommandsOnlyBuilder + | Omit; + autocomplete?: (interaction: AutocompleteInteraction) => void; + button?: (interaction: ButtonInteraction) => InteractionReturnable; +} & ( + | { + handlesInteraction?: false; + execute: ( + interaction: ChatInputCommandInteraction, + ) => InteractionReturnable; + } + | { + handlesInteraction: true; + execute: (interaction: ChatInputCommandInteraction) => void; + } +); + +const rest = new REST().setToken(DISCORD_TOKEN); + +export const commands: Record = ( + [ + debug, + eligible, + help, + inactive, + ownedtanks, + playerachievements, + playerinfo, + searchclans, + searchplayers, + searchtanks, + stats, + today, + verify, + ping, + evolution, + ] as CommandRegistry[] +).reduce((accumulator, registry) => { + // if (isDev()) registry.command.setName(`${registry.command.name}dev`); + + return { ...accumulator, [registry.command.name]: registry }; +}, {}); + +export const guildCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = + []; +export const publicCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = + []; + +Object.entries(commands).forEach(([, registry]) => { + if (isDev() ? registry.inDevelopment : registry.inProduction) { + const json = registry.command.toJSON(); + + if (registry.inPublic) { + publicCommands.push(json); + } else { + guildCommands.push(json); + } + } +}); + +try { + console.log(`Refreshing ${publicCommands.length} public command(s).`); + rest + .put(Routes.applicationCommands(getClientId()), { body: publicCommands }) + .then((publicData) => { + console.log( + `Successfully refreshed ${ + (publicData as RESTPostAPIChatInputApplicationCommandsJSONBody[]) + .length + } public command(s).`, + ); + }); + + console.log(`Refreshing ${guildCommands.length} guild command(s).`); + rest + .put( + Routes.applicationGuildCommands(getClientId(), discord.tres_guild_id), + { body: guildCommands }, + ) + .then((guildData) => { + console.log( + `Successfully refreshed ${ + (guildData as unknown[]).length + } guild command(s).`, + ); + }); +} catch (error) { + console.error(error); +} + +export default async function interactionCreate( + interaction: Interaction, +) { + if (interaction.isAutocomplete()) { + handleAutocomplete(interaction); + } else if (interaction.isChatInputCommand()) { + handleChatInputCommand(interaction); + } else if (interaction.isButton()) { + handleButton(interaction); + } +} diff --git a/src/index.ts b/src/index.ts index 53af8e31e..823467025 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { Client, GatewayIntentBits } from 'discord.js'; import { DISCORD_TOKEN } from './core/process/args.js'; import { registerErrorHandlers } from './events/error.js'; import guildMemberAdd from './events/guildMemberAdd.js'; -import interactionCreate from './events/interactionCreate.js'; +import interactionCreate from './events/interactionCreate/index.js'; import ready from './events/ready.js'; export const client = new Client({ diff --git a/tsconfig.json b/tsconfig.json index fa21483dc..8ca930d1c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,5 +11,5 @@ "resolveJsonModule": true, "jsx": "react-jsx" }, - "include": ["src/**/*.ts", "src/renderers/stats.tsx"] + "include": ["src/**/*.ts", "src/renderers/stats.tsx", "src/events/interactionCreate/handlers/button.tsx"] }