diff --git a/.vscode/settings.json b/.vscode/settings.json index 4482684a6..91a3cb534 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,6 +39,7 @@ "ownedtanks", "playerachievements", "playerinfo", + "Promisable", "ratingsleaderboard", "ratingsplot", "researchable", diff --git a/src/commands/breakdown.tsx b/src/commands/breakdown.tsx index fe05523db..fc6308cc9 100644 --- a/src/commands/breakdown.tsx +++ b/src/commands/breakdown.tsx @@ -13,7 +13,7 @@ import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; import { tankAverages } from '../core/blitzstars/tankAverages'; -import addFilterOptions from '../core/discord/addFilterOptions'; +import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions'; import addUsernameChoices from '../core/discord/addUsernameChoices'; import autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteUsername from '../core/discord/autocompleteUsername'; @@ -33,12 +33,12 @@ import resolvePlayerFromCommand, { import calculateWN8 from '../core/statistics/calculateWN8'; import { StatFilters, filterStats } from '../core/statistics/filterStats'; import getWN8Percentile from '../core/statistics/getWN8Percentile'; -import { CommandRegistryRaw } from '../events/interactionCreate'; +import { CommandRegistryPromisable } from '../events/interactionCreate'; const ROWS_PER_PAGE = 8; const MAX_PAGES = 10; -async function render( +export async function renderBreakdown( { region, id }: ResolvedPlayer, { start, end, name }: ResolvedPeriod, filters: StatFilters, @@ -290,9 +290,9 @@ async function render( } } -export const breakdownCommand = new Promise( +export const breakdownCommand = new Promise( async (resolve) => { - const command = await addFilterOptions( + const command = await addPeriodicFilterOptions( new SlashCommandBuilder() .setName('breakdown') .setDescription("A period's breakdown by tanks played"), @@ -316,7 +316,7 @@ export const breakdownCommand = new Promise( }); return [ - ...(await render(player, period, filters)), + ...(await renderBreakdown(player, period, filters)), buttonPrimary(path, 'Refresh'), await getBlitzStarsLinkButton(player.region, player.id), ]; @@ -332,7 +332,7 @@ export const breakdownCommand = new Promise( const period = resolvePeriodFromButton(player.region, interaction); const filters = getFiltersFromButton(interaction); - return await render(player, period, filters); + return await renderBreakdown(player, period, filters); }, }); }, diff --git a/src/commands/debug.ts b/src/commands/debug.ts index 70ac62ad9..ef768d4d6 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -13,7 +13,7 @@ export const debugCommand: CommandRegistry = { .setName('debug') .setDescription('Debug information about the bot'), - async handler(interaction) { + async handler() { const currentTime = new Date().getTime(); const uptime = currentTime - executionStart; const list = [ diff --git a/src/commands/evolution.tsx b/src/commands/evolution.tsx index b495076e4..5b8cbd213 100644 --- a/src/commands/evolution.tsx +++ b/src/commands/evolution.tsx @@ -29,7 +29,7 @@ import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton'; import resolvePlayerFromCommand, { ResolvedPlayer, } from '../core/discord/resolvePlayerFromCommand'; -import { CommandRegistryRaw } from '../events/interactionCreate'; +import { CommandRegistryPromisable } from '../events/interactionCreate'; type EvolutionStatType = 'player' | 'tank'; @@ -107,7 +107,7 @@ async function render( ); } -export const evolutionCommand = new Promise( +export const evolutionCommand = new Promise( async (resolve) => { let playerGroup: SlashCommandSubcommandGroupBuilder; let tankGroup: SlashCommandSubcommandGroupBuilder; diff --git a/src/commands/fullStats.tsx b/src/commands/fullStats.tsx index f9262602a..01ca868a8 100644 --- a/src/commands/fullStats.tsx +++ b/src/commands/fullStats.tsx @@ -10,7 +10,7 @@ import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; -import addFilterOptions from '../core/discord/addFilterOptions'; +import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions'; import addUsernameChoices from '../core/discord/addUsernameChoices'; import autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteUsername from '../core/discord/autocompleteUsername'; @@ -29,7 +29,7 @@ import resolvePlayerFromCommand, { } from '../core/discord/resolvePlayerFromCommand'; import { StatFilters, filterStats } from '../core/statistics/filterStats'; import { getTierWeights } from '../core/statistics/getTierWeights'; -import { CommandRegistryRaw } from '../events/interactionCreate'; +import { CommandRegistryPromisable } from '../events/interactionCreate'; async function render( { region, id }: ResolvedPlayer, @@ -64,9 +64,9 @@ async function render( ); } -export const fullStatsCommand = new Promise( +export const fullStatsCommand = new Promise( async (resolve) => { - const command = await addFilterOptions( + const command = await addPeriodicFilterOptions( new SlashCommandBuilder() .setName('full-stats') .setDescription('Full in-game statistics'), diff --git a/src/commands/ratings.tsx b/src/commands/ratings.tsx index e238e2063..9e344bdf9 100644 --- a/src/commands/ratings.tsx +++ b/src/commands/ratings.tsx @@ -26,7 +26,7 @@ import addRegionChoices from '../core/discord/addRegionChoices'; import autocompleteUsername from '../core/discord/autocompleteUsername'; import embedNegative from '../core/discord/embedNegative'; import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; -import { CommandRegistryRaw } from '../events/interactionCreate'; +import { CommandRegistryPromisable } from '../events/interactionCreate'; export interface RatingsPlayer { spa_id: number; @@ -120,7 +120,7 @@ const noOngoingSeason = embedNegative( "Wargaming didn't provide any data for this season.", ); -export const ratingsCommand = new Promise( +export const ratingsCommand = new Promise( async (resolve) => { const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber(); const onGoingSeason = await isOnGoingRatingsSeason(); diff --git a/src/commands/stats.tsx b/src/commands/stats.tsx index 3f2e8cf60..bf6146259 100644 --- a/src/commands/stats.tsx +++ b/src/commands/stats.tsx @@ -9,7 +9,7 @@ import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; -import addFilterOptions from '../core/discord/addFilterOptions'; +import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions'; import addUsernameChoices from '../core/discord/addUsernameChoices'; import autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteUsername from '../core/discord/autocompleteUsername'; @@ -27,7 +27,7 @@ import resolvePlayerFromCommand, { ResolvedPlayer, } from '../core/discord/resolvePlayerFromCommand'; import { StatFilters, filterStats } from '../core/statistics/filterStats'; -import { CommandRegistryRaw } from '../events/interactionCreate'; +import { CommandRegistryPromisable } from '../events/interactionCreate'; async function render( { region, id }: ResolvedPlayer, @@ -57,48 +57,50 @@ async function render( ); } -export const statsCommand = new Promise(async (resolve) => { - const command = await addFilterOptions( - new SlashCommandBuilder() - .setName('stats') - .setDescription('Regular battles statistics'), - (option) => option.addStringOption(addUsernameChoices), - ); +export const statsCommand = new Promise( + async (resolve) => { + const command = await addPeriodicFilterOptions( + new SlashCommandBuilder() + .setName('stats') + .setDescription('Regular battles statistics'), + (option) => option.addStringOption(addUsernameChoices), + ); - resolve({ - inProduction: true, - inPublic: true, + resolve({ + inProduction: true, + inPublic: true, - command, + command, - async handler(interaction) { - const player = await resolvePlayerFromCommand(interaction); - const period = resolvePeriodFromCommand(player.region, interaction); - const filters = await getFiltersFromCommand(interaction); - const path = commandToURL(interaction, { - ...player, - ...getCustomPeriodParams(interaction), - ...filters, - }); + async handler(interaction) { + const player = await resolvePlayerFromCommand(interaction); + const period = resolvePeriodFromCommand(player.region, interaction); + const filters = await getFiltersFromCommand(interaction); + const path = commandToURL(interaction, { + ...player, + ...getCustomPeriodParams(interaction), + ...filters, + }); - return Promise.all([ - render(player, period, filters), - buttonRefresh(interaction, path), - getBlitzStarsLinkButton(player.region, player.id), - ]); - }, + return Promise.all([ + render(player, period, filters), + buttonRefresh(interaction, path), + getBlitzStarsLinkButton(player.region, player.id), + ]); + }, - autocomplete: (interaction) => { - autocompleteUsername(interaction); - autocompleteTanks(interaction); - }, + autocomplete: (interaction) => { + autocompleteUsername(interaction); + autocompleteTanks(interaction); + }, - async button(interaction) { - const player = await resolvePlayerFromButton(interaction); - const period = resolvePeriodFromButton(player.region, interaction); - const filters = getFiltersFromButton(interaction); + async button(interaction) { + const player = await resolvePlayerFromButton(interaction); + const period = resolvePeriodFromButton(player.region, interaction); + const filters = getFiltersFromButton(interaction); - return await render(player, period, filters); - }, - }); -}); + return await render(player, period, filters); + }, + }); + }, +); diff --git a/src/commands/today.ts b/src/commands/today.ts new file mode 100644 index 000000000..81e82405b --- /dev/null +++ b/src/commands/today.ts @@ -0,0 +1,71 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { encyclopediaInfo } from '../core/blitz/encyclopediaInfo'; +import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; +import { addFilterOptions } from '../core/discord/addFilterOptions'; +import addUsernameChoices from '../core/discord/addUsernameChoices'; +import autocompleteTanks from '../core/discord/autocompleteTanks'; +import autocompleteUsername from '../core/discord/autocompleteUsername'; +import buttonPrimary from '../core/discord/buttonPrimary'; +import commandToURL from '../core/discord/commandToURL'; +import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams'; +import { getFiltersFromButton } from '../core/discord/getFiltersFromButton'; +import { getFiltersFromCommand } from '../core/discord/getFiltersFromCommand'; +import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton'; +import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand'; +import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton'; +import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand'; +import { CommandRegistryPromisable } from '../events/interactionCreate'; +import { renderBreakdown } from './breakdown'; + +export const todayCommand = new Promise( + async (resolve) => { + const awaitedEncyclopediaInfo = await encyclopediaInfo; + const command = addFilterOptions( + new SlashCommandBuilder() + .setName('today') + .setDescription("Today's played tanks and statistics"), + awaitedEncyclopediaInfo, + ).addStringOption(addUsernameChoices); + + resolve({ + inProduction: true, + inPublic: true, + + command, + + async handler(interaction) { + const player = await resolvePlayerFromCommand(interaction); + const period = resolvePeriodFromCommand( + player.region, + interaction, + 'today', + ); + const filters = await getFiltersFromCommand(interaction); + const path = commandToURL(interaction, { + ...player, + ...getCustomPeriodParams(interaction, true), + ...filters, + }); + + return [ + ...(await renderBreakdown(player, period, filters)), + buttonPrimary(path, 'Refresh'), + await getBlitzStarsLinkButton(player.region, player.id), + ]; + }, + + autocomplete: (interaction) => { + autocompleteUsername(interaction); + autocompleteTanks(interaction); + }, + + async button(interaction) { + const player = await resolvePlayerFromButton(interaction); + const period = resolvePeriodFromButton(player.region, interaction); + const filters = getFiltersFromButton(interaction); + + return await renderBreakdown(player, period, filters); + }, + }); + }, +); diff --git a/src/core/discord/addFilterOptions.ts b/src/core/discord/addFilterOptions.ts index 2c45a612d..1f00e9942 100644 --- a/src/core/discord/addFilterOptions.ts +++ b/src/core/discord/addFilterOptions.ts @@ -1,76 +1,60 @@ -import { - SlashCommandBuilder, - SlashCommandSubcommandBuilder, - SlashCommandSubcommandGroupBuilder, -} from 'discord.js'; +import { SlashCommandBuilder, SlashCommandSubcommandBuilder } from 'discord.js'; import { TREE_TYPE_NAMES, TreeTypeString } from '../../components/Tanks'; -import { encyclopediaInfo } from '../blitz/encyclopediaInfo'; -import addPeriodSubCommands from './addPeriodSubCommands'; +import { EncyclopediaInfo } from '../blitz/encyclopediaInfo'; import addTankChoices from './addTankChoices'; import addTierChoices from './addTierChoices'; -export default async function addFilterOptions< - OptionType extends SlashCommandBuilder | SlashCommandSubcommandGroupBuilder, ->( - option: OptionType, - extra: ( - option: SlashCommandSubcommandBuilder, - ) => SlashCommandSubcommandBuilder = (option) => option, -): Promise { - const awaitedEncyclopediaInfo = await encyclopediaInfo; - - addPeriodSubCommands(option, (option) => { - extra(option); - - return option - .addStringOption((option) => - option - .setName('nation') - .setDescription('Nation') - .addChoices( - ...Object.entries(awaitedEncyclopediaInfo.vehicle_nations).map( - ([type, name]) => ({ name, value: type }), - ), - ) - .setRequired(false), - ) - .addStringOption((option) => addTierChoices(option).setRequired(false)) - .addStringOption((option) => - option - .setName('tank-type') - .setDescription('Tank type') - .addChoices( - ...Object.entries(awaitedEncyclopediaInfo.vehicle_types).map( - ([type, name]) => ({ - name: name.replace(' Tank', ''), - value: type, - }), - ), - ) - .setRequired(false), - ) - .addStringOption((option) => - option - .setName('tree-type') - .setDescription('Tech tree type') - .addChoices( - { - name: TREE_TYPE_NAMES['researchable'], - value: 'researchable' satisfies TreeTypeString, - }, - { - name: TREE_TYPE_NAMES.premium, - value: 'premium' satisfies TreeTypeString, - }, - { - name: TREE_TYPE_NAMES.collector, - value: 'collector' satisfies TreeTypeString, - }, - ) - .setRequired(false), - ) - .addStringOption((option) => addTankChoices(option).setRequired(false)); - }); - - return option; +export function addFilterOptions< + Option extends SlashCommandBuilder | SlashCommandSubcommandBuilder, +>(option: Option, encyclopediaInfo: EncyclopediaInfo) { + return option + .addStringOption((option) => + option + .setName('nation') + .setDescription('Nation') + .addChoices( + ...Object.entries(encyclopediaInfo.vehicle_nations).map( + ([type, name]) => ({ name, value: type }), + ), + ) + .setRequired(false), + ) + .addStringOption((option) => addTierChoices(option).setRequired(false)) + .addStringOption((option) => + option + .setName('tank-type') + .setDescription('Tank type') + .addChoices( + ...Object.entries(encyclopediaInfo.vehicle_types).map( + ([type, name]) => ({ + name: name.replace(' Tank', ''), + value: type, + }), + ), + ) + .setRequired(false), + ) + .addStringOption((option) => + option + .setName('tree-type') + .setDescription('Tech tree type') + .addChoices( + { + name: TREE_TYPE_NAMES['researchable'], + value: 'researchable' satisfies TreeTypeString, + }, + { + name: TREE_TYPE_NAMES.premium, + value: 'premium' satisfies TreeTypeString, + }, + { + name: TREE_TYPE_NAMES.collector, + value: 'collector' satisfies TreeTypeString, + }, + ) + .setRequired(false), + ) + .addStringOption((option) => + addTankChoices(option).setRequired(false), + ) as Option; } diff --git a/src/core/discord/addPeriodicFilterOptions.ts b/src/core/discord/addPeriodicFilterOptions.ts new file mode 100644 index 000000000..7840258d3 --- /dev/null +++ b/src/core/discord/addPeriodicFilterOptions.ts @@ -0,0 +1,25 @@ +import { + SlashCommandBuilder, + SlashCommandSubcommandBuilder, + SlashCommandSubcommandGroupBuilder, +} from 'discord.js'; +import { encyclopediaInfo } from '../blitz/encyclopediaInfo'; +import { addFilterOptions } from './addFilterOptions'; +import addPeriodSubCommands from './addPeriodSubCommands'; + +export default async function addPeriodicFilterOptions< + OptionType extends SlashCommandBuilder | SlashCommandSubcommandGroupBuilder, +>( + option: OptionType, + extra: ( + option: SlashCommandSubcommandBuilder, + ) => SlashCommandSubcommandBuilder = (option) => option, +): Promise { + const awaitedEncyclopediaInfo = await encyclopediaInfo; + + addPeriodSubCommands(option, (option) => { + return addFilterOptions(extra(option), awaitedEncyclopediaInfo); + }); + + return option; +} diff --git a/src/core/discord/getCustomPeriodParams.ts b/src/core/discord/getCustomPeriodParams.ts index 55b7b8aa0..1b35e96e8 100644 --- a/src/core/discord/getCustomPeriodParams.ts +++ b/src/core/discord/getCustomPeriodParams.ts @@ -3,12 +3,17 @@ import { PeriodType } from './addPeriodSubCommands'; export function getCustomPeriodParams( interaction: ChatInputCommandInteraction, + forcedNonCustomPeriod = false, ) { - if ((interaction.options.getSubcommand() as PeriodType) === 'custom') + if ( + !forcedNonCustomPeriod && + (interaction.options.getSubcommand() as PeriodType) === 'custom' + ) { return { start: interaction.options.getInteger('start', true), end: interaction.options.getInteger('end', true), }; + } return undefined; } diff --git a/src/core/discord/resolvePeriodFromCommand.ts b/src/core/discord/resolvePeriodFromCommand.ts index a09da237d..fc09987aa 100644 --- a/src/core/discord/resolvePeriodFromCommand.ts +++ b/src/core/discord/resolvePeriodFromCommand.ts @@ -20,11 +20,14 @@ export interface ResolvedPeriod { export default function resolvePeriodFromCommand( region: Region, interaction: ChatInputCommandInteraction, + forcedPeriod?: PeriodType, ) { let name: string; let start: number; let end: number; - const periodSubcommand = interaction.options.getSubcommand() as PeriodType; + const periodSubcommand = forcedPeriod + ? forcedPeriod + : (interaction.options.getSubcommand() as PeriodType); if (periodSubcommand === 'custom') { const startOption = interaction.options.getInteger('start', true); diff --git a/src/events/interactionCreate/handlers/chatInputCommand.ts b/src/events/interactionCreate/handlers/chatInputCommand.ts index adb323b07..7d2699725 100644 --- a/src/events/interactionCreate/handlers/chatInputCommand.ts +++ b/src/events/interactionCreate/handlers/chatInputCommand.ts @@ -19,7 +19,8 @@ import { psa } from '../../../core/discord/psa'; export default async function handleChatInputCommand( interaction: ChatInputCommandInteraction, ) { - const registry = (await commands)[interaction.commandName]; + const awaitedCommands = await commands; + const registry = awaitedCommands[interaction.commandName]; await interaction.deferReply(); diff --git a/src/events/interactionCreate/index.ts b/src/events/interactionCreate/index.ts index 0b7aeadcb..3e7393199 100644 --- a/src/events/interactionCreate/index.ts +++ b/src/events/interactionCreate/index.ts @@ -31,6 +31,7 @@ import { searchClansCommand } from '../../commands/searchClans'; import { searchPlayersCommand } from '../../commands/searchPlayers'; import { searchTanksCommand } from '../../commands/searchTanks'; import { statsCommand } from '../../commands/stats'; +import { todayCommand } from '../../commands/today'; import getClientId from '../../core/blitzkrieg/getClientId'; import isDev from '../../core/blitzkrieg/isDev'; import { secrets } from '../../core/blitzkrieg/secrets'; @@ -52,34 +53,39 @@ export type InteractionReturnable = | InteractionIterableReturnable | Promise; -export interface Registry { - inProduction: boolean; -} - -export type CommandRegistryRaw = Registry & { - inPublic: boolean; - inPreview?: boolean; - +type CommandRegistryBase = { command: | SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder | Omit; +}; +type CommandRegistryDefinitionBase = { + inProduction: boolean; + inPublic: boolean; + inPreview?: boolean; autocomplete?: (interaction: AutocompleteInteraction) => void; button?: (interaction: ButtonInteraction) => InteractionReturnable; -} & ( - | { - handlesInteraction: true; - handler: (interaction: ChatInputCommandInteraction) => void; - } - | { - handlesInteraction?: false; - handler: ( - interaction: ChatInputCommandInteraction, - ) => InteractionReturnable; - } - ); -export type CommandRegistry = CommandRegistryRaw | Promise; +}; +type CommandRegistryDefinitionHandlesInteraction = { + handlesInteraction: true; + handler: (interaction: ChatInputCommandInteraction) => void; +}; +type CommandRegistryDefinitionDoesHandleInteraction = { + handlesInteraction?: false; + handler: ( + interaction: ChatInputCommandInteraction, + ) => InteractionReturnable; +}; +export type CommandRegistryPromisable = CommandRegistryBase & + (CommandRegistryDefinitionBase & + ( + | CommandRegistryDefinitionHandlesInteraction + | CommandRegistryDefinitionDoesHandleInteraction + )); +export type CommandRegistry = + | CommandRegistryPromisable + | Promise; const rest = new REST().setToken(secrets.DISCORD_TOKEN); @@ -101,10 +107,11 @@ export const COMMANDS_RAW: CommandRegistry[] = [ evolutionCommand, statsCommand, ratingsCommand, + todayCommand, ]; export const commands = Promise.all(COMMANDS_RAW).then((rawCommands) => - rawCommands.reduce>( + rawCommands.reduce>( (accumulator, registry) => { if (isDev()) registry.command.setDefaultMemberPermissions(0); @@ -123,7 +130,7 @@ export const publicCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = []; commands.then((awaitedCommands) => { - Object.entries(awaitedCommands).forEach(([, registry]) => { + Object.values(awaitedCommands).forEach((registry) => { const json = registry.command.toJSON(); if (registry.inPublic) {