mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-07 14:28:09 +03:00
[bot-is] Merge main
This commit is contained in:
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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,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) => {
|
||||
|
||||
@@ -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
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user