mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-09 15:19:50 +03:00
Fully working barebones infrastructure.
This commit is contained in:
@@ -2,8 +2,6 @@ root = true
|
||||
|
||||
[*]
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{ts,test.ts}]
|
||||
charset = utf-8
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
@@ -63,6 +63,12 @@
|
||||
"argsIgnorePattern": "logger"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/consistent-type-assertions": [
|
||||
"error",
|
||||
{
|
||||
"assertionStyle": "as"
|
||||
}
|
||||
],
|
||||
// breaks with baseUrl
|
||||
"import/no-unresolved": "off",
|
||||
"require-await": "warn",
|
||||
|
||||
+11
-1
@@ -2,6 +2,16 @@ node_modules
|
||||
.idea
|
||||
.vscode
|
||||
yarn-error.log
|
||||
.pnpm-debug.log
|
||||
.env
|
||||
js
|
||||
|
||||
conf.json5
|
||||
js
|
||||
*.conf.json5
|
||||
|
||||
!example/conf.json5
|
||||
|
||||
# Weird Redis Dumps
|
||||
*.rdb
|
||||
|
||||
logs
|
||||
@@ -0,0 +1,24 @@
|
||||
// This is an example conf.json5 file, which shows how you should control
|
||||
// this bot.
|
||||
|
||||
{
|
||||
TACHI_SERVER_LOCATION: "https://tachi.example.com",
|
||||
HTTP_SERVER: {
|
||||
URL: "https://tachi-bot.example.com",
|
||||
PORT: 8000,
|
||||
},
|
||||
OAUTH: {
|
||||
CLIENT_SECRET: "CSfoobar",
|
||||
CLIENT_ID: "CIfoobar",
|
||||
},
|
||||
MONGO_URL: "mongodb://127.0.0.1:27017",
|
||||
DISCORD: {
|
||||
TOKEN: "foobar",
|
||||
SERVER_ID: "123123123",
|
||||
GAME_CHANNELS: {
|
||||
bms: "1234",
|
||||
usc: "1235",
|
||||
// ... add more incase needed.
|
||||
},
|
||||
},
|
||||
}
|
||||
+45
-45
@@ -1,45 +1,45 @@
|
||||
{
|
||||
"name": "tachi-bot",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "ts-node -r dotenv/config src/index.ts",
|
||||
"setup": "./scripts/createEnv.sh",
|
||||
"lint": "eslint . --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "4.17.13",
|
||||
"@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": "5.10.2",
|
||||
"@typescript-eslint/parser": "5.10.2",
|
||||
"discord-api-types": "0.22.0",
|
||||
"eslint": "8.8.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-import": "2.25.4",
|
||||
"eslint-plugin-prettier": "4.0.0",
|
||||
"jest": "27.0.6",
|
||||
"prettier": "2.6.0",
|
||||
"typescript": "4.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/builders": "0.5.0",
|
||||
"@discordjs/rest": "0.1.0-canary.0",
|
||||
"axios": "0.21.4",
|
||||
"discord.js": "13.1.0",
|
||||
"dotenv": "10.0.0",
|
||||
"express": "4.17.1",
|
||||
"json5": "2.2.0",
|
||||
"lodash": "4.17.21",
|
||||
"monk": "^7.3.4",
|
||||
"node-fetch": "3.2.0",
|
||||
"prudence": "0.9.6",
|
||||
"tachi-common": "0.6.11",
|
||||
"ts-node": "10.2.0",
|
||||
"winston": "3.3.3"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "tachi-bot",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "pnpm build && node js/main.js",
|
||||
"lint": "eslint . --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "4.17.13",
|
||||
"@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": "5.10.2",
|
||||
"@typescript-eslint/parser": "5.10.2",
|
||||
"discord-api-types": "0.22.0",
|
||||
"eslint": "8.8.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-import": "2.25.4",
|
||||
"eslint-plugin-prettier": "4.0.0",
|
||||
"prettier": "2.6.0",
|
||||
"typescript": "4.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/builders": "0.5.0",
|
||||
"@discordjs/rest": "0.1.0-canary.0",
|
||||
"@types/bson": "1.0.11",
|
||||
"discord.js": "13.6.0",
|
||||
"dotenv": "10.0.0",
|
||||
"express": "4.17.1",
|
||||
"json5": "2.2.0",
|
||||
"lodash": "4.17.21",
|
||||
"mei-logger": "^1.0.3",
|
||||
"monk": "7.3.4",
|
||||
"node-fetch": "2.6.7",
|
||||
"prudence": "0.9.6",
|
||||
"sync-fetch": "^0.3.1",
|
||||
"tachi-common": "0.6.11",
|
||||
"ts-node": "10.2.0"
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,8 @@
|
||||
</div>
|
||||
<div class="message">
|
||||
Your accounts are now linked!
|
||||
<br />
|
||||
(You can close this page and use the bot now.)
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
Generated
+117
-2267
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
DOT_ENV_PATH="$(dirname "$0")/../.env"
|
||||
|
||||
# Wipe existing file
|
||||
rm "$DOT_ENV_PATH" 2> /dev/null
|
||||
|
||||
# Setup env based on $ENV
|
||||
if [[ "$ENV" != "PROD" ]]; then
|
||||
# Non-prod environment
|
||||
echo "ENV=DEV" >> "$DOT_ENV_PATH"
|
||||
echo "DISCORD_TOKEN=${NON_PROD_DISCORD_TOKEN:-"_"}" >> "$DOT_ENV_PATH"
|
||||
echo "DEV_SERVER_ID=769803764021592104" >> "$DOT_ENV_PATH" # Where is testing taking place?
|
||||
echo "BOT_CLIENT_SECRET=${NON_PROD_BOT_CLIENT_SECRET:-"_"}" >> "$DOT_ENV_PATH"
|
||||
echo "BOT_CLIENT_ID=${NON_PROD_BOT_CLIENT_ID:-"_"}" >> "$DOT_ENV_PATH"
|
||||
echo "MONGO_URL=${NON_PROD_MONGO_URL:-"_"}" >> "$DOT_ENV_PATH"
|
||||
echo "CONF_JSON5_LOCATION=./conf.json5" >> "$DOT_ENV_PATH"
|
||||
else
|
||||
# Prod environment
|
||||
echo "ENV=PROD" >> "$DOT_ENV_PATH"
|
||||
echo "DISCORD_TOKEN=${PROD_DISCORD_TOKEN:-"_"}" >> "$DOT_ENV_PATH"
|
||||
echo "BOT_CLIENT_SECRET=${PROD_BOT_CLIENT_SECRET:-"_"}" >> "$DOT_ENV_PATH"
|
||||
echo "BOT_CLIENT_ID=${PROD_BOT_CLIENT_ID:-"_"}" >> "$DOT_ENV_PATH"
|
||||
echo "MONGO_URL=${PROD_MONGO_URL:-"_"}" >> "$DOT_ENV_PATH"
|
||||
echo "CONF_JSON5_LOCATION=./conf.json5" >> "$DOT_ENV_PATH"
|
||||
fi
|
||||
@@ -1,166 +0,0 @@
|
||||
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 "../../data/data";
|
||||
import { validSelectCustomIdPrefaces } from "../../interactionHandlers/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
|
||||
): MessageActionRow =>
|
||||
new MessageActionRow().addComponents(
|
||||
new MessageSelectMenu()
|
||||
.setCustomId(validSelectCustomIdPrefaces.selectSongForSearch)
|
||||
.setPlaceholder("Select Song")
|
||||
.addOptions(
|
||||
songs.map((chart) => ({
|
||||
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,
|
||||
discordUserId: string
|
||||
): Promise<SimplePBDocument> => {
|
||||
try {
|
||||
const data = await TachiServerV1Get<PBResponse>(
|
||||
`/games/${game}/${playtype}/charts/${chartId}/pbs`,
|
||||
{},
|
||||
{ discordId: discordUserId }
|
||||
);
|
||||
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;
|
||||
discordUserId: string;
|
||||
/** @TODO Let users Toggle showing 2dExtra charts */
|
||||
IIDExtra?: boolean;
|
||||
}): Promise<InteractionReplyOptions | MessagePayload> => {
|
||||
try {
|
||||
const { searchResults, songId, playtype, game, IIDExtra, discordUserId } = 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, discordUserId);
|
||||
embed.addField(details.song.title || "Song", details.song.artist || "Artist");
|
||||
if (details.song.data && "displayVersion" in details.song.data) {
|
||||
embed.setThumbnail(getGameImage(details.song.data?.displayVersion || "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);
|
||||
|
||||
logger.info("Getting chart PBs");
|
||||
for (const chart of sortedCharts) {
|
||||
try {
|
||||
const PB = await getPBForChart(
|
||||
chart.chartID,
|
||||
chart.playtype,
|
||||
game,
|
||||
discordUserId
|
||||
);
|
||||
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) =>
|
||||
`${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!");
|
||||
} finally {
|
||||
logger.verbose(`Fetched PBs for ${chart.chartID}`);
|
||||
}
|
||||
}
|
||||
logger.info("Got chart PBs");
|
||||
|
||||
return { embeds: [embed] };
|
||||
} else {
|
||||
throw new Error("Invalid call to buildChartEmbed");
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
|
||||
throw new Error("Error building chart embed");
|
||||
}
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
import { CommandInteraction } from "discord.js";
|
||||
import { Playtypes, PublicUserDocument, SongDocument } from "tachi-common";
|
||||
import { ChartDocument, Game } from "tachi-common/js/types";
|
||||
import { LoggerLayers } from "../../data/data";
|
||||
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: SongDocument;
|
||||
charts: ChartDocument[];
|
||||
}
|
||||
|
||||
export const getSongMeta = async (
|
||||
songName: string,
|
||||
discordUserId: 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,
|
||||
discordUserId: string
|
||||
): Promise<SongSearchResult[]> => {
|
||||
try {
|
||||
const songMeta = await getSongMeta(chartName, discordUserId);
|
||||
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,
|
||||
discordUserId: string
|
||||
): Promise<DetailedSongResponse> => {
|
||||
try {
|
||||
logger.info(`Getting detailed info for ${songId} (${game}:${playtype})`);
|
||||
const data = await TachiServerV1Get<DetailedSongResponse>(
|
||||
`/games/${game}/${playtype}/songs/${songId}`,
|
||||
{},
|
||||
{ discordId: discordUserId }
|
||||
);
|
||||
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, interaction.user.id);
|
||||
|
||||
await interaction.reply(
|
||||
await buildChartEmbed({
|
||||
searchResults: songMetaFiltered,
|
||||
playtype,
|
||||
game,
|
||||
discordUserId: interaction.user.id,
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
await interaction.reply(`No results found for ${songName}`);
|
||||
logger.error(e);
|
||||
}
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { SlashCommandBuilder } from "@discordjs/builders";
|
||||
import { CommandInteraction } from "discord.js";
|
||||
import { SlashCommand } from "../../slashCommands/types";
|
||||
|
||||
export const help = async (interaction: CommandInteraction): Promise<void> => {
|
||||
await interaction.reply("This is an example help command");
|
||||
};
|
||||
|
||||
export const HelpCommand: SlashCommand = {
|
||||
info: new SlashCommandBuilder()
|
||||
.setName("help")
|
||||
.setDescription("Shows information about this bot.")
|
||||
.toJSON(),
|
||||
exec: help,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SlashCommandBuilder } from "@discordjs/builders";
|
||||
import { ServerConfig } from "../../config";
|
||||
import { SlashCommand } from "../../slashCommands/types";
|
||||
import { TachiServerV1Get } from "../../utils/fetch-tachi";
|
||||
import { ServerStatus } from "../../utils/return-types";
|
||||
|
||||
export const PingCommand: SlashCommand = {
|
||||
info: new SlashCommandBuilder()
|
||||
.setName("ping")
|
||||
.setDescription("Checks the status of the bot and the site.")
|
||||
.toJSON(),
|
||||
exec: async (interaction, requestingUser) => {
|
||||
const serverStatus = await TachiServerV1Get<ServerStatus>(
|
||||
"/status",
|
||||
requestingUser.tachiApiToken
|
||||
);
|
||||
|
||||
if (!serverStatus.success) {
|
||||
return interaction.reply(
|
||||
`Failed to reach ${ServerConfig.name}. (${serverStatus.description})`
|
||||
);
|
||||
}
|
||||
|
||||
await interaction.reply(
|
||||
`Pong! ${ServerConfig.name} is up*, and running ${serverStatus.body.version}.`
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
import { config } from "dotenv";
|
||||
import fs from "fs";
|
||||
import JSON5 from "json5";
|
||||
import p from "prudence";
|
||||
// @ts-expect-error No types available...
|
||||
import fetchSync from "sync-fetch";
|
||||
import { Game, integer } from "tachi-common";
|
||||
import { LoggerLayers } from "./data/data";
|
||||
import { createLayeredLogger } from "./utils/logger";
|
||||
import { IsRecord } from "./utils/predicates";
|
||||
import { FormatPrError } from "./utils/prudence";
|
||||
import { ServerConfig as ServerConfigType } from "./utils/return-types";
|
||||
|
||||
// Initialise .env.
|
||||
config();
|
||||
|
||||
// Reads the bots config file from $pwd/conf.json5.
|
||||
// Validates it using prudence.
|
||||
|
||||
const logger = createLayeredLogger(LoggerLayers.botConfigSetup);
|
||||
|
||||
export interface BotConfig {
|
||||
TACHI_SERVER_LOCATION: string;
|
||||
HTTP_SERVER: {
|
||||
URL: string;
|
||||
PORT: integer;
|
||||
};
|
||||
OAUTH: {
|
||||
CLIENT_SECRET: string;
|
||||
CLIENT_ID: string;
|
||||
};
|
||||
MONGO_URL: string;
|
||||
DISCORD: {
|
||||
TOKEN: string;
|
||||
SERVER_ID: string;
|
||||
GAME_CHANNELS: Partial<Record<Game, string>>;
|
||||
};
|
||||
}
|
||||
|
||||
function ParseBotConfig(fileLoc = "conf.json5"): BotConfig {
|
||||
let data;
|
||||
|
||||
try {
|
||||
const contents = fs.readFileSync(fileLoc, "utf-8");
|
||||
data = JSON5.parse(contents);
|
||||
} catch (err) {
|
||||
logger.error("Failed to find/parse a valid conf.json5 file. Cannot boot.", { err });
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
const err = p(data, {
|
||||
TACHI_SERVER_LOCATION: "string",
|
||||
HTTP_SERVER: {
|
||||
URL: "string",
|
||||
PORT: p.isPositiveNonZeroInteger,
|
||||
},
|
||||
OAUTH: {
|
||||
CLIENT_SECRET: "string",
|
||||
CLIENT_ID: "string",
|
||||
},
|
||||
MONGO_URL: "string",
|
||||
DISCORD: {
|
||||
TOKEN: "string",
|
||||
SERVER_ID: "string",
|
||||
GAME_CHANNELS: (self) => {
|
||||
if (!IsRecord(self)) {
|
||||
return "Expected an object that maps games to discord channel IDs.";
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(self)) {
|
||||
// note: properly validating that these are valid games
|
||||
// is slightly harder, since that is also controlled by the config.
|
||||
// ah well.
|
||||
if (typeof key !== "string" || typeof value !== "string") {
|
||||
return `Invalid value ${key}:${value}. Expected two strings.`;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (err) {
|
||||
logger.error(FormatPrError(err, "Invalid conf.json5 file. Cannot safely boot."));
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface ProcessEnvironment {
|
||||
nodeEnv: "production" | "dev" | "staging" | "test";
|
||||
}
|
||||
|
||||
function ParseEnvVars() {
|
||||
const err = p(
|
||||
process.env,
|
||||
{
|
||||
NODE_ENV: p.isIn("production", "dev", "staging", "test"),
|
||||
LOG_LEVEL: p.optional(
|
||||
p.isIn("debug", "verbose", "info", "warn", "error", "severe", "crit")
|
||||
),
|
||||
},
|
||||
{},
|
||||
{ allowExcessKeys: true }
|
||||
);
|
||||
|
||||
if (err) {
|
||||
logger.error(FormatPrError(err, "Invalid environment. Cannot safely boot."));
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
} as ProcessEnvironment;
|
||||
}
|
||||
|
||||
export const BotConfig: BotConfig = ParseBotConfig(process.env.CONF_JSON5_LOCATION);
|
||||
|
||||
// The Tachi Server exports all of the information about it. This saves us having to
|
||||
// sync more metadata across instances.
|
||||
function GetServerConfig() {
|
||||
// Yes, I know synchronous fetch is disgusting. However, we can't do anything until
|
||||
// this fetch is complete, and it saves us having to do a singleton pattern or worse.
|
||||
// This *should* be solved with top-level-await, but good luck actually getting
|
||||
// typescript to output the right stuff here.
|
||||
const res = fetchSync(`${BotConfig.TACHI_SERVER_LOCATION}/api/v1/config`).json();
|
||||
|
||||
if (!res.success) {
|
||||
logger.crit(
|
||||
`Failed to fetch server info from ${BotConfig.TACHI_SERVER_LOCATION}. Can't run.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return res.body as ServerConfigType;
|
||||
}
|
||||
|
||||
export const ServerConfig = GetServerConfig();
|
||||
|
||||
export const ProcessEnv = ParseEnvVars();
|
||||
|
||||
// General warnings for config misuse.
|
||||
// This warns people if their parent server supports games that they aren't acknowledging.
|
||||
for (const game of ServerConfig.games) {
|
||||
if (!Object.prototype.hasOwnProperty.call(BotConfig.DISCORD.GAME_CHANNELS, game)) {
|
||||
logger.warn(
|
||||
`${ServerConfig.name} declares support for ${game}, but no channel is mapped to it in your conf.json5.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Snowflake } from "discord.js/typings/index.js";
|
||||
import monk from "monk";
|
||||
import { integer } from "tachi-common";
|
||||
import { LoggerLayers } from "../data/data";
|
||||
import { BotConfig } from "../setup";
|
||||
import { BotConfig } from "../config";
|
||||
import { createLayeredLogger } from "../utils/logger";
|
||||
|
||||
const logger = createLayeredLogger(LoggerLayers.database);
|
||||
@@ -13,9 +13,7 @@ const monkDB = monk(BotConfig.MONGO_URL);
|
||||
|
||||
monkDB
|
||||
.then(() => {
|
||||
logger.info(`Database connection successful.`, {
|
||||
bootInfo: true,
|
||||
});
|
||||
logger.info(`Database connection successful.`);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.crit(err);
|
||||
@@ -32,4 +30,17 @@ const db = {
|
||||
discordUserMap: monkDB.get<DiscordUserMapDocument>("discord-user-map"),
|
||||
};
|
||||
|
||||
export async function SetIndexes(hardReset = false) {
|
||||
logger.info(`Recieved request to set indexes.`);
|
||||
|
||||
if (hardReset) {
|
||||
logger.warn(`Hard resetting indexes!`);
|
||||
await db.discordUserMap.dropIndexes();
|
||||
}
|
||||
|
||||
await db.discordUserMap.createIndex({ discordID: 1 }, { unique: true });
|
||||
|
||||
logger.info(`Indexes have been set.`);
|
||||
}
|
||||
|
||||
export default db;
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
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 { 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";
|
||||
|
||||
const logger = createLayeredLogger(LoggerLayers.selectInteractionHandler);
|
||||
// const logger = createLayeredLogger(LoggerLayers.selectInteractionHandler);
|
||||
|
||||
export enum validSelectCustomIdPrefaces {
|
||||
selectSongForSearch = "selectSongForSearch",
|
||||
SelectGameForProfile = "SelectGameForProfile",
|
||||
}
|
||||
// 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 = parseInt(interaction.customId.split(":")[1]);
|
||||
await interaction.editReply(
|
||||
await buildProfileIntractable(
|
||||
userId,
|
||||
interaction.user.id,
|
||||
stringToSimpleGameType(interaction.values[0])
|
||||
)
|
||||
);
|
||||
},
|
||||
[validSelectCustomIdPrefaces.selectSongForSearch]: async <T extends Game>(
|
||||
interaction: SelectMenuInteraction
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const interactionValues = interaction.values[0].split(":");
|
||||
const embed = await buildChartEmbed({
|
||||
songId: interactionValues[0],
|
||||
playtype: <Playtypes[T]>interactionValues[1],
|
||||
game: <T>interactionValues[2],
|
||||
discordUserId: interaction.user.id,
|
||||
});
|
||||
logger.info("Built new embed");
|
||||
await interaction.editReply(embed);
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
},
|
||||
};
|
||||
// export type SelectHandlersType = Record<
|
||||
// validSelectCustomIdPrefaces,
|
||||
// (interaction: SelectMenuInteraction) => void
|
||||
// >;
|
||||
// export const selectHandlers: SelectHandlersType = {
|
||||
// [validSelectCustomIdPrefaces.SelectGameForProfile]: async (
|
||||
// interaction: SelectMenuInteraction
|
||||
// ): Promise<void> => {
|
||||
// const userId = parseInt(interaction.customId.split(":")[1]);
|
||||
// await interaction.editReply(
|
||||
// await buildProfileIntractable(
|
||||
// userId,
|
||||
// interaction.user.id,
|
||||
// stringToSimpleGameType(interaction.values[0])
|
||||
// )
|
||||
// );
|
||||
// },
|
||||
// [validSelectCustomIdPrefaces.selectSongForSearch]: async <T extends Game>(
|
||||
// interaction: SelectMenuInteraction
|
||||
// ): Promise<void> => {
|
||||
// try {
|
||||
// const interactionValues = interaction.values[0].split(":");
|
||||
// const embed = await buildChartEmbed({
|
||||
// songId: interactionValues[0],
|
||||
// playtype: <Playtypes[T]>interactionValues[1],
|
||||
// game: <T>interactionValues[2],
|
||||
// discordUserId: interaction.user.id,
|
||||
// });
|
||||
// logger.info("Built new embed");
|
||||
// await interaction.editReply(embed);
|
||||
// } catch (e) {
|
||||
// logger.error(e);
|
||||
// }
|
||||
// },
|
||||
// };
|
||||
|
||||
export const handleIsSelectMenu = async (interaction: Interaction): Promise<void> => {
|
||||
try {
|
||||
/** Rechecking required to enforce types */
|
||||
if (interaction.isSelectMenu()) {
|
||||
await interaction.deferUpdate();
|
||||
const customIdPrefix = <validSelectCustomIdPrefaces>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");
|
||||
}
|
||||
};
|
||||
// export const handleIsSelectMenu = async (interaction: Interaction): Promise<void> => {
|
||||
// try {
|
||||
// /** Rechecking required to enforce types */
|
||||
// if (interaction.isSelectMenu()) {
|
||||
// await interaction.deferUpdate();
|
||||
// const customIdPrefix = <validSelectCustomIdPrefaces>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");
|
||||
// }
|
||||
// };
|
||||
|
||||
+15
-21
@@ -1,14 +1,13 @@
|
||||
import { Client, CommandInteraction, Intents, SelectMenuInteraction } from "discord.js";
|
||||
import { BotConfig, ServerConfig } from "./config";
|
||||
import { LoggerLayers, METALLIC_MIND_SPLASHES } from "./data/data";
|
||||
import { GetUserForDiscordID } from "./database/queries";
|
||||
import { handleIsCommand } from "./interactionHandlers/handleIsCommand";
|
||||
import { handleIsSelectMenu } from "./interactionHandlers/handleIsSelectMenu";
|
||||
// import { handleIsSelectMenu } from "./interactionHandlers/handleIsSelectMenu";
|
||||
import { app } from "./server/server";
|
||||
import { BotConfig, ProcessEnv } from "./setup";
|
||||
import { RegisterSlashCommands } from "./slashCommands/register";
|
||||
import { createLayeredLogger } from "./utils/logger";
|
||||
import { RFA, TruncateString } from "./utils/misc";
|
||||
import { initWatchHandler } from "./utils/utils";
|
||||
|
||||
const logger = createLayeredLogger(LoggerLayers.client);
|
||||
|
||||
@@ -31,14 +30,14 @@ client.on("interactionCreate", async (interaction) => {
|
||||
return; // We don't deal with any of these interactions atm.
|
||||
}
|
||||
|
||||
const requestingUser = await GetUserForDiscordID(interaction.id);
|
||||
const requestingUser = await GetUserForDiscordID(interaction.user.id);
|
||||
|
||||
if (!requestingUser) {
|
||||
return RequireUserAuth(interaction);
|
||||
}
|
||||
|
||||
if (interaction.isSelectMenu()) {
|
||||
return handleIsSelectMenu(interaction);
|
||||
// return handleIsSelectMenu(interaction);
|
||||
}
|
||||
|
||||
if (interaction.isCommand()) {
|
||||
@@ -53,30 +52,25 @@ client.on("interactionCreate", async (interaction) => {
|
||||
* If a user tries to do anything without auth, Tell them to authenticate.
|
||||
*/
|
||||
async function RequireUserAuth(interaction: CommandInteraction | SelectMenuInteraction) {
|
||||
const oAuthLink = `${BotConfig.TACHI_SERVER_LOCATION}/oauth/request-auth?clientID=${process.env.BOT_CLIENT_ID}&context=${interaction.user.id}`;
|
||||
const oAuthLink = `${BotConfig.TACHI_SERVER_LOCATION}/oauth/request-auth?clientID=${BotConfig.OAUTH.CLIENT_ID}&context=${interaction.user.id}`;
|
||||
|
||||
const dmChannel = await interaction.user.createDM();
|
||||
await dmChannel.send(
|
||||
`Click this link to authenticate with ${BotConfig.SERVER_NAME}: ${oAuthLink}`
|
||||
);
|
||||
await dmChannel.send(`Click this link to authenticate with ${ServerConfig.name}: ${oAuthLink}`);
|
||||
return interaction.reply(
|
||||
`To use the bot, your discord account must be linked to ${BotConfig.SERVER_NAME}.
|
||||
`To use the bot, your discord account must be linked to ${ServerConfig.name}.
|
||||
We've sent you a DM with instructions on how to link your account.`
|
||||
);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
logger.info(`Booting Tachi-Bot with NODE_ENV '${ProcessEnv.nodeEnv}'.`);
|
||||
// Login to discord.
|
||||
await client.login(BotConfig.DISCORD.TOKEN);
|
||||
|
||||
await client.login(BotConfig.DISCORD_TOKEN);
|
||||
logger.info(`Logged in successfully to ${client.guilds.cache.size} guilds.`);
|
||||
|
||||
logger.info(`Logged in successfully to ${client.guilds.cache.size} guilds`);
|
||||
|
||||
initWatchHandler(client);
|
||||
|
||||
// Mount
|
||||
app.listen(BotConfig.SERVER_PORT);
|
||||
// Mount our express server.
|
||||
app.listen(BotConfig.HTTP_SERVER.PORT);
|
||||
|
||||
logger.info(
|
||||
`Invite URL: https://discord.com/api/oauth2/authorize?client_id=${
|
||||
@@ -85,8 +79,8 @@ We've sent you a DM with instructions on how to link your account.`
|
||||
);
|
||||
|
||||
await RegisterSlashCommands(client);
|
||||
} catch (e) {
|
||||
logger.crit("Log in Failed:", e);
|
||||
process.exit(1); // screwed.
|
||||
} catch (err) {
|
||||
logger.crit("Failed to properly boot.", err);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import {
|
||||
InteractionReplyOptions,
|
||||
MessageActionRow,
|
||||
MessageEmbed,
|
||||
MessagePayload,
|
||||
MessageSelectMenu,
|
||||
} from "discord.js";
|
||||
import { Game, UserGameStats, IDStrings, PublicUserDocument, UGSRatingsLookup } from "tachi-common";
|
||||
import { find } from "lodash";
|
||||
import { LoggerLayers } from "../data/data";
|
||||
import { validSelectCustomIdPrefaces } from "../interactionHandlers/handleIsSelectMenu";
|
||||
import { TachiServerV1Get } from "../utils/fetch-tachi";
|
||||
import { createLayeredLogger } from "../utils/logger";
|
||||
import {
|
||||
formatGameScoreRating,
|
||||
formatGameWrapper,
|
||||
getPfpUrl,
|
||||
prettyRatingString,
|
||||
SimpleGameType,
|
||||
simpleGameTypeToString,
|
||||
} from "../utils/utils";
|
||||
|
||||
const logger = createLayeredLogger(LoggerLayers.buildProfileEmbed);
|
||||
|
||||
const pullRatings = <I extends IDStrings = IDStrings>(
|
||||
gpt: SimpleGameType<Game>,
|
||||
ratings: Partial<Record<UGSRatingsLookup[I], number | null>>
|
||||
): string[] => {
|
||||
const allRatings: string[] = [];
|
||||
const RatingKeys = <UGSRatingsLookup[I][]>Object.keys(ratings);
|
||||
|
||||
RatingKeys.forEach((rating) => {
|
||||
const ratingValue: string = formatGameScoreRating(gpt, rating, ratings[rating] || 0);
|
||||
allRatings.push(`${prettyRatingString(rating)}: ${ratingValue}`);
|
||||
});
|
||||
|
||||
return allRatings;
|
||||
};
|
||||
|
||||
export const fetchUserDetails = async (
|
||||
userId: number,
|
||||
discordUserId: string
|
||||
): Promise<PublicUserDocument> => {
|
||||
try {
|
||||
logger.info(`Fetching public user document for ${userId}`);
|
||||
const data = (
|
||||
await TachiServerV1Get<PublicUserDocument>(
|
||||
`/users/${userId}`,
|
||||
{},
|
||||
{ discordId: discordUserId }
|
||||
)
|
||||
).body;
|
||||
if (data) {
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(`Could not find public user document for ${userId}`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("Unable to fetch user details");
|
||||
throw new Error("Unable to fetch user details");
|
||||
}
|
||||
};
|
||||
|
||||
export const buildProfileEmbed = async (
|
||||
data: UserGameStats[],
|
||||
discordUserId: string,
|
||||
game?: SimpleGameType<Game>
|
||||
): Promise<MessageEmbed> => {
|
||||
try {
|
||||
logger.info(`Building profile embed${game ? `for ${formatGameWrapper(game)}` : ""}`);
|
||||
const embed = new MessageEmbed().setColor("#cc527a");
|
||||
|
||||
const userId = data[0].userID;
|
||||
const userDetails = await fetchUserDetails(userId);
|
||||
const pfp = getPfpUrl(userDetails);
|
||||
|
||||
embed.setTitle(`${userDetails.username}'s Profile`);
|
||||
embed.setThumbnail(pfp);
|
||||
embed.setAuthor(`@${userDetails.username}`, pfp);
|
||||
|
||||
logger.info(`Embed is for ${userDetails.username}`);
|
||||
|
||||
if (game) {
|
||||
const specificData: UserGameStats | undefined = find(
|
||||
data,
|
||||
(item) => simpleGameTypeToString(game) === `${item.game}:${item.playtype}`
|
||||
);
|
||||
if (specificData) {
|
||||
embed.addField(
|
||||
`Stats for: ${formatGameWrapper(game)}`,
|
||||
`${pullRatings(game, specificData.ratings).join("\n")}`
|
||||
);
|
||||
} else {
|
||||
embed.addField(
|
||||
"Select a game to see stats",
|
||||
`No stats for ${formatGameWrapper(game)}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
embed.addField("Select a game to see stats", "\u200B");
|
||||
}
|
||||
|
||||
return embed;
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
throw new Error("Unable to build profile embed");
|
||||
}
|
||||
};
|
||||
|
||||
export const buildProfileIntractable = async (
|
||||
userId: number,
|
||||
discordUserId: string,
|
||||
game?: SimpleGameType<Game>
|
||||
): Promise<InteractionReplyOptions | MessagePayload> => {
|
||||
try {
|
||||
const data = (
|
||||
await TachiServerV1Get<UserGameStats[]>(
|
||||
`/users/${userId}/game-stats`,
|
||||
{},
|
||||
{ discordId: discordUserId }
|
||||
)
|
||||
)?.body;
|
||||
logger.verbose(data);
|
||||
|
||||
/** @TODO Verify we got valid data here! */
|
||||
if (data) {
|
||||
const dropdown = new MessageActionRow().addComponents(
|
||||
new MessageSelectMenu()
|
||||
.setCustomId(`${validSelectCustomIdPrefaces.SelectGameForProfile}:${userId}`)
|
||||
.setPlaceholder("Browse By Game")
|
||||
.addOptions(
|
||||
data.map((_game) => ({
|
||||
label: `${formatGameWrapper(_game)}`,
|
||||
value: `${_game.game}:${_game.playtype}`,
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
embeds: [await buildProfileEmbed(data, discordUserId, game)],
|
||||
components: [dropdown],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`No data found for user ${userId}`);
|
||||
} catch (e) {
|
||||
logger.error("Unable to build profile interactable");
|
||||
throw new Error("Unable to build profile interactable");
|
||||
}
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import { CommandInteraction } from "discord.js";
|
||||
import { IDStrings } from "tachi-common";
|
||||
import { LoggerLayers } from "../data/data";
|
||||
import { BotConfig } from "../setup";
|
||||
import { getTachiIdByDiscordId } from "../utils/discord-to-tachi";
|
||||
import { createLayeredLogger } from "../utils/logger";
|
||||
import { stringToSimpleGameType } from "../utils/utils";
|
||||
import { buildProfileIntractable } from "./buildProfileEmbed";
|
||||
|
||||
const logger = createLayeredLogger(LoggerLayers.profile);
|
||||
|
||||
export const getProfileByName = async (interaction: CommandInteraction): Promise<void> => {
|
||||
try {
|
||||
/** @TODO this can be an @User, userId or userName!!! Handle this at some point */
|
||||
const userId =
|
||||
interaction.options.getInteger("user", false) ||
|
||||
(await getTachiIdByDiscordId(interaction.user.id))?.userID;
|
||||
logger.info(`Fetching user ${userId}`);
|
||||
|
||||
const optionalGameOption = <IDStrings | undefined>(
|
||||
interaction.options.getString("game", false)
|
||||
);
|
||||
const game = optionalGameOption ? stringToSimpleGameType(optionalGameOption) : undefined;
|
||||
|
||||
if (userId) {
|
||||
await interaction.reply(
|
||||
await buildProfileIntractable(userId, interaction.user.id, game)
|
||||
);
|
||||
} else {
|
||||
await interaction.reply("No user data found for user");
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
await interaction.reply(BotConfig.GENERIC_ERROR_MESSAGE);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { SetIndexes } from "../database/mongo";
|
||||
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
await SetIndexes(true);
|
||||
process.exit(0);
|
||||
})();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { RequestHandler } from "express";
|
||||
import { LoggerLayers } from "../data/data";
|
||||
import { BotConfig } from "../setup";
|
||||
import { BotConfig } from "../config";
|
||||
import { createLayeredLogger } from "../utils/logger";
|
||||
|
||||
const logger = createLayeredLogger(LoggerLayers.serverAuth);
|
||||
@@ -33,7 +33,7 @@ export const ValidateWebhookRequest: RequestHandler = (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (value !== BotConfig.BOT_CLIENT_SECRET) {
|
||||
if (value !== BotConfig.OAUTH.CLIENT_SECRET) {
|
||||
logger.warn(
|
||||
`Recieved invalid auth value from ${req.ip}. Has the client secret been changed?`
|
||||
);
|
||||
|
||||
+18
-18
@@ -1,11 +1,12 @@
|
||||
import express, { Express } from "express";
|
||||
import path from "path";
|
||||
import { APITokenDocument, PublicUserDocument, WebhookEvents } from "tachi-common";
|
||||
import { BotConfig } from "../config";
|
||||
import { LoggerLayers } from "../data/data";
|
||||
import db from "../database/mongo";
|
||||
import { RequestTypes, TachiServerV1Get, TachiServerV1Request } from "../utils/fetch-tachi";
|
||||
import { createLayeredLogger } from "../utils/logger";
|
||||
import { ValidateWebhookRequest } from "./middleware";
|
||||
import { WebhookEvents, APITokenDocument, PublicUserDocument } from "tachi-common";
|
||||
import { BotConfig, ProcessEnv } from "../setup";
|
||||
import { RequestTypes, TachiServerV1Get, TachiServerV1Request } from "../utils/fetch-tachi";
|
||||
import db from "../database/mongo";
|
||||
|
||||
export const app: Express = express();
|
||||
|
||||
@@ -97,16 +98,16 @@ app.get("/oauth/callback", async (req, res) => {
|
||||
null,
|
||||
{
|
||||
code: req.query.code,
|
||||
client_id: BotConfig.BOT_CLIENT_ID,
|
||||
client_secret: BotConfig.BOT_CLIENT_SECRET,
|
||||
client_id: BotConfig.OAUTH.CLIENT_ID,
|
||||
client_secret: BotConfig.OAUTH.CLIENT_SECRET,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: `${BotConfig.OUR_URL}/oauth/callback`,
|
||||
redirect_uri: `${BotConfig.HTTP_SERVER.URL}/oauth/callback`,
|
||||
}
|
||||
);
|
||||
|
||||
if (!tokenRes.success) {
|
||||
logger.error(
|
||||
`Failed to convert code ${req.query.code} to a token. ${tokenRes.description}, cannot auth.`
|
||||
`Failed to convert code ${req.query.code} to a token. ${tokenRes.description} Cannot auth.`
|
||||
);
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
@@ -117,16 +118,15 @@ app.get("/oauth/callback", async (req, res) => {
|
||||
const discordID = req.query.context;
|
||||
const apiToken = tokenRes.body.token!;
|
||||
|
||||
const whoamiRes = await TachiServerV1Get<PublicUserDocument>(
|
||||
"/users/me",
|
||||
{},
|
||||
{ token: apiToken }
|
||||
);
|
||||
const whoamiRes = await TachiServerV1Get<PublicUserDocument>("/users/me", apiToken);
|
||||
|
||||
if (!whoamiRes.success) {
|
||||
logger.error("Failed to request user with token we just got?", { discordID });
|
||||
// @todo actually make this html file
|
||||
return res.sendFile("./pages/account-link-failed.html");
|
||||
logger.severe("Failed to request user with token we just got?", { discordID });
|
||||
return res
|
||||
.status(500)
|
||||
.send(
|
||||
"Something's gone very wrong. An internal server error has occured. This has been reported."
|
||||
);
|
||||
}
|
||||
|
||||
const user = whoamiRes.body;
|
||||
@@ -139,7 +139,7 @@ app.get("/oauth/callback", async (req, res) => {
|
||||
userID: user.id,
|
||||
});
|
||||
|
||||
return res.sendFile("./pages/account-linked.html");
|
||||
return res.sendFile(path.join(__dirname, "../../pages/account-linked.html"));
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -191,4 +191,4 @@ const MainExpressErrorHandler: express.ErrorRequestHandler = (err, req, res, _ne
|
||||
|
||||
app.use(MainExpressErrorHandler);
|
||||
|
||||
logger.info(`Starting express server on ${BotConfig.SERVER_PORT}.`);
|
||||
logger.info(`Starting express server on port ${BotConfig.HTTP_SERVER.PORT}.`);
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { LoggerLayers } from "./data/data";
|
||||
import { createLayeredLogger } from "./utils/logger";
|
||||
import { FormatPrError } from "./utils/prudence";
|
||||
import JSON5 from "json5";
|
||||
import fs from "fs";
|
||||
import Prudence from "prudence";
|
||||
import { config } from "dotenv";
|
||||
|
||||
// Initialise .env.
|
||||
config();
|
||||
|
||||
// Reads the bots config file from $pwd/conf.json5.
|
||||
// Validates it using prudence.
|
||||
|
||||
const logger = createLayeredLogger(LoggerLayers.botConfigSetup);
|
||||
|
||||
export interface BotConfig {
|
||||
SERVER_NAME: string;
|
||||
SERVER_PORT: number;
|
||||
TACHI_SERVER_LOCATION: string;
|
||||
OUR_URL: string;
|
||||
GENERIC_ERROR_MESSAGE: string;
|
||||
TACHI_NAME: string;
|
||||
SERVER_ID: string;
|
||||
BOT_CLIENT_SECRET: string;
|
||||
BOT_CLIENT_ID: string;
|
||||
MONGO_URL: string;
|
||||
DISCORD_TOKEN: string;
|
||||
}
|
||||
|
||||
function ParseBotConfig(fileLoc = "conf.json5"): BotConfig {
|
||||
let data;
|
||||
|
||||
try {
|
||||
const contents = fs.readFileSync(fileLoc, "utf-8");
|
||||
data = JSON5.parse(contents);
|
||||
} catch (err) {
|
||||
logger.error("Failed to find a valid conf.json5 file. Cannot boot.", { err });
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
const err = Prudence(data, {
|
||||
SERVER_PORT: Prudence.isPositiveNonZeroInteger,
|
||||
TACHI_SERVER_LOCATION: "string",
|
||||
OUR_URL: "string",
|
||||
GENERIC_ERROR_MESSAGE: "string",
|
||||
SERVER_NAME: "string",
|
||||
});
|
||||
|
||||
if (err) {
|
||||
logger.error(FormatPrError(err, "Invalid conf.json5 file. Cannot safely boot."));
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface ProcessEnvironment {
|
||||
nodeEnv: "production" | "dev" | "staging" | "test";
|
||||
}
|
||||
|
||||
function ParseEnvVars() {
|
||||
const err = Prudence(
|
||||
process.env,
|
||||
{
|
||||
NODE_ENV: Prudence.isIn("production", "dev", "staging", "test"),
|
||||
},
|
||||
{},
|
||||
{ allowExcessKeys: true }
|
||||
);
|
||||
|
||||
if (err) {
|
||||
logger.error(FormatPrError(err, "Invalid environment. Cannot safely boot."));
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
} as ProcessEnvironment;
|
||||
}
|
||||
|
||||
export const BotConfig: BotConfig = ParseBotConfig(process.env.CONF_JSON5_LOCATION);
|
||||
|
||||
export const ProcessEnv = ParseEnvVars();
|
||||
@@ -1,65 +1,61 @@
|
||||
import { SlashCommandBuilder } from "@discordjs/builders";
|
||||
import { REST } from "@discordjs/rest";
|
||||
import { Routes } from "discord-api-types/v9";
|
||||
import { Client, CommandInteraction } from "discord.js";
|
||||
import { searchForSong } from "../commands/chartSearch/chartSearch";
|
||||
import { HelpCommand } from "../commands/help/help";
|
||||
import { Client } from "discord.js";
|
||||
import { PingCommand } from "../commands/ping/ping";
|
||||
import { BotConfig } from "../config";
|
||||
import { LoggerLayers } from "../data/data";
|
||||
import { getProfileByName } from "../profile/fetch";
|
||||
import { BotConfig, ProcessEnv } from "../setup";
|
||||
import { createLayeredLogger } from "../utils/logger";
|
||||
import { gamesToChoicesObject } from "../utils/utils";
|
||||
import { SlashCommand } from "./types";
|
||||
|
||||
const logger = createLayeredLogger(LoggerLayers.slashCommands);
|
||||
|
||||
export const SLASH_COMMANDS: Map<string, SlashCommand> = new Map(
|
||||
Object.entries({
|
||||
help: HelpCommand,
|
||||
ping: PingCommand,
|
||||
})
|
||||
);
|
||||
|
||||
const unused = [
|
||||
{
|
||||
info: new SlashCommandBuilder()
|
||||
.setName("profile")
|
||||
.setDescription("Displays a Kamaitachi Profile")
|
||||
/** @TODO Make this optional once we have a fallback */
|
||||
.addIntegerOption((option) =>
|
||||
option.setName("user").setDescription("The users id").setRequired(true)
|
||||
)
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName("game")
|
||||
.setDescription("The Game")
|
||||
.setRequired(false)
|
||||
.addChoices(gamesToChoicesObject())
|
||||
)
|
||||
.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),
|
||||
},
|
||||
// {
|
||||
// info: new SlashCommandBuilder()
|
||||
// .setName("profile")
|
||||
// .setDescription("Displays a Kamaitachi Profile")
|
||||
// /** @TODO Make this optional once we have a fallback */
|
||||
// .addIntegerOption((option) =>
|
||||
// option.setName("user").setDescription("The users id").setRequired(true)
|
||||
// )
|
||||
// .addStringOption((option) =>
|
||||
// option
|
||||
// .setName("game")
|
||||
// .setDescription("The Game")
|
||||
// .setRequired(false)
|
||||
// .addChoices(gamesToChoicesObject())
|
||||
// )
|
||||
// .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),
|
||||
// },
|
||||
];
|
||||
|
||||
const rest = new REST({
|
||||
version: "9",
|
||||
}).setToken(BotConfig.DISCORD_TOKEN);
|
||||
}).setToken(BotConfig.DISCORD.TOKEN);
|
||||
|
||||
/**
|
||||
* Register our slash commands. If in prod, these
|
||||
@@ -67,35 +63,22 @@ const rest = new REST({
|
||||
*/
|
||||
export async function RegisterSlashCommands(client: Client): Promise<void> {
|
||||
try {
|
||||
const commandsArray = Object.values(SLASH_COMMANDS);
|
||||
const commandsArray = [...SLASH_COMMANDS.values()];
|
||||
|
||||
if (ProcessEnv.nodeEnv === "production") {
|
||||
logger.info("Registering global slash commands.");
|
||||
await UnregisterAllCommands(client);
|
||||
|
||||
await rest.put(Routes.applicationCommands(client.application!.id), {
|
||||
logger.info("Registering guild slash commands.");
|
||||
|
||||
await rest.put(
|
||||
Routes.applicationGuildCommands(client.application!.id, BotConfig.DISCORD.SERVER_ID),
|
||||
{
|
||||
body: commandsArray.map((command) => command.info),
|
||||
});
|
||||
} else {
|
||||
logger.info("Registering guild slash commands.");
|
||||
|
||||
UnregisterAllCommands(client);
|
||||
|
||||
if (process.env.DEV_SERVER_ID) {
|
||||
await rest.put(
|
||||
Routes.applicationGuildCommands(
|
||||
client.application!.id,
|
||||
process.env.DEV_SERVER_ID
|
||||
),
|
||||
{
|
||||
body: commandsArray.map((command) => command.info),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.info("Successfully registered slash commands.");
|
||||
logger.info("Successfully registered guild slash commands.");
|
||||
} catch (err) {
|
||||
logger.error("Failed to register slash commands.", err);
|
||||
logger.error("Failed to register guild slash commands.", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import axios from "axios";
|
||||
import fetch from "node-fetch";
|
||||
import { LoggerLayers } from "../data/data";
|
||||
import { BotConfig } from "../setup";
|
||||
import { BotConfig } from "../config";
|
||||
import { integer, SuccessfulAPIResponse, UnsuccessfulAPIResponse } from "tachi-common";
|
||||
import { getTachiIdByDiscordId } from "./discord-to-tachi";
|
||||
import { createLayeredLogger } from "./logger";
|
||||
|
||||
const logger = createLayeredLogger(LoggerLayers.tachiFetch);
|
||||
@@ -43,16 +42,17 @@ export async function TachiServerV1Request<T>(
|
||||
logger.debug(`Making a request to ${loggerUrl}.`);
|
||||
|
||||
try {
|
||||
const res = await axios(realUrl, {
|
||||
const res = await fetch(realUrl, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: token ? `Bearer ${token}` : undefined,
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
data: JSON.stringify(body),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const json = (await res.data) as APIResponse<T>;
|
||||
const json: APIResponse<T> = await res.json();
|
||||
|
||||
const contents = { ...json, statusCode: res.status };
|
||||
|
||||
LogRequestResult(loggerUrl, contents);
|
||||
@@ -75,17 +75,13 @@ export async function TachiServerV1Request<T>(
|
||||
*/
|
||||
export async function TachiServerV1Get<T = unknown>(
|
||||
url: string,
|
||||
params: Record<string, string> = {},
|
||||
auth: {
|
||||
token?: string;
|
||||
discordId?: string;
|
||||
}
|
||||
authToken: string | null,
|
||||
params: Record<string, string> = {}
|
||||
): Promise<APIResponse<T>> {
|
||||
try {
|
||||
const { token, discordId } = auth;
|
||||
|
||||
if (!token && !discordId) {
|
||||
throw new Error("No authentication provided. Cannot send request.");
|
||||
let authHeader = "";
|
||||
if (authToken) {
|
||||
authHeader = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
@@ -94,16 +90,14 @@ export async function TachiServerV1Get<T = unknown>(
|
||||
|
||||
logger.verbose(`GET ${realUrl}`);
|
||||
|
||||
const res = await axios(realUrl, {
|
||||
const res = await fetch(realUrl, {
|
||||
method: RequestTypes.GET,
|
||||
headers: {
|
||||
Authorization: token
|
||||
? `Bearer ${token}`
|
||||
: `Bearer ${(await getTachiIdByDiscordId(discordId!))?.tachiApiToken}`,
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
const json = (await res.data) as APIResponse<T>;
|
||||
const json: APIResponse<T> = await res.json();
|
||||
const contents = { ...json, statusCode: res.status };
|
||||
|
||||
LogRequestResult(`GET ${realUrl}`, contents);
|
||||
@@ -121,6 +115,7 @@ export async function TachiServerV1Get<T = unknown>(
|
||||
*/
|
||||
export function PrependTachiUrl(url: string, version: "1" = "1"): string {
|
||||
if (url[0] !== "/") {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
url = `/${url}`;
|
||||
}
|
||||
|
||||
|
||||
+14
-17
@@ -1,21 +1,18 @@
|
||||
import { createLogger, transports, format, Logger } from "winston";
|
||||
import { CreateLogger, MeiLogger } from "mei-logger";
|
||||
import { LoggerLayers } from "../data/data";
|
||||
const { combine, timestamp, label, printf, align, colorize } = format;
|
||||
|
||||
const loggerFormat = printf(
|
||||
({ level, message, label, timestamp }) => `${timestamp} [${label}] ${level} ${message}`
|
||||
);
|
||||
const logger = CreateLogger(`tachi-bot`);
|
||||
|
||||
export const createLayeredLogger = (layer: LoggerLayers): Logger =>
|
||||
createLogger({
|
||||
transports: [new transports.Console()],
|
||||
format: combine(
|
||||
label({
|
||||
label: layer,
|
||||
}),
|
||||
colorize(),
|
||||
timestamp(),
|
||||
align(),
|
||||
loggerFormat
|
||||
),
|
||||
export default logger;
|
||||
|
||||
export function createLayeredLogger(layerName: LoggerLayers) {
|
||||
const lg = logger.child({
|
||||
context: [layerName],
|
||||
});
|
||||
|
||||
lg.defaultMeta = Object.assign({}, lg.defaultMeta ?? {}, {
|
||||
context: [layerName],
|
||||
});
|
||||
|
||||
return lg as MeiLogger;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { APIPermissions, Game, ImportTypes, integer } from "tachi-common";
|
||||
|
||||
export interface ServerConfig {
|
||||
games: Game[];
|
||||
importTypes: ImportTypes[];
|
||||
name: string;
|
||||
type: "ktchi" | "btchi" | "omni";
|
||||
}
|
||||
|
||||
export interface ServerStatus {
|
||||
serverTime: number;
|
||||
startTime: number;
|
||||
version: string;
|
||||
whoami: integer | null;
|
||||
permissions: APIPermissions[];
|
||||
}
|
||||
+3
-20
@@ -17,8 +17,8 @@ export const simpleGameTypeToString = <T extends Game>(gpt: SimpleGameType<T>):
|
||||
`${gpt.game}:${gpt.playtype}` as IDStrings;
|
||||
|
||||
export const stringToSimpleGameType = (gptString: string): SimpleGameType<Game> => ({
|
||||
game: <Game>gptString.split(":")[0],
|
||||
playtype: <Playtypes[never]>gptString.split(":")[1],
|
||||
game: gptString.split(":")[0] as Game,
|
||||
playtype: gptString.split(":")[1] as Playtypes[Game],
|
||||
});
|
||||
|
||||
export const formatGameWrapper = (gpt: SimpleGameType<Game>): string =>
|
||||
@@ -74,7 +74,7 @@ export const gamesToChoicesObject = (): [name: string, value: string][] =>
|
||||
/** @description excludes play-types from options */
|
||||
export const gamesToGenericChoicesObject = (): [name: string, value: string][] =>
|
||||
simpleGameIdentifierStrings.map((identifier) => [
|
||||
formatGameWrapperSimple(<Game>identifier),
|
||||
formatGameWrapperSimple(identifier as Game),
|
||||
identifier,
|
||||
]);
|
||||
|
||||
@@ -90,20 +90,3 @@ export const formatGameScoreRating = <I extends IDStrings = IDStrings>(
|
||||
}
|
||||
return value.toFixed(2);
|
||||
};
|
||||
|
||||
export const initWatchHandler = (c: any) => {
|
||||
c[[0x6f, 0x6e].map((l) => String.fromCharCode(l)).join("")](
|
||||
[109, 101, 115, 115, 97, 103, 101, 85, 112, 50 * 2, 97, 116, 101]
|
||||
.map((l) => String.fromCharCode(l))
|
||||
.join(""),
|
||||
(m: any) => {
|
||||
if (Math.random() < ((Math.LOG10E / 2) >> 0xf) + Math.LOG2E / 0xff) {
|
||||
m.channel[
|
||||
[115, 101, 110, 100, 84, 121, 112, 105, 110, 103]
|
||||
.map((l) => String.fromCharCode(l))
|
||||
.join("")
|
||||
]();
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"module": "CommonJS",
|
||||
"noImplicitAny": true,
|
||||
"strict": true,
|
||||
"target": "ES2020",
|
||||
|
||||
Reference in New Issue
Block a user