diff --git a/apps/discord-bot/src/commands/base.hypixel-command.ts b/apps/discord-bot/src/commands/base.hypixel-command.ts index f5b63ab42..796dbc1b5 100644 --- a/apps/discord-bot/src/commands/base.hypixel-command.ts +++ b/apps/discord-bot/src/commands/base.hypixel-command.ts @@ -20,7 +20,6 @@ import { SubPage, createCustomId, } from "@statsify/discord"; -import { CacheLevel } from "@statsify/api-client"; import { Container } from "typedi"; import { GamesWithBackgrounds, mapBackground } from "#constants"; import { HistoricalTimeData } from "#components"; @@ -51,17 +50,17 @@ export interface ProfileData { export type ModeEmoji = LocalizationString | false | undefined; -/** Page buttons re-run the command: `p::::` */ +/** 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 }); + listener.rerunCommand(interaction, ownerId, command.split("."), { player: uuid }); } -export function getProfileCustomId(context: CommandContext, uuid: string) { +export function getProfileCustomId(context: CommandContext, uuid: string, command = context.getCommandName()) { return (page: string) => - createCustomId(PROFILE_ROUTE, context.getInteraction().getUserId(), context.getCommandName(), uuid, page); + createCustomId(PROFILE_ROUTE, context.getInteraction().getUserId(), command, uuid, page); } export interface BaseHypixelCommand { @@ -86,13 +85,8 @@ export abstract class BaseHypixelCommand; type GuildTopKey = "daily" | "weekly" | "monthly" | number; - -interface GuildTopPageState { - modeIndex: number; - page: number; -} +type GuildTopMode = [key: GuildTopKey, title: string]; export class GuildTopSubCommand extends GuildLeaderboardSubCommand { @SubCommand({ description: (t) => t("commands.guild-top"), args: GuildArgument }) public async top(context: CommandContext) { - const userId = context.getInteraction().getUserId(); + const interaction = context.getInteraction(); const user = context.getUser(); const t = context.t(); @@ -61,12 +59,7 @@ export class GuildTopSubCommand extends GuildLeaderboardSubCommand { t, }; - const theme = getTheme(user); - - const up = new ButtonBuilder().emoji(t("emojis:up")).style(ButtonStyle.Success); - const down = new ButtonBuilder().emoji(t("emojis:down")).style(ButtonStyle.Danger); - - const modes: [GuildTopKey, string][] = [ + const modes: GuildTopMode[] = [ ["daily", "Today"], ["weekly", "This Week"], ["monthly", "This Month"], @@ -78,98 +71,50 @@ export class GuildTopSubCommand extends GuildLeaderboardSubCommand { [6, "6 days ago"], ]; - const dropdown = new SelectMenuBuilder(); + const pageCount = Math.ceil(guild.members.length / GUILD_TOP_PAGE_SIZE); + const [modeIndex, page] = this.getRequestedPage(interaction, modes, pageCount); + const [key, title] = modes[modeIndex]; + + const getCustomId = (...values: (string | number)[]) => + createCustomId(GUILD_ROUTE, interaction.getUserId(), context.getCommandName(), guild.id, ...values); + + const dropdown = new SelectMenuBuilder().customId(getCustomId("menu", page)); for (const [index, [key, title]] of modes.entries()) { dropdown.option( new SelectMenuOptionBuilder() .label(title) .value(`${key}`) - .default(index === 0) + .default(index === modeIndex) ); } const components = [new ActionRowBuilder().component(dropdown)]; - const pageCount = Math.ceil(guild.members.length / GUILD_TOP_PAGE_SIZE); if (pageCount > 1) - components.push(new ActionRowBuilder().component(up).component(down)); - - let page = 0; - let modeIndex = 0; - - const cache = new Map(); - const listener = CommandListener.getInstance(); - - const changePage = - (fn: (interaction: Interaction) => Partial) => - async (interaction: Interaction) => { - if (user?.locale) interaction.setLocale(user.locale); - - const state = fn(interaction); - - const mode = modes[state.modeIndex ?? modeIndex]; - - const message = await this.getGuildTopPageMessage( - cache, - components, - props, - mode[1], - mode[0], - state.page ?? page, - theme - ); - - if (interaction.getUserId() === userId) { - if (state.page !== undefined) page = state.page; - if (state.modeIndex !== undefined) modeIndex = state.modeIndex; - - dropdown.activeOption(modeIndex); - - context.reply(message); - } else { - interaction.sendFollowup({ ...message, components: [], ephemeral: true }); - } - }; - - listener.addHook( - up.getCustomId(), - changePage(() => ({ page: page - 1 < 0 ? pageCount - 1 : page - 1 })) - ); - - listener.addHook( - down.getCustomId(), - changePage(() => ({ page: page + 1 > pageCount - 1 ? 0 : page + 1 })) - ); - - listener.addHook( - dropdown.getCustomId(), - changePage((interaction) => { - const value = interaction.getData().values[0]; - return { modeIndex: modes.findIndex(([key]) => `${key}` === value) }; - }) - ); - - setTimeout(() => { - context.reply({ components: [] }); - cache.clear(); + components.push( + new ActionRowBuilder() + .component( + new ButtonBuilder() + .emoji(t("emojis:up")) + .style(ButtonStyle.Success) + .customId(getCustomId(key, `prev.${page}`)) + ) + .component( + new ButtonBuilder() + .emoji(t("emojis:down")) + .style(ButtonStyle.Danger) + .customId(getCustomId(key, `next.${page}`)) + ) + ); - listener.removeHook(up.getCustomId()); - listener.removeHook(down.getCustomId()); - listener.removeHook(dropdown.getCustomId()); - }, 300_000); + const image = await this.generateGuildTopPage(props, title, key, page, getTheme(user)); - const message = await this.getGuildTopPageMessage( - cache, + return { + files: [{ name: "guild-top.png", data: image, type: "image/png" }], + attachments: [], components, - props, - modes[modeIndex][1], - modes[modeIndex][0], - page, - theme - ); - - return message; + }; } protected getGuild(context: CommandContext) { @@ -177,34 +122,23 @@ export class GuildTopSubCommand extends GuildLeaderboardSubCommand { const query = context.option("query"); const type = context.option("type"); - return this.apiService.getGuild(query, type, user); + return this.apiService.getGuild(query, type, user, context.getCacheLevel()); } - private async getGuildTopPageMessage( - cache: Map, - components: ActionRowBuilder[], - props: BaseGuildTopProfileProps, - title: string, - key: GuildTopKey, - page: number, - theme?: Theme - ): Promise { - const cacheKey = `${key}-${page}`; + private getRequestedPage( + interaction: Interaction, + modes: GuildTopMode[], + pageCount: number + ): [modeIndex: number, page: number] { + if (!interaction.isMessageComponentInteraction()) return [0, 0]; - let image: Buffer; + const [, , key, target] = parseCustomId(interaction.getCustomId()).values; + const findMode = (key: string) => modes.findIndex(([mode]) => `${mode}` === key); - if (cache.has(cacheKey)) { - image = cache.get(cacheKey)!; - } else { - image = await this.generateGuildTopPage(props, title, key, page, theme); - cache.set(cacheKey, image); - } + if (interaction.isSelectMenuInteraction()) + return [findMode(interaction.getData().values[0]), Number(target)]; - return { - files: [{ name: "guild-top.png", data: image, type: "image/png" }], - attachments: [], - components, - }; + return [findMode(key), getScrolledPage(target, pageCount)]; } private async generateGuildTopPage( diff --git a/apps/discord-bot/src/commands/guild/guild.route.ts b/apps/discord-bot/src/commands/guild/guild.route.ts new file mode 100644 index 000000000..94e1e9247 --- /dev/null +++ b/apps/discord-bot/src/commands/guild/guild.route.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import type { AbstractCommandListener, ComponentRoute } from "@statsify/discord"; + +/** Guild buttons re-run `/guild `: `g::::...` */ +export const GUILD_ROUTE = "g"; + +export function guildRoute(listener: AbstractCommandListener): ComponentRoute { + return (interaction, { ownerId, values: [subcommand, guildId] }) => + listener.rerunCommand(interaction, ownerId, ["guild", subcommand], { query: guildId }); +} diff --git a/apps/discord-bot/src/commands/rankings/rankings.command.tsx b/apps/discord-bot/src/commands/rankings/rankings.command.tsx index 15966236e..28449e2f3 100644 --- a/apps/discord-bot/src/commands/rankings/rankings.command.tsx +++ b/apps/discord-bot/src/commands/rankings/rankings.command.tsx @@ -53,6 +53,7 @@ import { } from "@statsify/discord"; import { ButtonStyle } from "discord-api-types/v10"; import { type GamesWithBackgrounds, mapBackground } from "#constants"; +import { getProfileCustomId } from "#commands/base.hypixel-command"; import { RankingsProfile } from "./rankings.profile.js"; import { arrayGroup } from "@statsify/util"; import { games } from "./games.js"; @@ -309,7 +310,7 @@ export class RankingsCommand { const user = context.getUser(); const t = context.t(); - const player = await this.apiService.getPlayer(context.option("player"), user); + const player = await this.apiService.getPlayer(context.option("player"), user, context.getCacheLevel()); const isGameNotAll = game !== "all"; @@ -365,7 +366,8 @@ export class RankingsCommand { ), new ButtonBuilder().emoji(t("emojis:up")).style(ButtonStyle.Success), new ButtonBuilder().emoji(t("emojis:down")).style(ButtonStyle.Danger), - true + true, + getProfileCustomId(context, player.uuid, `rankings.${context.getCommandName()}`) ); } } diff --git a/apps/discord-bot/src/lib/command.listener.ts b/apps/discord-bot/src/lib/command.listener.ts index 7b9fbe789..a3dc64005 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 { GUILD_ROUTE, guildRoute } from "#commands/guild/guild.route"; import { LEADERBOARD_ROUTE, leaderboardRoute } from "#commands/leaderboards/leaderboard.route"; import { PROFILE_ROUTE, profileRoute } from "#commands/base.hypixel-command"; import { STATUS_COLORS } from "@statsify/logger"; @@ -54,6 +55,7 @@ export class CommandListener extends AbstractCommandListener { this.addRoute(PROFILE_ROUTE, profileRoute(this)); this.addRoute(LEADERBOARD_ROUTE, leaderboardRoute(this)); + this.addRoute(GUILD_ROUTE, guildRoute(this)); } protected async onCommand(interaction: Interaction): Promise { diff --git a/packages/api-client/src/api.service.ts b/packages/api-client/src/api.service.ts index f43108367..c6582052c 100644 --- a/packages/api-client/src/api.service.ts +++ b/packages/api-client/src/api.service.ts @@ -113,10 +113,11 @@ export class ApiService { ); } - public getGuild(tag: string, type: GuildQuery) { + public getCachedGuild(tag: string, type: GuildQuery, cache: CacheLevel) { return this.requestKey("/guild", "guild", { guild: tag, type, + cache, }); } diff --git a/packages/discord/src/command/command.context.ts b/packages/discord/src/command/command.context.ts index 9f5ee927e..4fe20b078 100644 --- a/packages/discord/src/command/command.context.ts +++ b/packages/discord/src/command/command.context.ts @@ -10,6 +10,7 @@ import { type APIApplicationCommandInteractionDataBasicOption, ApplicationCommandOptionType, } from "discord-api-types/v10"; +import { CacheLevel } from "@statsify/api-client"; import { User } from "@statsify/schemas"; import { noop } from "@statsify/util"; import type { AbstractCommandListener } from "./abstract-command.listener.js"; @@ -38,6 +39,11 @@ export class CommandContext { return this.data.name; } + /** A button re-runs its command, so a click reads cached stats and never asks Hypixel */ + public getCacheLevel() { + return this.interaction.isCommandInteraction() ? undefined : CacheLevel.CACHE_ONLY; + } + 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 0f8892d95..d431a3335 100644 --- a/packages/discord/src/services/api.service.ts +++ b/packages/discord/src/services/api.service.ts @@ -125,10 +125,11 @@ export class ApiService extends StatsifyApiService { }); } - public override async getGuild( + public async getGuild( tag: string, type?: GuildQuery, - user: User | null = null + user: User | null = null, + cache = CacheLevel.CACHE ) { let input: string; let playerType: PlayerTag; @@ -155,7 +156,7 @@ export class ApiService extends StatsifyApiService { input = tag; } - return super.getGuild(input, resolvedType).catch((err) => { + return super.getCachedGuild(input, resolvedType, cache).catch((err) => { if (!err.response || !err.response.data) throw this.unknownError(); const error = err.response.data as GuildNotFoundException | PlayerNotFoundException; diff --git a/packages/discord/src/services/paginate.service.ts b/packages/discord/src/services/paginate.service.ts index 671409e6f..192e28cfb 100644 --- a/packages/discord/src/services/paginate.service.ts +++ b/packages/discord/src/services/paginate.service.ts @@ -202,8 +202,8 @@ export async function paginate( * @param forwardButton The button to use for forward pagination * @param backwardButton The button to use for back pagination * @param invertButtons Whether to invert the buttons (backward becomes forward, forward becomes backward) - * @param startingIndex The starting page - * @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 scrollingPagination( context: CommandContext, @@ -211,10 +211,9 @@ export async function scrollingPagination( forwardButton?: ButtonBuilder, backwardButton?: ButtonBuilder, invertButtons = false, - startingIndex = 0, - timeout = 300_000, + getCustomId?: (page: string) => string, ) { - let currentIndex = startingIndex; + let currentIndex = 0; const cache = new Map(); const t = context.t(); @@ -230,8 +229,6 @@ export async function scrollingPagination( if (pages.length === 1) return getMessage(currentIndex); - const userId = context.getInteraction().getUserId(); - const controller = [ backwardButton ?? new ButtonBuilder().emoji(t("emojis:backward")), forwardButton ?? new ButtonBuilder().emoji(t("emojis:forward")), @@ -239,6 +236,23 @@ export async function scrollingPagination( if (invertButtons) controller.reverse(); + if (getCustomId) { + const interaction = context.getInteraction(); + + const index = interaction.isMessageComponentInteraction() ? + getScrolledPage(parseCustomId(interaction.getCustomId()).values.at(-1)!, pages.length) : + 0; + + controller[0].customId(getCustomId(`prev.${index}`)); + controller[1].customId(getCustomId(`next.${index}`)); + + const message = await getMessage(index); + message.components = [new ActionRowBuilder(controller)]; + + return message; + } + + const userId = context.getInteraction().getUserId(); const listener = context.getListener(); for (const [index, component] of controller.entries()) { @@ -279,7 +293,7 @@ export async function scrollingPagination( cache.clear(); } - setTimeout(onTimeout, timeout); + setTimeout(onTimeout, 300_000); const message = await getMessage(currentIndex); message.components = [new ActionRowBuilder(controller)]; @@ -287,6 +301,12 @@ export async function scrollingPagination( return message; } +/** Ids hold the current page, since with two pages both buttons would target the same one */ +export function getScrolledPage(target: string, pageCount: number) { + const [direction, page] = target.split("."); + return (Number(page) + (direction === "prev" ? pageCount - 1 : 1)) % pageCount; +} + function getRequestedPage(interaction: Interaction): [index: number, subIndex: number] { if (!interaction.isMessageComponentInteraction()) return [0, 0];