diff --git a/bot/src/interactionHandlers/handleIsSelectMenu.ts b/bot/src/interactionHandlers/handleIsSelectMenu.ts index 3e2906c37..c9701cff2 100644 --- a/bot/src/interactionHandlers/handleIsSelectMenu.ts +++ b/bot/src/interactionHandlers/handleIsSelectMenu.ts @@ -1,70 +1,54 @@ -// import { Interaction, SelectMenuInteraction } from "discord.js"; -// import { Game, Playtypes } from "tachi-common"; -// import { buildChartEmbed } from "../commands/chartSearch/buildChartEmbed"; -// import { LoggerLayers } from "../data/data"; -// import { buildProfileIntractable } from "../profile/buildProfileEmbed"; -// import { createLayeredLogger } from "../utils/logger"; -// import { stringToSimpleGameType } from "../utils/utils"; +import { SelectMenuInteraction } from "discord.js"; +import { Game, Playtype } from "tachi-common"; +import { LoggerLayers } from "../data/data"; +import { DiscordUserMapDocument } from "../database/documents"; +import { GetUserAndTokenForDiscordID } from "../database/queries"; +import { GetUserInfo } from "../utils/apiRequests"; +import { CreateChartScoresEmbed } from "../utils/embeds"; +import { CreateLayeredLogger } from "../utils/logger"; -// const logger = createLayeredLogger(LoggerLayers.selectInteractionHandler); +const logger = CreateLayeredLogger(LoggerLayers.slashCommands); -// export enum validSelectCustomIdPrefaces { -// selectSongForSearch = "selectSongForSearch", -// SelectGameForProfile = "SelectGameForProfile", -// } +/** + * Handles incoming command requests by resolving the interaction to the command + * it refers to, and calling it. + * + * @param interaction - The interaction the user made. This contains things like what + * command they called and with what arguments. + * @param requestingUser - The user who interacted with this command. + */ +export async function handleIsSelectMenu( + interaction: SelectMenuInteraction, + requestingUser: DiscordUserMapDocument +) { + try { + if (interaction.user.id !== requestingUser.discordID) { + await interaction.reply({ + ephemeral: true, + content: "You can't interact with other peoples select boxes, get your own!", + }); + } -// export type SelectHandlersType = Record< -// validSelectCustomIdPrefaces, -// (interaction: SelectMenuInteraction) => void -// >; -// export const selectHandlers: SelectHandlersType = { -// [validSelectCustomIdPrefaces.SelectGameForProfile]: async ( -// interaction: SelectMenuInteraction -// ): Promise => { -// const userId = parseInt(interaction.customId.split(":")[1]); -// await interaction.editReply( -// await buildProfileIntractable( -// userId, -// interaction.user.id, -// stringToSimpleGameType(interaction.values[0]) -// ) -// ); -// }, -// [validSelectCustomIdPrefaces.selectSongForSearch]: async ( -// interaction: SelectMenuInteraction -// ): Promise => { -// try { -// const interactionValues = interaction.values[0].split(":"); -// const embed = await buildChartEmbed({ -// songId: interactionValues[0], -// playtype: interactionValues[1], -// game: interactionValues[2], -// discordUserId: interaction.user.id, -// }); -// logger.info("Built new embed"); -// await interaction.editReply(embed); -// } catch (e) { -// logger.error(e); -// } -// }, -// }; + if (interaction.customId.startsWith("chart-select")) { + // horrendous hackery, but we only have 100 chars to store selector metadata. + const [, game, playtype, userID] = interaction.customId.match( + /!(.*):(.*):(.*)$/u + ) as unknown as [string, Game, Playtype, string]; -// export const handleIsSelectMenu = async (interaction: Interaction): Promise => { -// try { -// /** Rechecking required to enforce types */ -// if (interaction.isSelectMenu()) { -// await interaction.deferUpdate(); -// const customIdPrefix = interaction.customId.split(":")[0]; -// logger.info(`customIdPrefix: ${customIdPrefix}`); -// if (Object.values(validSelectCustomIdPrefaces).includes(customIdPrefix)) { -// await selectHandlers[customIdPrefix](interaction); -// } else { -// logger.info(`Unknown select handler: ${customIdPrefix}`); -// } -// } -// return; -// } catch (e) { -// logger.error(e); -// logger.error("Failed to handle isSelectMenu interaction"); -// } -// }; + const userDoc = await GetUserInfo(userID); + + const embed = await CreateChartScoresEmbed( + userDoc, + game, + playtype, + interaction.values[0] + ); + + await interaction.update({ embeds: [embed] }); + } else { + logger.warn(`Unknown selector triggered: ${interaction.customId}.`); + } + } catch (e) { + logger.error("Failed to handle isSelectMenu interaction", { error: e }); + } +} diff --git a/bot/src/main.ts b/bot/src/main.ts index e73d93875..77d73fe03 100644 --- a/bot/src/main.ts +++ b/bot/src/main.ts @@ -3,6 +3,7 @@ import { BotConfig, ServerConfig } from "./config"; import { LoggerLayers, METALLIC_MIND_SPLASHES } from "./data/data"; import { GetUserAndTokenForDiscordID } from "./database/queries"; import { handleIsCommand } from "./interactionHandlers/handleIsCommand"; +import { handleIsSelectMenu } from "./interactionHandlers/handleIsSelectMenu"; // import { handleIsSelectMenu } from "./interactionHandlers/handleIsSelectMenu"; import { app } from "./server/server"; import { RegisterSlashCommands } from "./slashCommands/register"; @@ -38,7 +39,7 @@ client.on("interactionCreate", async (interaction) => { } if (interaction.isSelectMenu()) { - // return handleIsSelectMenu(interaction); + return handleIsSelectMenu(interaction, requestingUser); } if (interaction.isCommand()) { diff --git a/bot/src/slashCommands/commands.ts b/bot/src/slashCommands/commands.ts index 22d872f71..ae4d757e7 100644 --- a/bot/src/slashCommands/commands.ts +++ b/bot/src/slashCommands/commands.ts @@ -5,6 +5,7 @@ import profile from "./commands/profile"; import quote from "./commands/quote"; import sync from "./commands/sync"; import whois from "./commands/whois"; +import chart_pb from "./commands/chart_pb"; import { SlashCommand } from "./types"; export const SLASH_COMMANDS: Map = new Map( @@ -14,6 +15,7 @@ export const SLASH_COMMANDS: Map = new Map( faq, whois, profile, + chart_pb, }) ); diff --git a/bot/src/slashCommands/commands/chart_pb.ts b/bot/src/slashCommands/commands/chart_pb.ts new file mode 100644 index 000000000..b093f889a --- /dev/null +++ b/bot/src/slashCommands/commands/chart_pb.ts @@ -0,0 +1,86 @@ +import { SlashCommandBuilder } from "@discordjs/builders"; +import { MessageActionRow, MessageSelectMenu, Util } from "discord.js"; +import { FormatChart, integer, SongDocument } from "tachi-common"; +import { GetGPTAndUser } from "../../utils/argParsers"; +import { CreateChartScoresEmbed } from "../../utils/embeds"; +import { TachiServerV1Get } from "../../utils/fetchTachi"; +import { GPTOptions, MakeRequired, OtherUserOption } from "../../utils/options"; +import { ChartQueryReturns } from "../../utils/returnTypes"; +import { SlashCommand } from "../types"; + +const command: SlashCommand = { + info: new SlashCommandBuilder() + .setName("chart_pb") + .setDescription("Retrieve your personal best on a given chart.") + .addStringOption(MakeRequired(GPTOptions)) + .addStringOption((str) => + str + .setName("song_name") + .setDescription("The name of the song to fetch info on.") + .setRequired(true) + ) + .addStringOption(OtherUserOption) + .toJSON(), + exec: async (interaction, requestingUser) => { + const gptUserInfo = await GetGPTAndUser(interaction, requestingUser); + + if (gptUserInfo.error !== null) { + return gptUserInfo.error; + } + + const search = interaction.options.getString("song_name", true); + + const { userDoc, game, playtype } = gptUserInfo.content; + + const chartsRes = await TachiServerV1Get( + `/games/${game}/${playtype}/charts`, + requestingUser.tachiApiToken, + { search } + ); + + if (!chartsRes.success) { + return `Failed to search charts for '${Util.escapeMarkdown(search)}'.`; + } + + if (chartsRes.body.charts.length === 0) { + return `Found no charts for the query '${Util.escapeMarkdown(search)}'.`; + } + + // discord caps selects at 25 ... :( + if (chartsRes.body.charts.length > 25) { + chartsRes.body.charts = chartsRes.body.charts.slice(0, 25); + } + + const firstChart = chartsRes.body.charts[0]; + + const songMap = new Map(); + + for (const song of chartsRes.body.songs) { + songMap.set(song.id, song); + } + + const firstSong = songMap.get(firstChart.songID); + + if (!firstSong) { + throw new Error(`No firstSong for equivalent firstChart ${firstChart.chartID}?`); + } + + const embed = await CreateChartScoresEmbed(userDoc, game, playtype, firstChart.chartID); + + const select = new MessageActionRow().addComponents( + new MessageSelectMenu() + .setCustomId(`chart-select!${game}:${playtype}:${userDoc.id}`) + .setPlaceholder("Select a different chart.") + .addOptions( + chartsRes.body.charts.map((chart) => ({ + label: FormatChart(game, songMap.get(chart.songID)!, chart), + value: chart.chartID, + })) + ) + ); + + return { embeds: [embed], components: [select] }; + }, +}; + +export default command; diff --git a/bot/src/slashCommands/types.ts b/bot/src/slashCommands/types.ts index 9a94944f1..e5293f08c 100644 --- a/bot/src/slashCommands/types.ts +++ b/bot/src/slashCommands/types.ts @@ -1,8 +1,13 @@ import { APIApplicationCommandOption } from "discord-api-types"; -import { CommandInteraction, MessageEmbed, MessagePayload } from "discord.js"; +import { + CommandInteraction, + InteractionReplyOptions, + MessageEmbed, + MessagePayload, +} from "discord.js"; import { DiscordUserMapDocument } from "../database/documents"; -export type Emittable = MessagePayload | string | MessageEmbed; +export type Emittable = MessagePayload | string | MessageEmbed | InteractionReplyOptions; export type Command = ( interaction: CommandInteraction, diff --git a/bot/src/utils/apiRequests.ts b/bot/src/utils/apiRequests.ts index 718e92936..1c6fd0a49 100644 --- a/bot/src/utils/apiRequests.ts +++ b/bot/src/utils/apiRequests.ts @@ -6,8 +6,12 @@ import { Playtype, PublicUserDocument, integer, + ChartDocument, + SongDocument, + PBScoreDocument, } from "tachi-common"; import { LoggerLayers } from "../data/data"; +import db from "../database/mongo"; import { RequestTypes, TachiServerV1Get, TachiServerV1Request } from "./fetchTachi"; import { CreateLayeredLogger } from "./logger"; import { Sleep } from "./misc"; @@ -51,6 +55,35 @@ export async function GetGoalWithID(goalID: string, game: Game, playtype: Playty return res.body; } +export async function GetChartInfoForUser( + userID: integer | string, + chartID: string, + game: Game, + playtype: Playtype +) { + const res = await TachiServerV1Get<{ song: SongDocument; chart: ChartDocument }>( + `/games/${game}/${playtype}/charts/${chartID}`, + null + ); + + if (!res.success) { + throw new Error(`Failed to fetch song/chart with chartID ${chartID}.`); + } + + const pbRes = await TachiServerV1Get<{ chart: ChartDocument; pb: PBScoreDocument }>( + `/users/${userID}/games/${game}/${playtype}/pbs/${chartID}`, + null + ); + + const pb = pbRes.success ? pbRes.body.pb : null; + + if (pb === null && pbRes.statusCode !== 404) { + throw new Error(`Failed to fetch score info for userID ${userID} on chart ${chartID}.`); + } + + return { song: res.body.song, chart: res.body.chart, pb }; +} + export async function PerformScoreImport( url: string, authToken: string, diff --git a/bot/src/utils/embeds.ts b/bot/src/utils/embeds.ts index 6204e2056..9bb60ae97 100644 --- a/bot/src/utils/embeds.ts +++ b/bot/src/utils/embeds.ts @@ -1,11 +1,22 @@ import { MessageEmbed } from "discord.js"; -import { FormatGame, GetGamePTConfig, ImportDocument, PublicUserDocument } from "tachi-common"; +import { + FormatChart, + FormatGame, + Game, + GetGamePTConfig, + ImportDocument, + Playtype, + PublicUserDocument, +} from "tachi-common"; import { BotConfig, ServerConfig } from "../config"; +import { GetChartInfoForUser } from "./apiRequests"; import { PrependTachiUrl } from "./fetchTachi"; import { + CreateChartLink, Entries, FormatDate, FormatProfileRating, + FormatScoreRating, MillisToSince, Pluralise, UppercaseFirst, @@ -98,3 +109,55 @@ export function CreateGameProfileEmbed(userDoc: PublicUserDocument, ugptStats: U `${BotConfig.TACHI_SERVER_LOCATION}/dashboard/users/${userDoc.username}/games/${game}/${playtype}` ); } + +export async function CreateChartScoresEmbed( + userDoc: PublicUserDocument, + game: Game, + playtype: Playtype, + chartID: string +) { + const { song, chart, pb } = await GetChartInfoForUser(userDoc.id, chartID, game, playtype); + + const embed = CreateEmbed() + .setTitle(`${userDoc.username}: ${FormatChart(game, song, chart)}`) + .setThumbnail(PrependTachiUrl(`/users/${userDoc.id}/pfp`)) + .setURL(CreateChartLink(chart, game)); + + if (pb === null) { + embed.setDescription("You have not played this chart."); + } else { + embed + .addField( + "Score", + `${pb.scoreData.score} (${pb.scoreData.grade}, ${pb.scoreData.percent.toFixed( + 2 + )}%)`, + true + ) + .addField("Lamp", pb.scoreData.lamp, true) + .addField( + "Ratings", + Entries(pb.calculatedData) + .map( + ([key, value]) => + `${UppercaseFirst(key)}: ${FormatScoreRating( + game, + playtype, + key, + value + )}` + ) + .join("\n") + ) + .addField( + "Last Raised", + pb.timeAchieved + ? `${FormatDate(pb.timeAchieved)} (${MillisToSince(pb.timeAchieved)})` + : "N/A", + true + ) + .addField("Ranking", `#**${pb.rankingData.rank}**/${pb.rankingData.outOf}`); + } + + return embed; +} diff --git a/bot/src/utils/misc.ts b/bot/src/utils/misc.ts index ce0252318..696a376e9 100644 --- a/bot/src/utils/misc.ts +++ b/bot/src/utils/misc.ts @@ -9,6 +9,8 @@ import { Playtype, UGSRatingsLookup, integer, + ChartDocument, + ScoreCalculatedDataLookup, } from "tachi-common"; import { GameClassSets } from "tachi-common/js/game-classes"; import { BotConfig } from "../config"; @@ -67,6 +69,25 @@ export function FormatProfileRating( return formatter(value); } +export function FormatScoreRating( + game: Game, + playtype: Playtype, + rating: ScoreCalculatedDataLookup[IDStrings], + value: number | null | undefined +) { + if (value === null || value === undefined) { + return "No Data."; + } + + const formatter = GetGamePTConfig(game, playtype).scoreRatingAlgFormatters[rating]; + + if (!formatter) { + return value.toFixed(2); + } + + return formatter(value); +} + /** * Typesafe asserted version of Object.entries. */ @@ -127,6 +148,9 @@ export function FormatClass( return gptConfig.classHumanisedFormat[classSet][classValue].display; } +/** + * Given a game, return the discord channel it's associated with. + */ export function GetGameChannel(client: Client, game: Game) { const gameChannelID = BotConfig.DISCORD.GAME_CHANNELS[game]; @@ -150,3 +174,16 @@ export function GetGameChannel(client: Client, game: Game) { return channel; } + +/** + * Given a chart and a game, return a link to the site for that chart. + */ +export function CreateChartLink(chart: ChartDocument, game: Game) { + if (chart.isPrimary) { + return `${BotConfig.TACHI_SERVER_LOCATION}/dashboard/games/${game}/${ + chart.playtype + }/songs/${chart.songID}/${encodeURIComponent(chart.difficulty)}`; + } + + return `${BotConfig.TACHI_SERVER_LOCATION}/dashboard/games/${game}/${chart.playtype}/songs/${chart.songID}/${chart.chartID}`; +} diff --git a/bot/src/utils/returnTypes.ts b/bot/src/utils/returnTypes.ts index e91e1f7c0..dffd89cee 100644 --- a/bot/src/utils/returnTypes.ts +++ b/bot/src/utils/returnTypes.ts @@ -8,6 +8,8 @@ import { UGSRatingsLookup, UserGameStats, integer, + ChartDocument, + SongDocument, } from "tachi-common"; export interface ServerConfig { @@ -50,3 +52,8 @@ export interface UGPTStats { totalScores: integer; rankingData: Record; } + +export interface ChartQueryReturns { + charts: (ChartDocument & { __playcount: integer })[]; + songs: SongDocument[]; +}