diff --git a/bot/src/chartSearch/buildChartEmbed.ts b/bot/src/chartSearch/buildChartEmbed.ts new file mode 100644 index 000000000..489f763f0 --- /dev/null +++ b/bot/src/chartSearch/buildChartEmbed.ts @@ -0,0 +1,132 @@ +import { InteractionReplyOptions, MessageActionRow, MessageEmbed, MessagePayload, MessageSelectMenu } from "discord.js"; +import { Game, IDStrings, Playtypes, PublicUserDocument, UGSRatingsLookup } from "tachi-common"; +import { PBScoreDocument } from "tachi-common/js/types"; +import { LoggerLayers } from "../config"; +import { validSelectCustomIdPrefaces } from "../interactionHandlers/selectMenu/handleIsSelectMenu"; +import { TachiServerV1Get } from "../utils/fetch-tachi"; +import { createLayeredLogger } from "../utils/logger"; +import { formatGameScoreRating, getGameImage } from "../utils/utils"; +import { getDetailedSongData, SongSearchResult } from "./chartSearch"; + +const logger = createLayeredLogger(LoggerLayers.buildChartEmbed); + +export interface PBResponse { + users: PublicUserDocument[]; + pbs: PBScoreDocument[]; +} + +export const buildSongSelect = (songs: SongSearchResult[], playtype: Playtypes[T], game: Game) => { + return new MessageActionRow().addComponents( + new MessageSelectMenu() + .setCustomId(validSelectCustomIdPrefaces.selectSongForSearch) + .setPlaceholder("Select Song") + .addOptions( + songs.map((chart) => { + return { + label: `${chart.title} - ${chart.artist}`, + value: `${chart.id}:${playtype}:${game}` + }; + }) + ) + ); +}; + +interface SimplePBDocument extends PBScoreDocument, Partial {} + +export const getPBForChart = async ( + chartId: string, + playtype: Playtypes[T], + game: Game +): Promise => { + try { + const data = await TachiServerV1Get(`/games/${game}/${playtype}/charts/${chartId}/pbs`); + if (data.success) { + const pbs = data.body.pbs; + const users = data.body.users; + + const topPb = pbs[0]; + const topUser = users.find((user) => user.id === topPb.userID); + + return { + ...topPb, + ...topUser + }; + } else { + logger.error(data.description); + throw new Error(data.description); + } + } catch (e) { + logger.error(e); + + throw new Error("Unable to fetch PBs for chart"); + } +}; + +export const buildChartEmbed = async (args: { + searchResults?: SongSearchResult[]; + songId?: string; + playtype: Playtypes[T]; + game: Game; + /** @TODO Let users Toggle showing 2dExtra charts */ + IIDExtra?: boolean; +}): Promise => { + try { + const { searchResults, songId, playtype, game, IIDExtra } = args; + const embed = new MessageEmbed().setColor("#cc527a"); + + if (!songId && searchResults) { + embed.addField(`${searchResults.length} potential results found`, "Select from the dropdown"); + return { embeds: [embed], components: [buildSongSelect(searchResults, playtype, game)] }; + } else if (songId) { + const details = await getDetailedSongData(songId, playtype, game); + embed.addField(details.song.title || "Song", details.song.artist || "Artist"); + embed.setThumbnail(getGameImage(details.song.firstVersion || "0", game)); + + const filteredCharts = details.charts.filter((chart) => { + if (IIDExtra) { + return true; + } else { + if (chart.data && !(chart.data as any)?.["2dxtraSet"]) { + return true; + } + } + + return false; + }); + const sortedCharts = filteredCharts.sort((a, b) => a.levelNum - b.levelNum); + + for (const chart of sortedCharts) { + try { + const PB = await getPBForChart(chart.chartID, chart.playtype, game); + embed.addField( + `${chart.difficulty} (${chart.level})`, + `Server Top: **[${PB.username}](https://kamaitachi.xyz/dashboard/users/${ + PB.id + }/games/${game}/${playtype})**\n${PB.scoreData.percent.toFixed(2)}% [${PB.scoreData.score}]\n${ + PB.scoreData.grade + } [${PB.scoreData.lamp}]\n${Object.keys(PB.calculatedData) + .map((item) => { + return `${item}: ${formatGameScoreRating( + { game, playtype }, + item, + PB.calculatedData[item as never] || 0 + )}`; + }) + .join("\n")}`, + false + ); + } catch { + embed.addField(`${chart.difficulty} (${chart.level})`, "No Scores Available!"); + } + } + + return { embeds: [embed] }; + } else { + throw new Error("Invalid call to buildChartEmbed"); + } + } catch (e) { + logger.error(e); + + throw new Error("Error building chart embed"); + } +}; diff --git a/bot/src/chartSearch/chartSearch.ts b/bot/src/chartSearch/chartSearch.ts new file mode 100644 index 000000000..c6f6fc747 --- /dev/null +++ b/bot/src/chartSearch/chartSearch.ts @@ -0,0 +1,101 @@ +import { CommandInteraction } from "discord.js"; +import { Playtypes, PublicUserDocument, SongDocument } from "tachi-common"; +import { ChartDocument, Game } from "tachi-common/js/types"; +import { LoggerLayers } from "../config"; +import { TachiServerV1Get } from "../utils/fetch-tachi"; +import { createLayeredLogger } from "../utils/logger"; +import { stringToSimpleGameType } from "../utils/utils"; +import { buildChartEmbed } from "./buildChartEmbed"; + +const logger = createLayeredLogger(LoggerLayers.chartSearch); + +export interface SongSearchResult extends SongDocument { + game: Game; + __textScore: number; +} + +export interface SearchResult { + users: PublicUserDocument[]; + songs: SongSearchResult[]; +} + +export interface DetailedSongResponse { + song: Partial; + charts: ChartDocument[]; +} + +export const getSongMeta = async (songName: string): Promise => { + try { + logger.info(`Searching for ${songName}`); + const songResponse = ( + await TachiServerV1Get("/search", { + search: songName + }) + ).body?.songs; + + if (songResponse && songResponse.length > 0) { + logger.info(`${songResponse.length} values found`); + return songResponse; + } else { + logger.error(`No results found for ${songName}`); + throw new Error(`No results found for ${songName}`); + } + } catch (e) { + logger.error(e); + throw new Error("Unable to fetch chart meta"); + } +}; + +export const getSongMetaFiltered = async (chartName: string, game: Game): Promise => { + try { + const songMeta = await getSongMeta(chartName); + const songMetaFiltered = songMeta.filter((chart) => chart.game === game); + if (songMetaFiltered.length === 0) { + throw new Error("No songs found"); + } + + return songMetaFiltered; + } catch (e) { + logger.info(e); + + throw new Error("No songs found"); + } +}; + +export const getDetailedSongData = async ( + songId: string, + playtype: Playtypes[T], + game: Game +): Promise => { + try { + logger.info(`Getting detailed info for ${songId} (${game}:${playtype})`); + const data = await TachiServerV1Get(`/games/${game}/${playtype}/songs/${songId}`); + if (data.success) { + return data.body; + } else { + logger.error(data.description); + throw new Error(data.description); + } + } catch (e) { + logger.info(e); + throw new Error("Unable to fetch detailed song data"); + } +}; + +export const searchForSong = async (interaction: CommandInteraction): Promise => { + const songName = interaction.options.getString("song", true); + const gpt = stringToSimpleGameType(interaction.options.getString("game", true)); + const game = gpt.game; + const playtype = gpt.playtype; + + try { + logger.info(`Requested ${songName} in ${game}`); + + const songMetaFiltered = await getSongMetaFiltered(songName, game); + + await interaction.reply(await buildChartEmbed({ searchResults: songMetaFiltered, playtype, game })); + } catch (e) { + await interaction.reply(`No results found for ${songName}`); + logger.error(e); + } +}; diff --git a/bot/src/config.ts b/bot/src/config.ts index 52ec93b67..a656f82f3 100644 --- a/bot/src/config.ts +++ b/bot/src/config.ts @@ -11,5 +11,7 @@ export enum LoggerLayers { profile = "profile", selectInteractionHandler = "selectInteractionHandler", buildProfileEmbed = "buildProfileEmbed", + chartSearch = "chartSearch", + buildChartEmbed = "buildChartEmbed", tachiLinker = "tachiLinker" } diff --git a/bot/src/interactionHandlers/command/handleIsCommand.ts b/bot/src/interactionHandlers/command/handleIsCommand.ts index a812a0d0f..1d03afb38 100644 --- a/bot/src/interactionHandlers/command/handleIsCommand.ts +++ b/bot/src/interactionHandlers/command/handleIsCommand.ts @@ -3,7 +3,7 @@ import { LoggerLayers } from "../../config"; import { SlashCommand, slashCommands } from "../../slashCommands/register"; import { createLayeredLogger } from "../../utils/logger"; -const logger = createLayeredLogger(LoggerLayers.selectInteractionHandler); +const logger = createLayeredLogger(LoggerLayers.slashCommands); export const handleIsCommand = async (interaction: Interaction): Promise => { try { diff --git a/bot/src/interactionHandlers/selectMenu/handleIsSelectMenu.ts b/bot/src/interactionHandlers/selectMenu/handleIsSelectMenu.ts index 77fee9e9d..bb7b0d87d 100644 --- a/bot/src/interactionHandlers/selectMenu/handleIsSelectMenu.ts +++ b/bot/src/interactionHandlers/selectMenu/handleIsSelectMenu.ts @@ -1,4 +1,6 @@ -import { Interaction } from "discord.js"; +import { Interaction, SelectMenuInteraction } from "discord.js"; +import { Game, Playtypes } from "tachi-common"; +import { buildChartEmbed } from "../../chartSearch/buildChartEmbed"; import { LoggerLayers } from "../../config"; import { buildProfileIntractable } from "../../profile/buildProfileEmbed"; import { createLayeredLogger } from "../../utils/logger"; @@ -6,18 +8,43 @@ import { stringToSimpleGameType } from "../../utils/utils"; const logger = createLayeredLogger(LoggerLayers.selectInteractionHandler); +export enum validSelectCustomIdPrefaces { + selectSongForSearch = "selectSongForSearch", + SelectGameForProfile = "SelectGameForProfile" +} + +export type SelectHandlersType = Record void>; +export const selectHandlers: SelectHandlersType = { + [validSelectCustomIdPrefaces.SelectGameForProfile]: async (interaction: SelectMenuInteraction): Promise => { + const userId = interaction.customId.split(":")[1]; + await interaction.update(await buildProfileIntractable(userId, stringToSimpleGameType(interaction.values[0]))); + }, + [validSelectCustomIdPrefaces.selectSongForSearch]: async ( + interaction: SelectMenuInteraction + ): Promise => { + const interactionValues = interaction.values[0].split(":"); + await interaction.update( + await buildChartEmbed({ + songId: interactionValues[0], + playtype: interactionValues[1], + game: interactionValues[2] + }) + ); + } +}; + export const handleIsSelectMenu = async (interaction: Interaction): Promise => { try { /** Rechecking required to enforce types */ if (interaction.isSelectMenu()) { - /** @TODO Split this out when we add more dropdowns */ - const userId = interaction.customId.split(":")[1]; - await interaction.update( - await buildProfileIntractable(userId, stringToSimpleGameType(interaction.values[0])) - ); + const customIdPrefix = interaction.customId.split(":")[0]; + if (Object.values(validSelectCustomIdPrefaces).includes(customIdPrefix)) { + await selectHandlers[customIdPrefix](interaction); + } } return; } catch (e) { + logger.error(e); logger.error("Failed to handle isSelectMenu interaction"); } }; diff --git a/bot/src/profile/buildProfileEmbed.ts b/bot/src/profile/buildProfileEmbed.ts index 23dcd21b2..6bd5851bc 100644 --- a/bot/src/profile/buildProfileEmbed.ts +++ b/bot/src/profile/buildProfileEmbed.ts @@ -2,6 +2,7 @@ import { InteractionReplyOptions, MessageActionRow, MessageEmbed, MessagePayload import { Game, UserGameStats, IDStrings, PublicUserDocument, UGSRatingsLookup } from "tachi-common"; import { find } from "lodash"; import { LoggerLayers } from "../config"; +import { validSelectCustomIdPrefaces } from "../interactionHandlers/selectMenu/handleIsSelectMenu"; import { TachiServerV1Get } from "../utils/fetch-tachi"; import { createLayeredLogger } from "../utils/logger"; import { @@ -97,7 +98,7 @@ export const buildProfileIntractable = async ( if (data) { const dropdown = new MessageActionRow().addComponents( new MessageSelectMenu() - .setCustomId(`SelectGameForProfile:${userId}`) + .setCustomId(`${validSelectCustomIdPrefaces.SelectGameForProfile}:${userId}`) .setPlaceholder("Browse By Game") .addOptions( data.map((_game) => { diff --git a/bot/src/slashCommands/register.ts b/bot/src/slashCommands/register.ts index 9f1dda358..63e5c494b 100644 --- a/bot/src/slashCommands/register.ts +++ b/bot/src/slashCommands/register.ts @@ -3,6 +3,7 @@ import { REST } from "@discordjs/rest"; import { APIApplicationCommandOption } from "discord-api-types"; import { Routes } from "discord-api-types/v9"; import { Client, CommandInteraction } from "discord.js"; +import { searchForSong } from "../chartSearch/chartSearch"; import { getProfileByName } from "../profile/fetch"; import { ProcessEnv } from "../setup"; import { LoggerLayers } from "../config"; @@ -37,6 +38,17 @@ export const slashCommands: SlashCommand[] = [ ) .toJSON(), exec: async (interaction: CommandInteraction) => await getProfileByName(interaction) + }, + { + info: new SlashCommandBuilder() + .setName("search") + .setDescription("Search for a song") + .addStringOption((option) => + option.setName("game").setDescription("The Game").setRequired(true).addChoices(gamesToChoicesObject()) + ) + .addStringOption((option) => option.setName("song").setDescription("The song name").setRequired(true)) + .toJSON(), + exec: async (interaction: CommandInteraction) => await searchForSong(interaction) } ]; diff --git a/bot/src/utils/utils.ts b/bot/src/utils/utils.ts index c4f86eef6..50493e5f0 100644 --- a/bot/src/utils/utils.ts +++ b/bot/src/utils/utils.ts @@ -1,5 +1,4 @@ -import { Client } from "discord.js"; -import { FormatGame, Game, Playtypes, IDStrings, UGSRatingsLookup, GetGamePTConfig } from "tachi-common"; +import { FormatGame, Game, Playtypes, IDStrings, UGSRatingsLookup, GetGamePTConfig, GetGameConfig } from "tachi-common"; import { ProcessEnv } from "../setup"; import { PrependTachiUrl } from "./fetch-tachi"; @@ -16,6 +15,7 @@ export const stringToSimpleGameType = (gptString: string): SimpleGameType }; export const formatGameWrapper = (gpt: SimpleGameType): string => FormatGame(gpt.game, gpt.playtype); +export const formatGameWrapperSimple = (gpt: Game): string => GetGameConfig(gpt).name; export const capitalise = (s: string): string => (s && s[0].toUpperCase() + s.slice(1)) || ""; @@ -30,6 +30,14 @@ export const getPfpUrl = (userId: number): string => { return PrependTachiUrl(`/users/${userId}/pfp`); }; +export const getGameImage = (gameId: string, game: Game): string => { + if (ProcessEnv.ENV !== "prod") { + return `https://kamaitachi.xyz/static/images/gameicons/${game}/${gameId}.png`; + } + /** @TODO Prod CDN for game images */ + return PrependTachiUrl(`/users/${gameId}/pfp`); +}; + export const gameIdentifierStrings = [ "iidx:SP", "iidx:DP", @@ -45,12 +53,32 @@ export const gameIdentifierStrings = [ "gitadora:Gita", "gitadora:Dora" ]; + +export const simpleGameIdentifierStrings = [ + "iidx", + "museca", + "maimai", + "sdvx", + "ddr", + "bms", + "chunithm", + "gitadora", + "usc" +]; + export const gamesToChoicesObject = (): [name: string, value: string][] => { return gameIdentifierStrings.map((identifier) => { return [formatGameWrapper(stringToSimpleGameType(identifier)), identifier]; }); }; +/** @description excludes play-types from options */ +export const gamesToGenericChoicesObject = (): [name: string, value: string][] => { + return simpleGameIdentifierStrings.map((identifier) => { + return [formatGameWrapperSimple(identifier), identifier]; + }); +}; + export const formatGameScoreRating = ( gpt: SimpleGameType, ratingName: UGSRatingsLookup[I],