diff --git a/bot/.gitignore b/bot/.gitignore index fc8f7df0d..5e83af438 100644 --- a/bot/.gitignore +++ b/bot/.gitignore @@ -3,3 +3,4 @@ node_modules .vscode yarn-error.log .env +conf.json5 diff --git a/bot/package.json b/bot/package.json index d53eb745d..279d635cc 100644 --- a/bot/package.json +++ b/bot/package.json @@ -12,6 +12,7 @@ "@types/express-serve-static-core": "^4.17.24", "@types/jest": "^27.0.1", "@types/json5": "^2.2.0", + "@types/lodash": "^4.14.172", "@types/node": "^16.7.10", "@typescript-eslint/eslint-plugin": "^4.29.1", "@typescript-eslint/parser": "^4.29.1", @@ -24,12 +25,13 @@ "dependencies": { "@discordjs/builders": "^0.5.0", "@discordjs/rest": "^0.1.0-canary.0", + "axios": "^0.21.4", "deezer-api-ts": "^1.2.1", "discord.js": "^13.1.0", "dotenv": "^10.0.0", "express": "^4.17.1", "json5": "^2.2.0", - "node-fetch": "^3.0.0", + "lodash": "^4.17.21", "prudence": "^0.9.6", "songlink-api": "^1.0.0", "tachi-common": "^0.2.1", diff --git a/bot/src/config.ts b/bot/src/config.ts index a4e67a8a9..03865f7bb 100644 --- a/bot/src/config.ts +++ b/bot/src/config.ts @@ -8,5 +8,6 @@ export enum LoggerLayers { server = "server", serverAuth = "serverAuth", botConfigSetup = "botConfigSetup", - tachiFetch = "tachiFetch" + tachiFetch = "tachiFetch", + profile = "profile" } diff --git a/bot/src/index.ts b/bot/src/index.ts index c172ad4d1..10a1df701 100644 --- a/bot/src/index.ts +++ b/bot/src/index.ts @@ -1,10 +1,12 @@ import { Client, Intents } from "discord.js"; +import { UserGameStats } from "tachi-common"; +import { buildProfileIntractable } from "./profile/buildProfileEmbed"; import { ProcessEnv } from "./setup"; import { LoggerLayers } from "./config"; import { registerSlashCommands, slashCommands, SlashCommand } from "./slashCommands/register"; +import { TachiServerV1Get } from "./utils/fetch-tachi"; import { createLayeredLogger } from "./utils/logger"; - const logger = createLayeredLogger(LoggerLayers.client); const client = new Client({ @@ -12,9 +14,22 @@ const client = new Client({ }); client.on("interactionCreate", async (interaction) => { - if (!interaction.isCommand()) return; - try { + if (interaction.isSelectMenu()) { + /** @TODO Move this to its own handler! + * We only have one intractable but for now this works */ + const userId = interaction.customId.split(":")[1]; + const userData = (await TachiServerV1Get(`/users/${userId}/game-stats`))?.body; + if (userData) { + await interaction.update(buildProfileIntractable(userData, userId, interaction.values[0])); + } else { + throw new Error("Failed to re-fetch user data! This is a bad sign!"); + } + return; + } + + if (!interaction.isCommand()) return; + const command = slashCommands.find((command: SlashCommand) => { return command.info.name === interaction.commandName; }); @@ -38,7 +53,9 @@ client.on("interactionCreate", async (interaction) => { await client.login(ProcessEnv.DISCORD_TOKEN); logger.info(`Logged in successfully to ${client.guilds.cache.size} guilds`); logger.info( - `Invite URL: https://discord.com/api/oauth2/authorize?client_id=${client.application!.id}&permissions=8&scope=applications.commands%20bot` + `Invite URL: https://discord.com/api/oauth2/authorize?client_id=${ + client.application!.id + }&permissions=8&scope=applications.commands%20bot` ); await registerSlashCommands(client); diff --git a/bot/src/profile/buildProfileEmbed.ts b/bot/src/profile/buildProfileEmbed.ts new file mode 100644 index 000000000..68c836b39 --- /dev/null +++ b/bot/src/profile/buildProfileEmbed.ts @@ -0,0 +1,65 @@ +import { InteractionReplyOptions, MessageActionRow, MessageEmbed, MessagePayload, MessageSelectMenu } from "discord.js"; +import { FormatGame, Game, Playtypes, UserGameStats } from "tachi-common"; +import { IDStrings, UGSRatingsLookup } from "tachi-common/js/types"; +import { find } from "lodash"; + +export const buildProfileEmbed = (data: UserGameStats[], game?: string): MessageEmbed => { + const embed = new MessageEmbed().setColor("#cc527a"); + + /** @TODO FETCH USERS ACTUAL DETAILS */ + embed.addField("Foo", "Foo"); + embed.addField("Bar", "Bar"); + embed.addField("Baz", "Baz"); + embed.setThumbnail("https://cdn.mos.cms.futurecdn.net/mrArzwHcNuQbRwbEmuiwdJ.jpg"); + + if (game) { + const specificData: UserGameStats | undefined = find(data, (item) => game === `${item.game}:${item.playtype}`); + if (!specificData) { + throw new Error(`Could not get ratings for requested game: ${game}`); + } else { + const ratings: string[] = []; + Object.keys(specificData.ratings).map((rating) => { + /** @TODO MAKE THIS ITS OWN FUNCTION THIS IS FILTHY */ + ratings.push( + `${rating}: ${ + Math.round( + (specificData.ratings[rating as UGSRatingsLookup[IDStrings]] || 0 + Number.EPSILON) * 100 + ) / 100 + }` + ); + }); + + /** @TODO DONT SPLIT THE STRING HERE HOLY SHIT */ + embed.addField( + `Stats for: ${FormatGame(game.split(":")[0] as Game, game.split(":")[1] as Playtypes[never])}`, + `${ratings.join("\n")}` + ); + } + } else { + embed.addField("Select a game to see stats", "\u200B"); + } + + return embed; +}; + +export const buildProfileIntractable = ( + data: UserGameStats[], + userId: string, + game?: string +): InteractionReplyOptions | MessagePayload => { + const dropdown = new MessageActionRow().addComponents( + new MessageSelectMenu() + .setCustomId(`SelectGameForProfile:${userId}`) + .setPlaceholder("Browse By Game") + .addOptions( + data.map((game) => { + return { + label: `${FormatGame(game.game, game.playtype)}`, + value: `${game.game}:${game.playtype}` + }; + }) + ) + ); + + return { embeds: [buildProfileEmbed(data, game)], components: [dropdown] }; +}; diff --git a/bot/src/profile/fetch.ts b/bot/src/profile/fetch.ts new file mode 100644 index 000000000..c10c23779 --- /dev/null +++ b/bot/src/profile/fetch.ts @@ -0,0 +1,28 @@ +import { CommandInteraction } from "discord.js"; +import { LoggerLayers } from "../config"; +import { BotConfig } from "../setup"; +import { TachiServerV1Get } from "../utils/fetch-tachi"; +import { createLayeredLogger } from "../utils/logger"; +import { UserGameStats } from "tachi-common"; +import { buildProfileIntractable } from "./buildProfileEmbed"; + +const logger = createLayeredLogger(LoggerLayers.profile); + +export const getProfileByName = async (interaction: CommandInteraction): Promise => { + try { + const userId = interaction.options.getString("user", false) || "TODO FALLBACK TO SELF"; + logger.info(`Fetching user ${userId}`); + + const userData = (await TachiServerV1Get(`/users/${userId}/game-stats`))?.body; + logger.verbose(userData); + + if (userData) { + await interaction.reply(buildProfileIntractable(userData, userId)); + } else { + await interaction.reply("No user data found for user"); + } + } catch (e) { + logger.error(e); + await interaction.reply(BotConfig.GENERIC_ERROR_MESSAGE); + } +}; diff --git a/bot/src/setup.ts b/bot/src/setup.ts index d07a31bbd..2754f8724 100644 --- a/bot/src/setup.ts +++ b/bot/src/setup.ts @@ -18,6 +18,7 @@ export interface BotConfig { SERVER_PORT: number; TACHI_SERVER_LOCATION: string; OUR_URL: string; + GENERIC_ERROR_MESSAGE: string; } function ParseBotConfig(fileLoc = "conf.json5"): BotConfig { @@ -36,6 +37,7 @@ function ParseBotConfig(fileLoc = "conf.json5"): BotConfig { SERVER_PORT: Prudence.isPositiveNonZeroInteger, TACHI_SERVER_LOCATION: "string", OUR_URL: "string", + GENERIC_ERROR_MESSAGE: "string" }); if (err) { @@ -55,12 +57,17 @@ export interface ProcessEnvironment { } function ParseEnvVars() { - const err = Prudence(process.env, { - ENV: "string", - DISCORD_TOKEN: "string", - BOT_CLIENT_SECRET: "string", - BOT_CLIENT_ID: "string" - }, {}, { allowExcessKeys: true }); + const err = Prudence( + process.env, + { + ENV: "string", + DISCORD_TOKEN: "string", + BOT_CLIENT_SECRET: "string", + BOT_CLIENT_ID: "string" + }, + {}, + { allowExcessKeys: true } + ); if (err) { logger.error(FormatPrError(err, "Invalid environment. Cannot safely boot.")); @@ -73,5 +80,4 @@ function ParseEnvVars() { export const BotConfig: BotConfig = ParseBotConfig(process.env.CONF_JSON5_LOCATION); - export const ProcessEnv = ParseEnvVars(); diff --git a/bot/src/slashCommands/register.ts b/bot/src/slashCommands/register.ts index cf341743c..073fae8d1 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 { getProfileByName } from "../profile/fetch"; import { ProcessEnv } from "../setup"; import { LoggerLayers } from "../config"; import { help } from "../help/help"; @@ -23,6 +24,14 @@ export const slashCommands: SlashCommand[] = [ { info: new SlashCommandBuilder().setName("help").setDescription("Shows information about this bot").toJSON(), exec: async (interaction: CommandInteraction) => await help(interaction) + }, + { + info: new SlashCommandBuilder() + .setName("profile") + .setDescription("Displays a Kamaitachi Profile") + .addStringOption((option) => option.setName("user").setDescription("The users id").setRequired(false)) + .toJSON(), + exec: async (interaction: CommandInteraction) => await getProfileByName(interaction) } ]; diff --git a/bot/src/utils/fetch-tachi.ts b/bot/src/utils/fetch-tachi.ts index 951c07c88..f29641e3c 100644 --- a/bot/src/utils/fetch-tachi.ts +++ b/bot/src/utils/fetch-tachi.ts @@ -1,3 +1,4 @@ +import axios from "axios"; import { LoggerLayers } from "../config"; import { BotConfig } from "../setup"; import { integer, SuccessfulAPIResponse, UnsuccessfulAPIResponse } from "tachi-common"; @@ -5,14 +6,16 @@ import { createLayeredLogger } from "./logger"; const logger = createLayeredLogger(LoggerLayers.tachiFetch); -export type APIResponse = (SuccessfulAPIResponse | UnsuccessfulAPIResponse) & { statusCode: integer }; +export type APIResponse = (SuccessfulAPIResponse | (UnsuccessfulAPIResponse & { body: null })) & { + statusCode: integer; +}; export enum RequestTypes { GET = "GET", POST = "POST", PATCH = "PATCH", PUT = "PUT", - DELETE = "DELETE", + DELETE = "DELETE" // HEAD, OPTIONS not used by tachi-server anywhere. } @@ -24,22 +27,26 @@ export enum RequestTypes { * @param body - Optionally, provide some content for the request body. * @param T - A generic that asserts the type of the response contents. Defaults to unknown. */ -export async function TachiServerV1Request(method: Exclude, url: string, body: unknown = {}): Promise> { +export async function TachiServerV1Request( + method: Exclude, + url: string, + body: unknown = {} +): Promise> { const realUrl = PrependTachiUrl(url, "1"); const loggerUrl = `${method} ${realUrl}`; logger.debug(`Making a request to ${loggerUrl}.`); try { - const res = await fetch(realUrl, { + const res = await axios(realUrl, { method, headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body) + data: JSON.stringify(body) }); - const json = await res.json(); + const json = (await res.data) as APIResponse; const contents = { ...json, statusCode: res.status }; LogRequestResult(loggerUrl, contents); @@ -60,14 +67,17 @@ export async function TachiServerV1Request(method: Exclude(url: string, params: Record = {}): Promise> { +export async function TachiServerV1Get( + url: string, + params: Record = {} +): Promise> { try { const urlParams = new URLSearchParams(params); const realUrl = `${PrependTachiUrl(url, "1")}?${urlParams.toString()}`; - const res = await fetch(realUrl); - const json = await res.json(); + const res = await axios(realUrl, { method: RequestTypes.GET }); + const json = (await res.data) as APIResponse; const contents = { ...json, statusCode: res.status }; LogRequestResult(`GET ${realUrl}`, contents); diff --git a/bot/yarn.lock b/bot/yarn.lock index 4b82bfaeb..d7afb1e68 100644 --- a/bot/yarn.lock +++ b/bot/yarn.lock @@ -862,6 +862,11 @@ dependencies: "@types/node" "*" +"@types/lodash@^4.14.172": + version "4.14.172" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.172.tgz#aad774c28e7bfd7a67de25408e03ee5a8c3d028a" + integrity sha512-/BHF5HAx3em7/KkzVKm3LrsD6HZAXuXO1AJZQ3cRRBZj4oHZDviWPYu0aEplAqDFNHZPW6d3G7KN+ONcCCC7pw== + "@types/mime@^1": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" @@ -1282,6 +1287,13 @@ axios@^0.19.0: dependencies: follow-redirects "1.5.10" +axios@^0.21.4: + version "0.21.4" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" + integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== + dependencies: + follow-redirects "^1.14.0" + babel-jest@^27.1.0: version "27.1.0" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-27.1.0.tgz#e96ca04554fd32274439869e2b6d24de9d91bc4e" @@ -1846,11 +1858,6 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" -data-uri-to-buffer@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz#594b8973938c5bc2c33046535785341abc4f3636" - integrity sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og== - data-urls@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b" @@ -2397,13 +2404,6 @@ fecha@^4.2.0: resolved "https://registry.npmjs.org/fecha/-/fecha-4.2.1.tgz" integrity sha512-MMMQ0ludy/nBs1/o0zVOiKTpG7qMbonKUzjJgQFEuvq6INZ1OraKPRAWkBq5vlKLOUMpmNYG1JoN3oDPUQ9m3Q== -fetch-blob@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.1.2.tgz#6bc438675f3851ecea51758ac91f6a1cd1bacabd" - integrity sha512-hunJbvy/6OLjCD0uuhLdp0mMPzP/yd2ssd1t2FCJsaA7wkWhpbp9xfuNVpv7Ll4jFhzp6T4LAupSiV9uOeg0VQ== - dependencies: - web-streams-polyfill "^3.0.3" - file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz" @@ -2478,6 +2478,11 @@ follow-redirects@1.5.10: dependencies: debug "=3.1.0" +follow-redirects@^1.14.0: + version "1.14.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.3.tgz#6ada78118d8d24caee595595accdc0ac6abd022e" + integrity sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw== + foreground-child@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-2.0.0.tgz#71b32800c9f15aa8f2f83f4a6bd9bff35d861a53" @@ -3692,7 +3697,7 @@ lodash.truncate@^4.4.2: resolved "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz" integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM= -lodash@^4.7.0: +lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -3953,14 +3958,6 @@ node-fetch@^2.6.1: resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -node-fetch@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.0.0.tgz#79da7146a520036f2c5f644e4a26095f17e411ea" - integrity sha512-bKMI+C7/T/SPU1lKnbQbwxptpCrG9ashG+VkytmXCPZyuM9jB6VU+hY0oi4lC8LxTtAeWdckNCTa3nrGsAdA3Q== - dependencies: - data-uri-to-buffer "^3.0.1" - fetch-blob "^3.1.2" - node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -5365,11 +5362,6 @@ walker@^1.0.7: dependencies: makeerror "1.0.x" -web-streams-polyfill@^3.0.3: - version "3.1.0" - resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.1.0.tgz#86f983b4f44745502b0d8563d9ef3afc609d4465" - integrity sha512-wO9r1YnYe7kFBLHyyVEhV1H8VRWoNiNnuP+v/HUUmSTaRF8F93Kmd3JMrETx0f11GXxRek6OcL2QtjFIdc5WYw== - webidl-conversions@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff"