diff --git a/apps/discord-bot/src/commands/base.hypixel-command.ts b/apps/discord-bot/src/commands/base.hypixel-command.ts index 3d32a4f2e..f5b63ab42 100644 --- a/apps/discord-bot/src/commands/base.hypixel-command.ts +++ b/apps/discord-bot/src/commands/base.hypixel-command.ts @@ -7,16 +7,20 @@ */ import { + AbstractCommandListener, ApiService, Command, CommandContext, + ComponentRoute, LocalizationString, LocalizeFunction, Page, paginate, PlayerArgument, SubPage, + createCustomId, } from "@statsify/discord"; +import { CacheLevel } from "@statsify/api-client"; import { Container } from "typedi"; import { GamesWithBackgrounds, mapBackground } from "#constants"; import { HistoricalTimeData } from "#components"; @@ -47,6 +51,19 @@ export interface ProfileData { export type ModeEmoji = LocalizationString | false | undefined; +/** Page buttons re-run the command: `p::::` */ +export const PROFILE_ROUTE = "p"; + +export function profileRoute(listener: AbstractCommandListener): ComponentRoute { + return (interaction, { ownerId, values: [command, uuid] }) => + listener.rerunCommand(interaction, ownerId, [command], { player: uuid }); +} + +export function getProfileCustomId(context: CommandContext, uuid: string) { + return (page: string) => + createCustomId(PROFILE_ROUTE, context.getInteraction().getUserId(), context.getCommandName(), uuid, page); +} + export interface BaseHypixelCommand { getPreProfileData?(player: Player): K | Promise; filterModes?(player: Player, modes: GameModeWithSubModes[]): GameModeWithSubModes[]; @@ -69,8 +86,13 @@ export abstract class BaseHypixelCommand { diff --git a/packages/discord/src/command/command.context.ts b/packages/discord/src/command/command.context.ts index 86e6b2ad8..9f5ee927e 100644 --- a/packages/discord/src/command/command.context.ts +++ b/packages/discord/src/command/command.context.ts @@ -33,6 +33,11 @@ export class CommandContext { return this.user; } + /** The name of the command or subcommand being run, without its parents */ + public getCommandName(): string { + return this.data.name; + } + public setUser(user: User | null) { this.user = user; if (user?.locale) this.interaction.setLocale(user?.locale); diff --git a/packages/discord/src/services/api.service.ts b/packages/discord/src/services/api.service.ts index a51d79bd1..0f8892d95 100644 --- a/packages/discord/src/services/api.service.ts +++ b/packages/discord/src/services/api.service.ts @@ -41,14 +41,19 @@ export class ApiService extends StatsifyApiService { * * @param tag Username, UUID, or Discord ID, or nothing. If nothing is provided it will attempt to fall back on the provided user. * @param user User to use if no tag is provided. + * @param cache Defaults to live stats for Gold users and recently cached stats for everyone else * @returns a Player */ - public async getPlayer(tag: string, user: User | null = null) { + public async getPlayer( + tag: string, + user: User | null = null, + cache = User.isGold(user) ? CacheLevel.LIVE : CacheLevel.CACHE + ) { const [formattedTag, type] = this.parseTag(tag); const input = await this.resolveTag(formattedTag, type, user); return super - .getCachedPlayer(input, User.isGold(user) ? CacheLevel.LIVE : CacheLevel.CACHE) + .getCachedPlayer(input, cache) .catch((err) => { if (!err.response || !err.response.data) throw this.unknownError(); const error = err.response.data as PlayerNotFoundException; diff --git a/packages/discord/src/services/paginate.service.ts b/packages/discord/src/services/paginate.service.ts index ac7295d12..671409e6f 100644 --- a/packages/discord/src/services/paginate.service.ts +++ b/packages/discord/src/services/paginate.service.ts @@ -20,7 +20,7 @@ import { SelectMenuBuilder, SelectMenuOptionBuilder, } from "#messages"; -import { Interaction } from "#interaction"; +import { Interaction, parseCustomId } from "#interaction"; import type { AbstractCommandListener, CommandContext, @@ -53,12 +53,13 @@ type PageId = `${number}|${number}`; * * @param context The context of the command * @param pages The array of pages to paginate against - * @param timeout When to stop the pagination (ms), defaults to 300000 + * @param getCustomId Returns a custom id ending with `page`, whose route re-runs the command. + * Without it, the buttons stop after 5 minutes */ export async function paginate( context: CommandContext, pages: Page[], - timeout = 300_000, + getCustomId?: (page: string) => string, ) { const cache = new Map(); const t = context.t(); @@ -86,6 +87,25 @@ export async function paginate( // If there is only one page with no sub pages, return the message immediately without pagination if (pages.length === 1 && "generator" in pages[0]) return getMessage(0, 0); + if (getCustomId) { + let [index, subIndex] = getRequestedPage(context.getInteraction()); + if (index >= pages.length) [index, subIndex] = [0, 0]; + + const page = pages[index]; + if ("subPages" in page && subIndex >= page.subPages.length) subIndex = 0; + + const message = await getMessage(index, subIndex); + + message.components = [ + new PageController(pages, index, getCustomId).getActionRow(), + "subPages" in page && page.subPages.length > 1 ? + new PageController(page.subPages, subIndex, getCustomId, index).getActionRow() : + undefined, + ].filter((row) => row !== undefined); + + return message; + } + const userId = context.getInteraction().getUserId(); const listener = context.getListener(); @@ -164,7 +184,7 @@ export async function paginate( return context.reply({ components: [] }); } - setTimeout(onTimeout, timeout); + setTimeout(onTimeout, 300_000); const message = await getMessage(currentIndex, currentSubIndex); message.components = [ @@ -267,6 +287,17 @@ export async function scrollingPagination( return message; } +function getRequestedPage(interaction: Interaction): [index: number, subIndex: number] { + if (!interaction.isMessageComponentInteraction()) return [0, 0]; + + const target = interaction.isSelectMenuInteraction() ? + interaction.getData().values[0] : + parseCustomId(interaction.getCustomId()).values.at(-1)!; + + const [index, subIndex = 0] = target.split(".").map(Number); + return [index, subIndex]; +} + async function contentToMessage( content: PaginateInteractionContent, ): Promise { @@ -291,14 +322,22 @@ async function contentToMessage( class PageController { #menu: SelectMenuBuilder | ButtonBuilder[]; - public constructor(pages: PageInput[], selected: number) { + public constructor( + pages: PageInput[], + selected: number, + getCustomId?: (page: string) => string, + parentIndex?: number + ) { + const target = (index: number) => (parentIndex === undefined ? `${index}` : `${parentIndex}.${index}`); + if (pages.length > 5) { const menu = new SelectMenuBuilder(); + if (getCustomId) menu.customId(getCustomId(parentIndex === undefined ? "menu" : "submenu")); for (const [index, page] of pages.entries()) { const option = new SelectMenuOptionBuilder() .label(page.label) - .value(`${index}`); + .value(target(index)); if (page.emoji) option.emoji(page.emoji); menu.option(option); } @@ -310,6 +349,7 @@ class PageController { const button = new ButtonBuilder() .label(page.label) .style(ButtonStyle.Secondary); + if (getCustomId) button.customId(getCustomId(target(index))); if (page.emoji) button.emoji(page.emoji); if (index === selected) button.style(ButtonStyle.Primary); return button;