Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions apps/discord-bot/src/commands/base.hypixel-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -51,17 +50,17 @@ export interface ProfileData<T extends GamesWithBackgrounds, K = never> {

export type ModeEmoji = LocalizationString | false | undefined;

/** Page buttons re-run the command: `p:<user id>:<command>:<player uuid>:<page>` */
/** Page buttons re-run the command: `p:<user id>:<command path joined by dots>:<player uuid>:<page>` */
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<T extends GamesWithBackgrounds, K = never> {
Expand All @@ -86,13 +85,8 @@ export abstract class BaseHypixelCommand<T extends GamesWithBackgrounds, K = nev

public async run(context: CommandContext) {
const user = context.getUser();
const interaction = context.getInteraction();

const player = await this.apiService.getPlayer(
context.option("player"),
user,
interaction.isCommandInteraction() ? undefined : CacheLevel.CACHE_ONLY
);
const player = await this.apiService.getPlayer(context.option("player"), user, context.getCacheLevel());

const [logo, skin, badge] = await Promise.all([
getLogo(user),
Expand Down
162 changes: 48 additions & 114 deletions apps/discord-bot/src/commands/guild/guild-top.subcommand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ import {
ButtonBuilder,
CommandContext,
GuildArgument,
IMessage,
Interaction,
SelectMenuBuilder,
SelectMenuOptionBuilder,
SubCommand,
createCustomId,
getScrolledPage,
parseCustomId,
} from "@statsify/discord";
import { ButtonStyle } from "discord-api-types/v10";
import { CommandListener } from "#lib/command.listener";
import { GUILD_ROUTE } from "./guild.route.js";
import {
GUILD_TOP_PAGE_SIZE,
GuildTopMember,
Expand All @@ -33,16 +35,12 @@ import { getTheme } from "#themes";

type BaseGuildTopProfileProps = Omit<GuildTopProfileProps, "page" | "members" | "title">;
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();

Expand All @@ -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"],
Expand All @@ -78,133 +71,74 @@ 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<string, Buffer>();
const listener = CommandListener.getInstance();

const changePage =
(fn: (interaction: Interaction) => Partial<GuildTopPageState>) =>
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) {
const user = context.getUser();
const query = context.option<string>("query");
const type = context.option<GuildQuery>("type");

return this.apiService.getGuild(query, type, user);
return this.apiService.getGuild(query, type, user, context.getCacheLevel());
}

private async getGuildTopPageMessage(
cache: Map<string, Buffer>,
components: ActionRowBuilder[],
props: BaseGuildTopProfileProps,
title: string,
key: GuildTopKey,
page: number,
theme?: Theme
): Promise<IMessage> {
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(
Expand Down
17 changes: 17 additions & 0 deletions apps/discord-bot/src/commands/guild/guild.route.ts
Original file line number Diff line number Diff line change
@@ -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 <subcommand>`: `g:<user id>:<subcommand>:<guild id>:...` */
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 });
}
6 changes: 4 additions & 2 deletions apps/discord-bot/src/commands/rankings/rankings.command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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()}`)
);
}
}
2 changes: 2 additions & 0 deletions apps/discord-bot/src/lib/command.listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void> {
Expand Down
3 changes: 2 additions & 1 deletion packages/api-client/src/api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,11 @@ export class ApiService {
);
}

public getGuild(tag: string, type: GuildQuery) {
public getCachedGuild(tag: string, type: GuildQuery, cache: CacheLevel) {
return this.requestKey<GetGuildResponse, "guild">("/guild", "guild", {
guild: tag,
type,
cache,
});
}

Expand Down
6 changes: 6 additions & 0 deletions packages/discord/src/command/command.context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 4 additions & 3 deletions packages/discord/src/services/api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading
Loading