From 7d0b528f05b446ab165ebf85c361a42fa51e488c Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:33:00 -0500 Subject: [PATCH 1/6] feat(discord-bot): keep profile page buttons working after restarts --- .../src/commands/base.hypixel-command.ts | 24 ++++++++- apps/discord-bot/src/lib/command.listener.ts | 3 ++ .../discord/src/command/command.context.ts | 5 ++ packages/discord/src/services/api.service.ts | 9 +++- .../discord/src/services/paginate.service.ts | 54 ++++++++++++++++--- 5 files changed, 85 insertions(+), 10 deletions(-) diff --git a/apps/discord-bot/src/commands/base.hypixel-command.ts b/apps/discord-bot/src/commands/base.hypixel-command.ts index 3d32a4f2e..083edbecc 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,14 @@ 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 interface BaseHypixelCommand { getPreProfileData?(player: Player): K | Promise; filterModes?(player: Player, modes: GameModeWithSubModes[]): GameModeWithSubModes[]; @@ -69,8 +81,14 @@ export abstract class BaseHypixelCommand + createCustomId(PROFILE_ROUTE, interaction.getUserId(), context.getCommandName(), player.uuid, page) + ); } public abstract getProfile( diff --git a/apps/discord-bot/src/lib/command.listener.ts b/apps/discord-bot/src/lib/command.listener.ts index f7094b913..cffaf8367 100644 --- a/apps/discord-bot/src/lib/command.listener.ts +++ b/apps/discord-bot/src/lib/command.listener.ts @@ -17,6 +17,7 @@ import { Interaction, } from "@statsify/discord"; import { Container } from "typedi"; +import { PROFILE_ROUTE, profileRoute } from "#commands/base.hypixel-command"; import { STATUS_COLORS } from "@statsify/logger"; import { User, UserTier } from "@statsify/schemas"; import { config, formatTime } from "@statsify/util"; @@ -49,6 +50,8 @@ export class CommandListener extends AbstractCommandListener { this.apiService = Container.get(ApiService); this.cooldowns = new Map(); + + this.addRoute(PROFILE_ROUTE, profileRoute(this)); } protected async onCommand(interaction: Interaction): Promise { 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..5fa6d8d91 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 customId Makes the page buttons keep working after a restart. It returns a custom id + * that ends 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, + customId?: (page: string) => string, ) { const cache = new Map(); const t = context.t(); @@ -86,6 +87,22 @@ 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 (customId) { + const [index, subIndex] = getRequestedPage(context.getInteraction()); + const page = pages[index]; + + const message = await getMessage(index, subIndex); + + message.components = [ + new PageController(pages, index, customId).getActionRow(), + "subPages" in page && page.subPages.length > 1 ? + new PageController(page.subPages, subIndex, customId, index).getActionRow() : + undefined, + ].filter((row) => row !== undefined); + + return message; + } + const userId = context.getInteraction().getUserId(); const listener = context.getListener(); @@ -164,7 +181,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 +284,18 @@ export async function scrollingPagination( return message; } +/** The page a click asks for: the select menu's value, or the end of the button's custom id */ +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 +320,26 @@ async function contentToMessage( class PageController { #menu: SelectMenuBuilder | ButtonBuilder[]; - public constructor(pages: PageInput[], selected: number) { + /** + * @param customId Gives the buttons custom ids that keep working after a restart (see paginate) + * @param parent The page these sub pages belong to, so their targets read `.` + */ + public constructor( + pages: PageInput[], + selected: number, + customId?: (page: string) => string, + parent?: number + ) { + const target = (index: number) => (parent === undefined ? `${index}` : `${parent}.${index}`); + if (pages.length > 5) { const menu = new SelectMenuBuilder(); + if (customId) menu.customId(customId(parent === 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 +351,7 @@ class PageController { const button = new ButtonBuilder() .label(page.label) .style(ButtonStyle.Secondary); + if (customId) button.customId(customId(target(index))); if (page.emoji) button.emoji(page.emoji); if (index === selected) button.style(ButtonStyle.Primary); return button; From a8d5ce2791733c941a92cfe6e490ca94f38901b3 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:35:16 -0500 Subject: [PATCH 2/6] fix(discord): show the first page when a page button points past the end --- packages/discord/src/services/paginate.service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/discord/src/services/paginate.service.ts b/packages/discord/src/services/paginate.service.ts index 5fa6d8d91..42d5104ef 100644 --- a/packages/discord/src/services/paginate.service.ts +++ b/packages/discord/src/services/paginate.service.ts @@ -88,8 +88,11 @@ export async function paginate( if (pages.length === 1 && "generator" in pages[0]) return getMessage(0, 0); if (customId) { - const [index, subIndex] = getRequestedPage(context.getInteraction()); + 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); From 6e5818e1b7282ff6cfe860f3b973938b7628842f Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:38:00 -0500 Subject: [PATCH 3/6] refactor(discord): name the sub page controller's parent index --- packages/discord/src/services/paginate.service.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/discord/src/services/paginate.service.ts b/packages/discord/src/services/paginate.service.ts index 42d5104ef..76ed99e79 100644 --- a/packages/discord/src/services/paginate.service.ts +++ b/packages/discord/src/services/paginate.service.ts @@ -323,21 +323,17 @@ async function contentToMessage( class PageController { #menu: SelectMenuBuilder | ButtonBuilder[]; - /** - * @param customId Gives the buttons custom ids that keep working after a restart (see paginate) - * @param parent The page these sub pages belong to, so their targets read `.` - */ public constructor( pages: PageInput[], selected: number, customId?: (page: string) => string, - parent?: number + parentIndex?: number ) { - const target = (index: number) => (parent === undefined ? `${index}` : `${parent}.${index}`); + const target = (index: number) => (parentIndex === undefined ? `${index}` : `${parentIndex}.${index}`); if (pages.length > 5) { const menu = new SelectMenuBuilder(); - if (customId) menu.customId(customId(parent === undefined ? "menu" : "submenu")); + if (customId) menu.customId(customId(parentIndex === undefined ? "menu" : "submenu")); for (const [index, page] of pages.entries()) { const option = new SelectMenuOptionBuilder() From 238d8fff4e4b4511e2525dc23223c426f1f96aaf Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:38:07 -0500 Subject: [PATCH 4/6] docs(discord): cut comments that restate the page route code --- apps/discord-bot/src/commands/base.hypixel-command.ts | 1 - packages/discord/src/services/paginate.service.ts | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/discord-bot/src/commands/base.hypixel-command.ts b/apps/discord-bot/src/commands/base.hypixel-command.ts index 083edbecc..2c7b38e93 100644 --- a/apps/discord-bot/src/commands/base.hypixel-command.ts +++ b/apps/discord-bot/src/commands/base.hypixel-command.ts @@ -83,7 +83,6 @@ export abstract class BaseHypixelCommand Date: Thu, 5 Nov 2026 11:38:51 -0500 Subject: [PATCH 5/6] refactor(discord): name paginate's custom id builder getCustomId --- .../discord/src/services/paginate.service.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/discord/src/services/paginate.service.ts b/packages/discord/src/services/paginate.service.ts index 558e21e4a..671409e6f 100644 --- a/packages/discord/src/services/paginate.service.ts +++ b/packages/discord/src/services/paginate.service.ts @@ -53,13 +53,13 @@ type PageId = `${number}|${number}`; * * @param context The context of the command * @param pages The array of pages to paginate against - * @param customId Returns a custom id ending with `page`, whose route re-runs the command. + * @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[], - customId?: (page: string) => string, + getCustomId?: (page: string) => string, ) { const cache = new Map(); const t = context.t(); @@ -87,7 +87,7 @@ 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 (customId) { + if (getCustomId) { let [index, subIndex] = getRequestedPage(context.getInteraction()); if (index >= pages.length) [index, subIndex] = [0, 0]; @@ -97,9 +97,9 @@ export async function paginate( const message = await getMessage(index, subIndex); message.components = [ - new PageController(pages, index, customId).getActionRow(), + new PageController(pages, index, getCustomId).getActionRow(), "subPages" in page && page.subPages.length > 1 ? - new PageController(page.subPages, subIndex, customId, index).getActionRow() : + new PageController(page.subPages, subIndex, getCustomId, index).getActionRow() : undefined, ].filter((row) => row !== undefined); @@ -325,14 +325,14 @@ class PageController { public constructor( pages: PageInput[], selected: number, - customId?: (page: string) => string, + getCustomId?: (page: string) => string, parentIndex?: number ) { const target = (index: number) => (parentIndex === undefined ? `${index}` : `${parentIndex}.${index}`); if (pages.length > 5) { const menu = new SelectMenuBuilder(); - if (customId) menu.customId(customId(parentIndex === undefined ? "menu" : "submenu")); + if (getCustomId) menu.customId(getCustomId(parentIndex === undefined ? "menu" : "submenu")); for (const [index, page] of pages.entries()) { const option = new SelectMenuOptionBuilder() @@ -349,7 +349,7 @@ class PageController { const button = new ButtonBuilder() .label(page.label) .style(ButtonStyle.Secondary); - if (customId) button.customId(customId(target(index))); + if (getCustomId) button.customId(getCustomId(target(index))); if (page.emoji) button.emoji(page.emoji); if (index === selected) button.style(ButtonStyle.Primary); return button; From aa5c0c9c891c58a18caa10a77c073b088ac5b24e Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:09:48 -0500 Subject: [PATCH 6/6] refactor(discord-bot): build profile page custom ids with getProfileCustomId --- apps/discord-bot/src/commands/base.hypixel-command.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/discord-bot/src/commands/base.hypixel-command.ts b/apps/discord-bot/src/commands/base.hypixel-command.ts index 2c7b38e93..f5b63ab42 100644 --- a/apps/discord-bot/src/commands/base.hypixel-command.ts +++ b/apps/discord-bot/src/commands/base.hypixel-command.ts @@ -59,6 +59,11 @@ export function profileRoute(listener: AbstractCommandListener): ComponentRoute 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[]; @@ -174,9 +179,7 @@ export abstract class BaseHypixelCommand - createCustomId(PROFILE_ROUTE, interaction.getUserId(), context.getCommandName(), player.uuid, page) - ); + return paginate(context, pages, getProfileCustomId(context, player.uuid)); } public abstract getProfile(