diff --git a/apps/api/src/dtos/guild-search.dto.ts b/apps/api/src/dtos/guild-search.dto.ts new file mode 100644 index 000000000..e44220aeb --- /dev/null +++ b/apps/api/src/dtos/guild-search.dto.ts @@ -0,0 +1,18 @@ +/** + * 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 { ApiProperty } from "@nestjs/swagger"; +import { IsOptional, IsString, MaxLength } from "class-validator"; + +export class GuildSearchDto { + @IsOptional() + @IsString() + @MaxLength(32) + @ApiProperty({ required: false }) + public query?: string; +} diff --git a/apps/api/src/dtos/index.ts b/apps/api/src/dtos/index.ts index 975dd1697..73c5eff56 100644 --- a/apps/api/src/dtos/index.ts +++ b/apps/api/src/dtos/index.ts @@ -10,6 +10,7 @@ export * from "./cache.dto.js"; export * from "./cached-player.dto.js"; export * from "./guild-leaderboard.dto.js"; export * from "./guild-rankings.dto.js"; +export * from "./guild-search.dto.js"; export * from "./guild.dto.js"; export * from "./head.dto.js"; export * from "./user.dto.js"; diff --git a/apps/api/src/dtos/player-leaderboard.dto.ts b/apps/api/src/dtos/player-leaderboard.dto.ts index 0e39b70ed..54be1447f 100644 --- a/apps/api/src/dtos/player-leaderboard.dto.ts +++ b/apps/api/src/dtos/player-leaderboard.dto.ts @@ -7,7 +7,7 @@ */ import { ApiProperty, PartialType } from "@nestjs/swagger"; -import { IsEnum, IsInt, IsOptional, Min } from "class-validator"; +import { IsEnum, IsInt, IsOptional, IsString, MaxLength, Min, MinLength } from "class-validator"; import { Player, getLeaderboardFields } from "@statsify/schemas"; import { PlayerDto } from "./player.dto.js"; import { Transform } from "class-transformer"; @@ -19,6 +19,13 @@ export class PlayerLeaderboardDto extends PartialType(PlayerDto) { @ApiProperty({ enum: fields }) public field: string; + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(32) + @ApiProperty({ required: false }) + public guild?: string; + @Transform((params) => +params.value) @IsInt() @Min(0) diff --git a/apps/api/src/guild/guild.module.ts b/apps/api/src/guild/guild.module.ts index 01c267dc0..57234a91b 100644 --- a/apps/api/src/guild/guild.module.ts +++ b/apps/api/src/guild/guild.module.ts @@ -10,6 +10,8 @@ import { Guild, Player } from "@statsify/schemas"; import { GuildController } from "./guild.controller.js"; import { GuildLeaderboardController } from "./leaderboards/guild-leaderboard.controller.js"; import { GuildLeaderboardService } from "./leaderboards/guild-leaderboard.service.js"; +import { GuildSearchController } from "./search/guild-search.controller.js"; +import { GuildSearchService } from "./search/guild-search.service.js"; import { GuildService } from "./guild.service.js"; import { HypixelModule } from "#hypixel"; import { Module } from "@nestjs/common"; @@ -18,7 +20,7 @@ import { TypegooseModule } from "@m8a/nestjs-typegoose"; @Module({ imports: [HypixelModule, PlayerModule, TypegooseModule.forFeature([Guild, Player])], - controllers: [GuildController, GuildLeaderboardController], - providers: [GuildService, GuildLeaderboardService], + controllers: [GuildController, GuildLeaderboardController, GuildSearchController], + providers: [GuildService, GuildLeaderboardService, GuildSearchService], }) export class GuildModule {} diff --git a/apps/api/src/guild/guild.service.ts b/apps/api/src/guild/guild.service.ts index dca736f09..c8c92e873 100644 --- a/apps/api/src/guild/guild.service.ts +++ b/apps/api/src/guild/guild.service.ts @@ -14,6 +14,7 @@ import { } from "@statsify/api-client"; import { Guild, GuildMember, Player, deserialize, serialize } from "@statsify/schemas"; import { GuildLeaderboardService } from "./leaderboards/guild-leaderboard.service.js"; +import { GuildSearchService } from "./search/guild-search.service.js"; import { HypixelService } from "#hypixel"; import { InjectModel } from "@m8a/nestjs-typegoose"; import { Injectable } from "@nestjs/common"; @@ -30,6 +31,7 @@ export class GuildService { private readonly hypixelService: HypixelService, private readonly playerService: PlayerService, private readonly guildLeaderboardService: GuildLeaderboardService, + private readonly guildSearchService: GuildSearchService, @InjectModel(Guild) private readonly guildModel: ReturnModelType, @InjectModel(Player) private readonly playerModel: ReturnModelType ) {} @@ -140,12 +142,14 @@ export class GuildService { const flatGuild = flatten(guild); const serializedGuild = serialize(Guild, flatGuild); - await this.guildModel - .replaceOne({ id: guild.id }, serializedGuild, { upsert: true }) - .lean() - .exec(); - - await this.guildLeaderboardService.addLeaderboards(Guild, serializedGuild, "id"); + await Promise.all([ + this.guildModel + .replaceOne({ id: guild.id }, serializedGuild, { upsert: true }) + .lean() + .exec(), + this.guildLeaderboardService.addLeaderboards(Guild, serializedGuild, "id"), + this.guildSearchService.add(guild.name), + ]); return deserialize(Guild, flatGuild); } diff --git a/apps/api/src/guild/search/guild-search.controller.ts b/apps/api/src/guild/search/guild-search.controller.ts new file mode 100644 index 000000000..6730963b0 --- /dev/null +++ b/apps/api/src/guild/search/guild-search.controller.ts @@ -0,0 +1,33 @@ +/** + * 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 { ApiOkResponse, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Auth } from "#auth"; +import { Controller, Get, Query } from "@nestjs/common"; +import { GetGuildSearchResponse } from "@statsify/api-client"; +import { GuildSearchDto } from "#dtos"; +import { GuildSearchService } from "./guild-search.service.js"; + +@Controller("/guild/search") +@ApiTags("Guild") +export class GuildSearchController { + public constructor(private readonly guildSearchService: GuildSearchService) {} + + @ApiOperation({ summary: "Get a Fuzzy Searched List of Guilds" }) + @ApiOkResponse({ type: GetGuildSearchResponse }) + @Auth({ weight: 0 }) + @Get() + public async getGuilds(@Query() { query = "" }: GuildSearchDto) { + const guilds = await this.guildSearchService.get(query); + + return { + success: true, + guilds, + }; + } +} diff --git a/apps/api/src/guild/search/guild-search.service.ts b/apps/api/src/guild/search/guild-search.service.ts new file mode 100644 index 000000000..8a2bcd512 --- /dev/null +++ b/apps/api/src/guild/search/guild-search.service.ts @@ -0,0 +1,83 @@ +/** + * 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 { Guild } from "@statsify/schemas"; +import { InjectModel } from "@m8a/nestjs-typegoose"; +import { InjectRedis } from "#redis"; +import { Injectable } from "@nestjs/common"; +import { Logger } from "@statsify/logger"; +import { Redis } from "ioredis"; +import type { ReturnModelType } from "@typegoose/typegoose"; + +const REDI_SEARCH_NOT_INSTALLED = + "This error was most likely caused because RediSearch is not installed."; +const GUILD_AUTOCOMPLETE_KEY = "guild:autocomplete"; + +@Injectable() +export class GuildSearchService { + private logger = new Logger("GuildSearchService"); + + public constructor( + @InjectRedis() private readonly redis: Redis, + @InjectModel(Guild) private readonly guildModel: ReturnModelType + ) {} + + public async get(query: string): Promise { + query = query.trim().toLowerCase(); + + if (!query) return []; + + const searchOptions = query.length >= 3 ? ["FUZZY", "MAX", "25"] : ["MAX", "25"]; + + const redisResults = await ( + this.redis.call( + "FT.SUGGET", + GUILD_AUTOCOMPLETE_KEY, + query, + ...searchOptions + ) as Promise + ).catch((e) => { + this.handleRedisError(e); + + return []; + }); + + if (redisResults.length > 0) return redisResults; + + return this.getCachedGuilds(query); + } + + public async add(name: string) { + if (name.length < 3 || name.length > 32) return; + + await this.redis + .call("FT.SUGADD", GUILD_AUTOCOMPLETE_KEY, name, "1", "INCR") + .catch((e) => this.handleRedisError(e)); + } + + private async getCachedGuilds(query: string) { + const guilds = await this.guildModel + .find({ nameToLower: { $regex: `^${this.escapeRegex(query)}` } }) + .select({ name: true, _id: false }) + .sort({ exp: -1 }) + .limit(25) + .lean() + .exec(); + + return guilds.map(({ name }) => name); + } + + private escapeRegex(input: string) { + return input.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); + } + + private handleRedisError(error: unknown) { + this.logger.error(error); + this.logger.error(REDI_SEARCH_NOT_INSTALLED); + } +} diff --git a/apps/api/src/leaderboards/leaderboard.service.ts b/apps/api/src/leaderboards/leaderboard.service.ts index 58a15af98..2a4dd1905 100644 --- a/apps/api/src/leaderboards/leaderboard.service.ts +++ b/apps/api/src/leaderboards/leaderboard.service.ts @@ -80,18 +80,7 @@ export abstract class LeaderboardService { ) { const PAGE_SIZE = 10; - const { - fieldName, - additionalFields = [], - extraDisplay, - formatter, - sort, - name, - hidden, - } = getLeaderboardField( - constructor, - field - ) as LeaderboardEnabledMetadata; + const { sort } = getLeaderboardField(constructor, field); let top: number; let bottom: number; @@ -127,6 +116,31 @@ export abstract class LeaderboardService { sort ); + return this.formatLeaderboard( + constructor, + field, + leaderboard, + top / PAGE_SIZE, + highlight + ); + } + + protected async formatLeaderboard( + constructor: Constructor, + field: string, + leaderboard: { id: string; score: number; index: number }[], + page: number, + highlight?: number + ) { + const { + fieldName, + additionalFields = [], + extraDisplay, + formatter, + name, + hidden, + } = getLeaderboardField(constructor, field); + const additionalFieldMetadata = additionalFields.map((k) => getLeaderboardField(constructor, k, false) ); @@ -185,7 +199,7 @@ export abstract class LeaderboardService { name, fields, data, - page: top / PAGE_SIZE, + page, }; } diff --git a/apps/api/src/player/leaderboards/player-leaderboard.controller.ts b/apps/api/src/player/leaderboards/player-leaderboard.controller.ts index 567d5f4cf..307fa8fe4 100644 --- a/apps/api/src/player/leaderboards/player-leaderboard.controller.ts +++ b/apps/api/src/player/leaderboards/player-leaderboard.controller.ts @@ -37,7 +37,7 @@ export class PlayerLeaderboardsController { @ApiBadRequestResponse({ type: ErrorResponse }) @Auth({ weight: 3 }) public getPlayerLeaderboard( - @Body() { field, page, player, position }: PlayerLeaderboardDto + @Body() { field, guild, page, player, position }: PlayerLeaderboardDto ) { let input: number | string; let type: LeaderboardQuery; @@ -53,6 +53,14 @@ export class PlayerLeaderboardsController { type = LeaderboardQuery.PAGE; } + if (guild) + return this.playerLeaderboardService.getGuildScopedLeaderboard( + guild, + field, + input, + type + ); + return this.playerLeaderboardService.getLeaderboard(Player, field, input, type); } diff --git a/apps/api/src/player/leaderboards/player-leaderboard.service.ts b/apps/api/src/player/leaderboards/player-leaderboard.service.ts index 5877de527..c06876dcd 100644 --- a/apps/api/src/player/leaderboards/player-leaderboard.service.ts +++ b/apps/api/src/player/leaderboards/player-leaderboard.service.ts @@ -6,13 +6,20 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { CacheLevel, PlayerNotFoundException } from "@statsify/api-client"; +import { + CacheLevel, + GUILD_ID_REGEX, + GuildNotFoundException, + LeaderboardQuery, + PlayerNotFoundException, +} from "@statsify/api-client"; import { type Circular, flatten } from "@statsify/util"; +import { Guild, Player, getLeaderboardField, serialize } from "@statsify/schemas"; +import { HypixelService } from "#hypixel"; import { Inject, Injectable, forwardRef } from "@nestjs/common"; import { InjectModel } from "@m8a/nestjs-typegoose"; import { InjectRedis } from "#redis"; import { LeaderboardAdditionalStats, LeaderboardService } from "#leaderboards"; -import { Player } from "@statsify/schemas"; import { PlayerService } from "#player"; import { Redis } from "ioredis"; import type { ReturnModelType } from "@typegoose/typegoose"; @@ -22,22 +29,80 @@ export class PlayerLeaderboardService extends LeaderboardService { public constructor( @Inject(forwardRef(() => PlayerService)) private readonly playerService: Circular, + private readonly hypixelService: HypixelService, @InjectModel(Player) private readonly playerModel: ReturnModelType, + @InjectModel(Guild) private readonly guildModel: ReturnModelType, @InjectRedis() redis: Redis ) { super(redis); } - protected async searchLeaderboardInput(input: string, field: string): Promise { - if (input.length <= 16) { - const player = await this.playerService.get(input, CacheLevel.CACHE_ONLY, { - uuid: true, - }); + public async getGuildScopedLeaderboard( + guildInput: string, + field: string, + input: number | string, + type: LeaderboardQuery + ) { + const PAGE_SIZE = 10; + + const { sort } = getLeaderboardField(Player, field); + + const guild = await this.getGuild(guildInput); + const memberIds = guild.members.map(({ uuid }) => uuid); + + const scores = await this.redis.call( + "ZMSCORE", + `player.${field}`, + ...memberIds + ) as (string | null)[]; + + const leaderboard = scores + .flatMap((score, index) => + score === null ? [] : [{ id: memberIds[index], score: Number(score) }] + ) + .sort((a, b) => sort === "ASC" ? a.score - b.score : b.score - a.score) + .map((doc, index) => ({ ...doc, index })); + + let top: number; + let highlight: number | undefined = undefined; + + switch (type) { + case LeaderboardQuery.PAGE: + top = (input as number) * PAGE_SIZE; + break; + + case LeaderboardQuery.INPUT: { + const playerId = await this.getPlayerId(input as string); + highlight = leaderboard.findIndex(({ id }) => id === playerId); - if (!player) throw new PlayerNotFoundException(); - input = player.uuid; + if (highlight === -1) throw new PlayerNotFoundException(); + + top = highlight - (highlight % PAGE_SIZE); + break; + } + + case LeaderboardQuery.POSITION: { + const position = (input as number) - 1; + highlight = position; + top = position - (position % PAGE_SIZE); + break; + } } + const page = await this.formatLeaderboard( + Player, + field, + leaderboard.slice(top, top + PAGE_SIZE), + top / PAGE_SIZE, + highlight + ); + + return { ...page, name: `${page.name} - ${guild.nameFormatted ?? guild.name}` }; + } + + protected async searchLeaderboardInput(input: string, field: string): Promise { + input = await this.getPlayerId(input); + const ranking = await this.getLeaderboardRankings(Player, [field], input); if (!ranking || !ranking[0] || !ranking[0].rank) throw new PlayerNotFoundException(); @@ -73,4 +138,63 @@ export class PlayerLeaderboardService extends LeaderboardService { }) ); } + + private async getGuild(input: string) { + const isGuildId = GUILD_ID_REGEX.test(input); + + const guild = await this.guildModel + .findOne() + .where(isGuildId ? "id" : "nameToLower") + .equals(isGuildId ? input : input.toLowerCase()) + .select({ "id": true, "members.uuid": true, "name": true, "nameFormatted": true }) + .lean() + .exec(); + + if (guild) return guild; + + const fetchedGuild = await this.hypixelService.getGuild( + input, + isGuildId ? "id" : "name" + ); + + if (!fetchedGuild) throw new GuildNotFoundException(); + + const memberIds = fetchedGuild.members.map(({ uuid }) => uuid); + + await Promise.all([ + this.playerModel + .updateMany({ uuid: { $in: memberIds } }, { guildId: fetchedGuild.id }) + .lean() + .exec(), + this.guildModel + .replaceOne({ id: fetchedGuild.id }, serialize(Guild, flatten(fetchedGuild)), { + upsert: true, + }) + .lean() + .exec(), + this.addGuildAutocomplete(fetchedGuild.name), + ]); + + return fetchedGuild; + } + + private async addGuildAutocomplete(name: string) { + if (name.length < 3 || name.length > 32) return; + + await this.redis + .call("FT.SUGADD", "guild:autocomplete", name, "1", "INCR") + .catch(() => undefined); + } + + private async getPlayerId(input: string) { + if (input.length > 16) return input; + + const player = await this.playerService.get(input, CacheLevel.CACHE_ONLY, { + uuid: true, + }); + + if (!player) throw new PlayerNotFoundException(); + + return player.uuid; + } } diff --git a/apps/api/src/player/player.module.ts b/apps/api/src/player/player.module.ts index 899fdaa34..023ae237f 100644 --- a/apps/api/src/player/player.module.ts +++ b/apps/api/src/player/player.module.ts @@ -6,9 +6,9 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ +import { Guild, Player } from "@statsify/schemas"; import { HypixelModule } from "#hypixel"; import { Module } from "@nestjs/common"; -import { Player } from "@statsify/schemas"; import { PlayerController } from "./player.controller.js"; import { PlayerLeaderboardService } from "./leaderboards/player-leaderboard.service.js"; import { PlayerLeaderboardsController } from "./leaderboards/player-leaderboard.controller.js"; @@ -20,7 +20,7 @@ import { TypegooseModule } from "@m8a/nestjs-typegoose"; @Module({ imports: [ HypixelModule, - TypegooseModule.forFeature([Player]), + TypegooseModule.forFeature([Guild, Player]), ], controllers: [PlayerController, PlayerLeaderboardsController, PlayerSearchController], providers: [PlayerService, PlayerLeaderboardService, PlayerSearchService], diff --git a/apps/discord-bot/src/commands/leaderboards/player-leaderboard.argument.ts b/apps/discord-bot/src/commands/leaderboards/player-leaderboard.argument.ts index 7839fa91c..01edcf585 100644 --- a/apps/discord-bot/src/commands/leaderboards/player-leaderboard.argument.ts +++ b/apps/discord-bot/src/commands/leaderboards/player-leaderboard.argument.ts @@ -11,15 +11,23 @@ import { APIApplicationCommandOptionChoice, ApplicationCommandOptionType, } from "discord-api-types/v10"; -import { AbstractArgument, CommandContext, LocalizationString } from "@statsify/discord"; +import { + AbstractArgument, + ApiService, + CommandContext, + LocalizationString, +} from "@statsify/discord"; import { ClassMetadata, METADATA_KEY, PlayerStats, getLeaderboardFields, } from "@statsify/schemas"; +import { Container } from "typedi"; import { removeFormatting } from "@statsify/util"; +const apiClient = Container.get(ApiService); + const entries = Object.entries( Reflect.getMetadata(METADATA_KEY, PlayerStats.prototype) as ClassMetadata ); @@ -71,3 +79,42 @@ export class PlayerLeaderboardArgument extends AbstractArgument { .slice(0, 25); } } + +export class PlayerLeaderboardGuildArgument extends AbstractArgument { + public name = "guild"; + public description: LocalizationString; + public type = ApplicationCommandOptionType.String; + public required = false; + public autocomplete = true; + + public constructor() { + super(); + this.description = (t) => t("arguments.guild-filter"); + } + + public async autocompleteHandler( + context: CommandContext + ): Promise { + const query = context.option(this.name, "").toLowerCase(); + + const searched = { name: query, value: query }; + + if (query.length > 32) return [searched]; + + const guilds = await apiClient.getGuildAutocomplete(query); + + let results = guilds.map((guild) => ({ name: guild, value: guild })); + + if (query && !guilds.some((guild) => guild.toLowerCase() === query)) { + results = results.slice(0, 24); + results.push(searched); + } + + return results; + } +} + +export const createPlayerLeaderboardArguments = (prefix: keyof PlayerStats) => [ + new PlayerLeaderboardArgument(prefix), + new PlayerLeaderboardGuildArgument(), +]; diff --git a/apps/discord-bot/src/commands/leaderboards/player-leaderboard.command.ts b/apps/discord-bot/src/commands/leaderboards/player-leaderboard.command.ts index 1a9159e80..58cd01b10 100644 --- a/apps/discord-bot/src/commands/leaderboards/player-leaderboard.command.ts +++ b/apps/discord-bot/src/commands/leaderboards/player-leaderboard.command.ts @@ -31,6 +31,7 @@ import { TNT_GAMES_MODES, TURBO_KART_RACERS_MODES, UHC_MODES, + User, VAMPIREZ_MODES, WALLS_MODES, WARLORDS_MODES, @@ -40,6 +41,7 @@ import { ApiService, Command, CommandContext, + ErrorMessage, SubCommand, } from "@statsify/discord"; import { BaseLeaderboardCommand } from "./base.leaderboard-command.js"; @@ -48,7 +50,7 @@ import { GamesWithBackgrounds, mapBackground } from "#constants"; import { GuildLeaderboardArgument } from "./guild-leaderboard.argument.js"; import { GuildLeaderboardSubCommand } from "./guild-leaderboard.subcommand.js"; import { - PlayerLeaderboardArgument, + createPlayerLeaderboardArguments, } from "./player-leaderboard.argument.js"; import { getBackground } from "@statsify/assets"; @@ -63,7 +65,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-arcade"), - args: [new PlayerLeaderboardArgument("arcade")], + args: createPlayerLeaderboardArguments("arcade"), }) public arcade(context: CommandContext) { return this.run(context, "arcade", ARCADE_MODES); @@ -71,7 +73,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-arenabrawl"), - args: [new PlayerLeaderboardArgument("arenabrawl")], + args: createPlayerLeaderboardArguments("arenabrawl"), group: "classic", }) public arenabrawl(context: CommandContext) { @@ -80,7 +82,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-bedwars"), - args: [new PlayerLeaderboardArgument("bedwars")], + args: createPlayerLeaderboardArguments("bedwars"), }) public bedwars(context: CommandContext) { return this.run(context, "bedwars", BEDWARS_MODES); @@ -88,7 +90,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-blitzsg"), - args: [new PlayerLeaderboardArgument("blitzsg")], + args: createPlayerLeaderboardArguments("blitzsg"), }) public blitzsg(context: CommandContext) { return this.run(context, "blitzsg", BLITZSG_MODES); @@ -96,7 +98,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-buildbattle"), - args: [new PlayerLeaderboardArgument("buildbattle")], + args: createPlayerLeaderboardArguments("buildbattle"), }) public buildbattle(context: CommandContext) { return this.run(context, "buildbattle", BUILD_BATTLE_MODES); @@ -104,7 +106,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-challenges"), - args: [new PlayerLeaderboardArgument("challenges")], + args: createPlayerLeaderboardArguments("challenges"), }) public challenges(context: CommandContext) { return this.run(context, "challenges", CHALLENGE_MODES); @@ -112,7 +114,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-copsandcrims"), - args: [new PlayerLeaderboardArgument("copsandcrims")], + args: createPlayerLeaderboardArguments("copsandcrims"), }) public copsandcrims(context: CommandContext) { return this.run(context, "copsandcrims", COPS_AND_CRIMS_MODES); @@ -120,7 +122,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-duels"), - args: [new PlayerLeaderboardArgument("duels")], + args: createPlayerLeaderboardArguments("duels"), }) public duels(context: CommandContext) { return this.run(context, "duels", DUELS_MODES); @@ -128,7 +130,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-general"), - args: [new PlayerLeaderboardArgument("general")], + args: createPlayerLeaderboardArguments("general"), }) public general(context: CommandContext) { return this.run(context, "general", GENERAL_MODES); @@ -136,7 +138,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-megawalls"), - args: [new PlayerLeaderboardArgument("megawalls")], + args: createPlayerLeaderboardArguments("megawalls"), }) public megawalls(context: CommandContext) { return this.run(context, "megawalls", MEGAWALLS_MODES); @@ -144,7 +146,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-murdermystery"), - args: [new PlayerLeaderboardArgument("murdermystery")], + args: createPlayerLeaderboardArguments("murdermystery"), }) public murdermystery(context: CommandContext) { return this.run(context, "murdermystery", MURDER_MYSTERY_MODES); @@ -152,7 +154,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-paintball"), - args: [new PlayerLeaderboardArgument("paintball")], + args: createPlayerLeaderboardArguments("paintball"), group: "classic", }) public paintball(context: CommandContext) { @@ -161,7 +163,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-parkour"), - args: [new PlayerLeaderboardArgument("parkour")], + args: createPlayerLeaderboardArguments("parkour"), }) public parkour(context: CommandContext) { return this.run(context, "parkour", PARKOUR_MODES); @@ -169,7 +171,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-pit"), - args: [new PlayerLeaderboardArgument("pit")], + args: createPlayerLeaderboardArguments("pit"), }) public pit(context: CommandContext) { return this.run(context, "pit", PIT_MODES); @@ -177,7 +179,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-quake"), - args: [new PlayerLeaderboardArgument("quake")], + args: createPlayerLeaderboardArguments("quake"), group: "classic", }) public quake(context: CommandContext) { @@ -186,7 +188,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-quests"), - args: [new PlayerLeaderboardArgument("quests")], + args: createPlayerLeaderboardArguments("quests"), }) public quests(context: CommandContext) { return this.run(context, "quests", QUEST_MODES); @@ -194,7 +196,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-skywars"), - args: [new PlayerLeaderboardArgument("skywars")], + args: createPlayerLeaderboardArguments("skywars"), }) public skywars(context: CommandContext) { return this.run(context, "skywars", SKYWARS_MODES); @@ -202,7 +204,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-smashheroes"), - args: [new PlayerLeaderboardArgument("smashheroes")], + args: createPlayerLeaderboardArguments("smashheroes"), }) public smashheroes(context: CommandContext) { return this.run(context, "smashheroes", SMASH_HEROES_MODES); @@ -210,7 +212,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-speeduhc"), - args: [new PlayerLeaderboardArgument("speeduhc")], + args: createPlayerLeaderboardArguments("speeduhc"), }) public speeduhc(context: CommandContext) { return this.run(context, "speeduhc", SPEED_UHC_MODES); @@ -218,7 +220,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-tntgames"), - args: [new PlayerLeaderboardArgument("tntgames")], + args: createPlayerLeaderboardArguments("tntgames"), }) public tntgames(context: CommandContext) { return this.run(context, "tntgames", TNT_GAMES_MODES); @@ -226,7 +228,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-turbokartracers"), - args: [new PlayerLeaderboardArgument("turbokartracers")], + args: createPlayerLeaderboardArguments("turbokartracers"), group: "classic", }) public turbokartracers(context: CommandContext) { @@ -235,7 +237,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-uhc"), - args: [new PlayerLeaderboardArgument("uhc")], + args: createPlayerLeaderboardArguments("uhc"), }) public uhc(context: CommandContext) { return this.run(context, "uhc", UHC_MODES); @@ -243,7 +245,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-vampirez"), - args: [new PlayerLeaderboardArgument("vampirez")], + args: createPlayerLeaderboardArguments("vampirez"), group: "classic", }) public vampirez(context: CommandContext) { @@ -252,7 +254,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-walls"), - args: [new PlayerLeaderboardArgument("walls")], + args: createPlayerLeaderboardArguments("walls"), group: "classic", }) public walls(context: CommandContext) { @@ -261,7 +263,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-warlords"), - args: [new PlayerLeaderboardArgument("warlords")], + args: createPlayerLeaderboardArguments("warlords"), }) public warlords(context: CommandContext) { return this.run(context, "warlords", WARLORDS_MODES); @@ -269,7 +271,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-woolgames"), - args: [new PlayerLeaderboardArgument("woolgames")], + args: createPlayerLeaderboardArguments("woolgames"), }) public woolgames(context: CommandContext) { return this.run(context, "woolgames", WOOLGAMES_MODES); @@ -289,6 +291,11 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { modes: GameModes ) { const leaderboard = context.option("leaderboard"); + const guild = context.option("guild"); + + if (guild && !User.isDiamond(context.getUser())) { + throw new ErrorMessage("errors.diamondOnly"); + } const field = `stats.${prefix}.${leaderboard.replaceAll(" ", ".")}`; @@ -296,13 +303,12 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { ...mapBackground(modes, modes.getApiModes()[0]) ); - const getLeaderboard = this.apiService.getPlayerLeaderboard.bind(this.apiService); - return this.createLeaderboard({ context, background, field, - getLeaderboard, + getLeaderboard: (field, input, type) => + this.apiService.getPlayerLeaderboard(field, input, type, guild), type: "player", getLeaderboardDataIcon: (id) => this.apiService.getPlayerHead(id, 24), }); diff --git a/locales/en-US/default.json b/locales/en-US/default.json index 642eaad06..53cf5e021 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -3,6 +3,7 @@ "choice": "Choose an option", "file": "Upload a file", "gtbhelper": "The current hint", + "guild-filter": "A guild name or guild ID", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "A guild name or guild member", "guild-type": "The type of guild search to perform", diff --git a/packages/api-client/src/api.service.ts b/packages/api-client/src/api.service.ts index f43108367..91ba4bdae 100644 --- a/packages/api-client/src/api.service.ts +++ b/packages/api-client/src/api.service.ts @@ -18,6 +18,7 @@ import { GetCommandUsageResponse, GetGamecountsResponse, GetGuildResponse, + GetGuildSearchResponse, GetKeyResponse, GetPlayerResponse, GetPlayerSearchResponse, @@ -86,11 +87,13 @@ export class ApiService { public getPlayerLeaderboard( field: string, input: string | number, - type: LeaderboardQuery + type: LeaderboardQuery, + guild?: string ): Promise { return this.request("/player/leaderboards", {}, "POST", { body: { field, + guild, [type === LeaderboardQuery.INPUT ? "player" : type]: input, }, }); @@ -144,6 +147,14 @@ export class ApiService { ); } + public getGuildAutocomplete(query: string) { + return this.requestKey( + "/guild/search", + "guilds", + { query } + ); + } + public async getWatchdog() { return this.requestKey( "/hypixelresources/watchdog", diff --git a/packages/api-client/src/responses/get.guild-search.response.ts b/packages/api-client/src/responses/get.guild-search.response.ts new file mode 100644 index 000000000..c04de56b4 --- /dev/null +++ b/packages/api-client/src/responses/get.guild-search.response.ts @@ -0,0 +1,15 @@ +/** + * 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 { ApiProperty } from "@nestjs/swagger"; +import { SuccessResponse } from "./success.response.js"; + +export class GetGuildSearchResponse extends SuccessResponse { + @ApiProperty({ type: [String] }) + public guilds: string[]; +} diff --git a/packages/api-client/src/responses/index.ts b/packages/api-client/src/responses/index.ts index 5457251c2..5e692a37f 100644 --- a/packages/api-client/src/responses/index.ts +++ b/packages/api-client/src/responses/index.ts @@ -10,6 +10,7 @@ export * from "./delete.player.response.js"; export * from "./error.response.js"; export * from "./get.gamecounts.response.js"; export * from "./get.guild.response.js"; +export * from "./get.guild-search.response.js"; export * from "./get.session.response.js"; export * from "./get.key.response.js"; export * from "./get.player.response.js"; diff --git a/packages/discord/src/services/api.service.ts b/packages/discord/src/services/api.service.ts index a51d79bd1..37930f883 100644 --- a/packages/discord/src/services/api.service.ts +++ b/packages/discord/src/services/api.service.ts @@ -168,14 +168,7 @@ export class ApiService extends StatsifyApiService { ); } - throw new ErrorMessage( - (t) => t("errors.invalidGuild.title"), - (t) => - t("errors.invalidGuild.description", { - context: resolvedType.toLowerCase(), - tag: input, - }) - ); + throw this.missingGuild(resolvedType, input); } if (error.message === "player") throw this.missingPlayer(playerType, input); @@ -187,10 +180,18 @@ export class ApiService extends StatsifyApiService { public override getPlayerLeaderboard( field: string, input: string | number, - type: LeaderboardQuery + type: LeaderboardQuery, + guild?: string ) { - return super.getPlayerLeaderboard(field, input, type).catch((err: AxiosError) => { - if ((err.response?.data as PlayerNotFoundException).statusCode === 404) return null; + return super.getPlayerLeaderboard(field, input, type, guild).catch((err: AxiosError) => { + const error = err.response?.data as GuildNotFoundException | PlayerNotFoundException; + + if (guild && error.message === "guild") { + const guildType = GUILD_ID_REGEX.test(guild) ? GuildQuery.ID : GuildQuery.NAME; + throw this.missingGuild(guildType, guild); + } + + if (error.statusCode === 404) return null; throw new ErrorMessage("errors.leaderboardNotFound"); }); } @@ -310,6 +311,17 @@ export class ApiService extends StatsifyApiService { ); } + public missingGuild(type: GuildQuery, tag: string) { + return new ErrorMessage( + (t) => t("errors.invalidGuild.title"), + (t) => + t("errors.invalidGuild.description", { + context: type.toLowerCase(), + tag, + }) + ); + } + public unknownError() { return new ErrorMessage("errors.unknown"); }