[bot-is] Merge main

This commit is contained in:
Pfych
2021-10-10 13:21:59 +11:00
8 changed files with 313 additions and 10 deletions
+132
View File
@@ -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 = <T extends Game>(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<PublicUserDocument> {}
export const getPBForChart = async <T extends Game>(
chartId: string,
playtype: Playtypes[T],
game: Game
): Promise<SimplePBDocument> => {
try {
const data = await TachiServerV1Get<PBResponse>(`/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 <T extends Game, I extends IDStrings = IDStrings>(args: {
searchResults?: SongSearchResult[];
songId?: string;
playtype: Playtypes[T];
game: Game;
/** @TODO Let users Toggle showing 2dExtra charts */
IIDExtra?: boolean;
}): Promise<InteractionReplyOptions | MessagePayload> => {
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 },
<UGSRatingsLookup[I]>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");
}
};
+101
View File
@@ -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<SongDocument>;
charts: ChartDocument[];
}
export const getSongMeta = async (songName: string): Promise<SongSearchResult[]> => {
try {
logger.info(`Searching for ${songName}`);
const songResponse = (
await TachiServerV1Get<SearchResult>("/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<SongSearchResult[]> => {
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 <T extends Game>(
songId: string,
playtype: Playtypes[T],
game: Game
): Promise<DetailedSongResponse> => {
try {
logger.info(`Getting detailed info for ${songId} (${game}:${playtype})`);
const data = await TachiServerV1Get<DetailedSongResponse>(`/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<void> => {
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);
}
};
+2
View File
@@ -11,5 +11,7 @@ export enum LoggerLayers {
profile = "profile",
selectInteractionHandler = "selectInteractionHandler",
buildProfileEmbed = "buildProfileEmbed",
chartSearch = "chartSearch",
buildChartEmbed = "buildChartEmbed",
tachiLinker = "tachiLinker"
}
@@ -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<void> => {
try {
@@ -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<validSelectCustomIdPrefaces, (interaction: SelectMenuInteraction) => void>;
export const selectHandlers: SelectHandlersType = {
[validSelectCustomIdPrefaces.SelectGameForProfile]: async (interaction: SelectMenuInteraction): Promise<void> => {
const userId = interaction.customId.split(":")[1];
await interaction.update(await buildProfileIntractable(userId, stringToSimpleGameType(interaction.values[0])));
},
[validSelectCustomIdPrefaces.selectSongForSearch]: async <T extends Game>(
interaction: SelectMenuInteraction
): Promise<void> => {
const interactionValues = interaction.values[0].split(":");
await interaction.update(
await buildChartEmbed({
songId: interactionValues[0],
playtype: <Playtypes[T]>interactionValues[1],
game: <T>interactionValues[2]
})
);
}
};
export const handleIsSelectMenu = async (interaction: Interaction): Promise<void> => {
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 = <validSelectCustomIdPrefaces>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");
}
};
+2 -1
View File
@@ -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) => {
+12
View File
@@ -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)
}
];
+30 -2
View File
@@ -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<Game>
};
export const formatGameWrapper = (gpt: SimpleGameType<Game>): 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(<Game>identifier), identifier];
});
};
export const formatGameScoreRating = <I extends IDStrings = IDStrings>(
gpt: SimpleGameType<Game>,
ratingName: UGSRatingsLookup[I],