Merge pull request #45 from TNG-dev/zkldi/finish-structure

This commit is contained in:
zkldi
2022-03-21 13:08:08 +00:00
committed by GitHub
45 changed files with 3661 additions and 5968 deletions
-2
View File
@@ -2,8 +2,6 @@ root = true
[*]
insert_final_newline = true
[*.{ts,test.ts}]
charset = utf-8
indent_style = tab
indent_size = 4
+6
View File
@@ -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
View File
@@ -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
+25
View File
@@ -0,0 +1,25 @@
// 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",
},
DISCORD: {
TOKEN: "foobar",
SERVER_ID: "123123123",
GAME_CHANNELS: {
bms: "1234",
usc: "1235",
// ... add more incase needed.
},
// A list of user IDs for users who should be allowed to do more stuff.
ADMIN_USERS: [],
},
}
+2
View File
@@ -0,0 +1,2 @@
MONGO_URL="127.0.0.1:27017/tachi_bot_db"
NODE_ENV="dev"
+47 -43
View File
@@ -1,44 +1,48 @@
{
"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",
"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",
"@types/luxon": "^2.3.1",
"deepmerge": "^4.2.2",
"discord.js": "13.6.0",
"dotenv": "10.0.0",
"express": "4.17.1",
"json5": "2.2.0",
"lodash": "4.17.21",
"luxon": "^2.3.1",
"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>
+2594
View File
File diff suppressed because it is too large Load Diff
-25
View File
@@ -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 "../../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
): 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");
}
};
-120
View File
@@ -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 "../../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: 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);
}
};
+12
View File
@@ -0,0 +1,12 @@
import ping from "./ping/ping";
import quote from "./quote/quote";
import faq from "./faq/faq";
import { SlashCommand } from "./types";
export const SLASH_COMMANDS: Map<string, SlashCommand> = new Map(
Object.entries({
ping,
quote,
faq,
})
);
+74
View File
@@ -0,0 +1,74 @@
import { SlashCommandBuilder } from "@discordjs/builders";
import deepmerge from "deepmerge";
import { ServerConfig } from "../../config";
import { CreateEmbed } from "../../utils/embeds";
import { SlashCommand } from "../types";
const NEUTRAL_FAQ_ENTRIES: Record<string, string> = {
duplicates: `Scores on ${ServerConfig.name} are deduplicated based on your score and lamp (and some other things).
If you happen to get the exact same score twice, ${ServerConfig.name} will **ignore** the second one!
There are legitimate reasons for this -- it's very common for people to import the same scores twice through file uploads or import scripts.
For more info on why this is a fundamental limitation of ${ServerConfig.name}, check [the documentation](https://tachi.readthedocs.io/en/latest/user/score-oddities/#deduplication-false-positives-all-games).`,
contribute: `Contributing to ${ServerConfig.name} in any way will get you the Contributor role, and a cool green name.\n
Contributors who save us hours (or more) of dev time, or are just generally really supportive will get the Significant Contributor role, and an even cooler orange name.
${ServerConfig.name} is an Open Source project. Feel free to read our [contribution guide](https://tachi.readthedocs.io/en/latest/contributing/), or just generally ask for stuff to help out with!`,
docs: `Documentation for ${ServerConfig.name} is stored at https://tachi.rtfd.io.`,
pbs: `A PB is all of yhour best scores on that specific chart joined together.
For most games, this means joining your best score with your best lamp.
Read more about this [here](https://tachi.readthedocs.io/en/latest/user/pbs-scores/)`,
filter_directives: `Filter Directives are a fancy way of *filtering* rows inside a table.
They provide an advanced toolkit for users to perform complex queries on their data.
Read more about them [here](https://tachi.readthedocs.io/en/latest/user/filter-directives/)`,
};
// Server specific FAQ stuff.
const KTCHI_FAQ_ENTRIES: Record<string, string> = {
kai_support: `Support for KAI based networks (FLO, EAG, MIN) is available, but we are waiting on APIs for more games on their end. At the moment, only IIDX and SDVX are supported.`,
bokutachi: `Bokutachi is our public sister website for home games and simulators. Feel free to check out [the discord](${
// Note: obfuscating this for obvious reasons so we don't get garbage bot spam.
Buffer.from("aHR0cHM6Ly9kaXNjb3JkLmdnL3N3VkJUanhtUFk=", "base64")
})`,
};
const BTCHI_FAQ_ENTRIES: Record<string, string> = {
usc_hard_mode: `Hard Mode windows are not supported on ${ServerConfig.name}. Your scores **will be ignored** if they are played on non-standard windows.`,
};
let faqEntries = NEUTRAL_FAQ_ENTRIES;
if (ServerConfig.type !== "btchi") {
faqEntries = deepmerge(faqEntries, KTCHI_FAQ_ENTRIES);
}
if (ServerConfig.type !== "ktchi") {
faqEntries = deepmerge(faqEntries, BTCHI_FAQ_ENTRIES);
}
const choiceMap = new Map(Object.entries(faqEntries));
const command: SlashCommand = {
info: new SlashCommandBuilder()
.setName("faq")
.setDescription("Retrieve various little bits of info.")
.addStringOption((s) =>
s
.setName("entry")
.setDescription("The FAQ entry to retrieve.")
.setRequired(true)
.addChoices(Object.keys(faqEntries).map((e) => [e, e]))
)
.toJSON(),
exec: (interaction) => {
const entry = interaction.options.getString("entry", true);
const data = choiceMap.get(entry);
if (!data) {
return `This FAQ entry does not exist.`;
}
return CreateEmbed().setTitle(`FAQ: ${entry}`).setDescription(data);
},
};
export default command;
-5
View File
@@ -1,5 +0,0 @@
import { CommandInteraction } from "discord.js";
export const help = async (interaction: CommandInteraction): Promise<void> => {
await interaction.reply("This is an example help command");
};
+26
View File
@@ -0,0 +1,26 @@
import { SlashCommandBuilder } from "@discordjs/builders";
import { ServerConfig } from "../../config";
import { SlashCommand } from "../types";
import { TachiServerV1Get } from "../../utils/fetch-tachi";
import { ServerStatus } from "../../utils/return-types";
const command: 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 `Failed to reach ${ServerConfig.name}. (${serverStatus.description})`;
}
return `Pong! ${ServerConfig.name} is up, and running ${serverStatus.body.version}.`;
},
};
export default command;
+179
View File
@@ -0,0 +1,179 @@
import { SlashCommandBuilder } from "@discordjs/builders";
import { Util } from "discord.js";
import { escapeRegExp } from "lodash";
import db from "../../database/mongo";
import { GetQuoteWithID } from "../../database/queries";
import { GetUserInfo } from "../../utils/api-requests";
import { CreateEmbed } from "../../utils/embeds";
import { FormatDate, IsAdmin, TruncateString } from "../../utils/misc";
import { SlashCommand } from "../types";
const command: SlashCommand = {
info: new SlashCommandBuilder()
.setName("quote")
.setDescription("Interact with the quote database.")
.addSubcommand((sub) =>
sub
.setName("read")
.setDescription("Read a quote.")
.addStringOption((str) =>
str.setName("name").setDescription("The quote to retrieve.").setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName("write")
.setDescription("Write a quote.")
.addStringOption((str) =>
str.setName("name").setDescription("The name for this quote.").setRequired(true)
)
.addStringOption((str) =>
str
.setName("text")
.setDescription("The content for this quote.")
.setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName("delete")
.setDescription("Delete a quote (Admin only).")
.addStringOption((str) =>
str.setName("name").setDescription("The quote to delete.").setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName("search_text")
.setDescription("Search for quotes by their text.")
.addStringOption((str) =>
str.setName("query").setDescription("What to search for.").setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName("search_id")
.setDescription("Search for quotes by their id.")
.addStringOption((str) =>
str.setName("query").setDescription("What to search for.").setRequired(true)
)
)
.toJSON(),
exec: async (interaction, requestingUser) => {
const subCommand = interaction.options.getSubcommand() as
| "read"
| "write"
| "delete"
| "search_text"
| "search_id";
if (subCommand === "search_id" || subCommand === "search_text") {
const query = interaction.options.getString("query", true).trim();
if (!/^[a-z0-9]{3,20}$/u.test(query)) {
return `This query is invalid. It must be all lowercase characters (or numbers), and between 3 and 20 characters.`;
}
let quotes;
if (subCommand === "search_id") {
quotes = await db.quotes.find(
{
quoteID: new RegExp(escapeRegExp(query), "iu"),
},
{
sort: { hits: -1 },
limit: 10,
}
);
} else {
quotes = await db.quotes.find(
{
text: new RegExp(escapeRegExp(query), "iu"),
},
{
sort: { hits: -1 },
limit: 10,
}
);
}
if (quotes.length === 0) {
return `Couldn't find any quotes that matched this query.`;
}
return CreateEmbed()
.setTitle(`Searched quotes for '${query}'`)
.setDescription(`Found ${quotes.length} quote${quotes.length === 1 ? "" : "s"}.`)
.addFields(
quotes.map((q) => ({
name: q.quoteID,
value: TruncateString(q.text, 140),
}))
);
}
const quoteID = interaction.options.getString("name", true).trim();
if (!/^[a-z0-9]{3,20}$/u.test(quoteID)) {
return `This quote name is invalid. It must be all lowercase characters (or numbers), and between 3 and 20 characters.`;
}
const quote = await GetQuoteWithID(quoteID);
if (subCommand === "read") {
if (!quote) {
return `No quote exists for '${Util.escapeMarkdown(quoteID)}'.`;
}
await db.quotes.update({ quoteID }, { $inc: { hits: 1 } });
const authorInfo = await GetUserInfo(quote.quotedBy);
return CreateEmbed()
.setTitle(`Quote: ${quoteID}`)
.setDescription(`\`\`\`${Util.escapeMarkdown(quote.text)}\`\`\``)
.setFooter({
text: `Quoted by ${authorInfo.username} on ${FormatDate(
quote.quotedAt
)}. This quote has been read ${quote.hits + 1} times.`,
});
} else if (subCommand === "write") {
const text = interaction.options.getString("text", true);
if (quote) {
return `A quote exists for '${Util.escapeMarkdown(quoteID)}' already.`;
}
if (text.length > 480) {
return `Quotes are capped at 480 characters. Not quoting this.`;
}
await db.quotes.insert({
quoteID,
text,
hits: 0,
quotedAt: Date.now(),
quotedBy: requestingUser.userID,
});
return `Quote '${Util.escapeMarkdown(quoteID)}' has been saved.`;
} else if (subCommand === "delete") {
if (!IsAdmin(requestingUser.discordID)) {
return `You are not authorised to delete quotes.`;
}
if (!quote) {
return `No quote exists for '${Util.escapeMarkdown(quoteID)}'.`;
}
await db.quotes.remove({ quoteID: quote.quoteID });
return `Quote '${Util.escapeMarkdown(quoteID)}' has been deleted.`;
}
throw new Error(`Unknown subcommand '${subCommand}'. How did this happen?`);
},
};
export default command;
+19
View File
@@ -0,0 +1,19 @@
import { APIApplicationCommandOption } from "discord-api-types";
import { CommandInteraction, MessageEmbed, MessagePayload } from "discord.js";
import { DiscordUserMapDocument } from "../database/documents";
export type Emittable = MessagePayload | string | MessageEmbed;
export type Command = (
interaction: CommandInteraction,
requestingUser: DiscordUserMapDocument
) => Promise<Emittable> | Emittable;
export interface SlashCommand {
info: {
name: string;
description: string;
options: APIApplicationCommandOption[];
};
exec: Command;
}
+157 -91
View File
@@ -1,94 +1,160 @@
/** @TODO Potentially re-work this to only run on PROD env, handy for DEV env! **/
export const allowedChannels = ["876321394005254214"];
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";
export enum LoggerLayers {
client = "client",
slashCommands = "slashCommands",
server = "server",
serverAuth = "serverAuth",
botConfigSetup = "botConfigSetup",
tachiFetch = "tachiFetch",
profile = "profile",
selectInteractionHandler = "selectInteractionHandler",
buildProfileEmbed = "buildProfileEmbed",
chartSearch = "chartSearch",
buildChartEmbed = "buildChartEmbed",
tachiAuth = "tachiAuth",
// 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;
};
DISCORD: {
TOKEN: string;
SERVER_ID: string;
GAME_CHANNELS: Partial<Record<Game, string>>;
ADMIN_USERS: string[];
};
}
export const theFunny = [
"because 352 people are in this server and they can see that i was looking at chokers and thigh highs",
"Invalid command. (No similar commands found, either.)",
"**You may be a victim of software counterfeiting.**\n\nTo use all Microsoft Windows® features, such as all updates\nfrom Windows Update; get all the latest updates; and receive\nproduct support, your copy of Microsoft Windows® must be\nvalidated as genuine.\n\n__Go online and resolve now__",
"The Score Tracking Man He Sees Everything Like This",
"'DROP TABLE scores;--",
"Blessed Data",
"No Nonsense",
"Maybe a little nonsense",
"Mr Worldwide",
"Ceci n'est pas un splash text",
"mowgli, loOK OUT",
"Swish swoosh pew pew",
"All this scratching is making me itch",
"Pace yourself",
"What is this, a crossover episode?",
"npm install humor",
"OKNOTOK",
"Unlimited multiplication of money up to 300%!",
"What happens if I print a dictionary?",
"Whatever people say I am...",
"When the four corners of this coccoon collide...",
"WE CAN DO BETTER",
"WE WILL DO BETTER",
"BREAK YOUR NIHILISM",
"float chordjack = jack * 0.75f;",
"malicious walls",
"my computer called cheesebuger",
"Unban me from Quaver",
"...we came in?",
"isn't this where...",
"You are simply neglecting the law of large numbers.",
"I'm using tilt controls!",
"Relax yourself, girl, please set-tle down",
"Michael Jackson IS Monty Python.",
"1.01^365",
"WARNING: That is not falco",
"it's been one week since ya looked at me",
"IT AIN'T NO EASY GRAB THEY GOT T",
"AUTOGLASS REPAIR",
"AUTOGLASS REPLACE",
"turn on RANDOM!",
"It is deliberate.",
"Buy the golden ninja pack!",
"WITHIN REASON!",
"Put the maid dress on.",
"And it just happens to be fantastically enjoyable.",
"Touch the statue. I will forgive you.",
"https://github.com/EpicGames/Signup/pull/10",
"Have you tried PNPM?",
"MongoDB is web scale, actually.",
"▼LTRA ▼IOLENCE EDITION",
"It's all about the CEO grindset.",
"through ivy - out middle",
"The graphics. That's what it's really about.",
"you could be playing mario galaxy right now",
"...When yours just crashes?",
"My Beautiful Score Tracking Fantasy",
"The Supercharged Score Tracker.",
"Play ULTRAKILL.",
"A Cutting-Edge Score Tracker (heh).",
"Winnipeg is a warm, nice place to live.",
"zkldi once said: i piss my bed twice a month and that really annoys me",
"I accidentally the score tracker.",
"~𝒕𝒉𝒆 𝒈𝒆𝒂𝒓𝒔 𝒕𝒖𝒓𝒏 𝒐𝒏𝒄𝒆 𝒎𝒐𝒓𝒆~",
"come on down",
"The preparation for a dive is always a tense time.",
"Music Is Math",
"The public gets what the public wants!",
"Formerly Chucks",
"Node.js Is Bad Ass Rock Star Tech",
"i'm obligated to ask for you to seek immediate psychological attention",
"Is IIDX still a good game or are you all basically stupid?",
"A point in the horizon, a melting scene from your childhood. Your mortality is showing a frantic drift towards nothing, biology doomed to an infinite recursive loop. Teeth with teeth with teeth. Take a bite. Serene scene of a coastal town, warmth of the sun. Bitter tears. Lust for power. This is where you abandoned your dreams. You are a high net worth individual, an expanding vortex of pathetic trauma. Finally a beautiful fucking nerve ape. A pure soul is born, its neurotransactions stutter into being. 30583750937509353 operations per nanosecond. Beauty eludes your porous mind.\n\nThe value of Life is negative. The balance of being is rotated by 38 degrees. The surface is full of cracks, a turgid light shines through. Fleshy primordial bodies sluggishly roll down the slope. Only you slide upwards, with a celestial step. You become beautified, a saintly figure. Your pristine idiocy reveals a safe path through the impenetrable fog of life. Your dull sword cuts through the weak tendons and membranes of the garden of corruption. Sit on the throne of contentment and ferment. Inspect the eternal blue skies of your kingdom. You come to a realization. You pick up and onion and begin peeling.\n\nOnion layer one. Onion layer two. Onion layer three ... Onion layer n^n. Aeons have passed and the onion is fully peeled. Nothing remains. It's perfect. You get lost in the point that remains where the onion used to be. Synaptic cascade, neurological catastrophe. The point becomes infinitely dense, the universe condenses into a unicellular being. It screams sin. It craves happiness. It's done with this world. It tries to commit suicide but it fails. Sad pathetic mess. You feel pity and disgust but in a way only a being of pure grace can. In your violent mercy you terminate the worldlife.",
"Please do not reply to or quote my posts in the future.",
];
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",
},
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;
},
// A list of users that are allowed to do powerful stuff.
ADMIN_USERS: ["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";
mongoUrl: string;
}
function ParseEnvVars() {
const err = p(
process.env,
{
NODE_ENV: p.isIn("production", "dev", "staging", "test"),
// mei implicitly reads this.
LOG_LEVEL: p.optional(
p.isIn("debug", "verbose", "info", "warn", "error", "severe", "crit")
),
MONGO_URL: "string",
},
{},
{ allowExcessKeys: true }
);
if (err) {
logger.error(FormatPrError(err, "Invalid environment. Cannot safely boot."));
throw err;
}
return {
nodeEnv: process.env.NODE_ENV,
mongoUrl: process.env.MONGO_URL,
} 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.`
);
}
}
+38
View File
@@ -0,0 +1,38 @@
export enum LoggerLayers {
client = "client",
slashCommands = "slashCommands",
server = "server",
serverAuth = "serverAuth",
botConfigSetup = "botConfigSetup",
tachiFetch = "tachiFetch",
profile = "profile",
selectInteractionHandler = "selectInteractionHandler",
buildProfileEmbed = "buildProfileEmbed",
chartSearch = "chartSearch",
buildChartEmbed = "buildChartEmbed",
tachiAuth = "tachiAuth",
database = "database",
databaseQuery = "databaseQuery",
}
export const METALLIC_MIND_SPLASHES = [
"because 352 people are in this server and they can see that i was looking at chokers and thigh highs",
"No similar commands found, either.",
"**You may be a victim of software counterfeiting.**\n\nTo use all Microsoft Windows® features, such as all updates\nfrom Windows Update; get all the latest updates; and receive\nproduct support, your copy of Microsoft Windows® must be\nvalidated as genuine.\n\n__Go online and resolve now__",
"'DROP TABLE scores;--",
"Ceci n'est pas un splash text",
"Swish swoosh pew pew",
"What is this, a crossover episode?",
"npm install humor",
"Unban me from Quaver",
"You are simply neglecting the law of large numbers.",
"I'm using tilt controls!",
"IT AIN'T NO EASY GRAB THEY GOT T",
"turn on RANDOM!",
"It's all about the CEO grindset.",
"you could be playing mario galaxy right now",
"Play ULTRAKILL.",
"zkldi once said: i piss my bed twice a month and that really annoys me",
"A point in the horizon, a melting scene from your childhood. Your mortality is showing a frantic drift towards nothing, biology doomed to an infinite recursive loop. Teeth with teeth with teeth. Take a bite. Serene scene of a coastal town, warmth of the sun. Bitter tears. Lust for power. This is where you abandoned your dreams. You are a high net worth individual, an expanding vortex of pathetic trauma. Finally a beautiful fucking nerve ape. A pure soul is born, its neurotransactions stutter into being. 30583750937509353 operations per nanosecond. Beauty eludes your porous mind.\n\nThe value of Life is negative. The balance of being is rotated by 38 degrees. The surface is full of cracks, a turgid light shines through. Fleshy primordial bodies sluggishly roll down the slope. Only you slide upwards, with a celestial step. You become beautified, a saintly figure. Your pristine idiocy reveals a safe path through the impenetrable fog of life. Your dull sword cuts through the weak tendons and membranes of the garden of corruption. Sit on the throne of contentment and ferment. Inspect the eternal blue skies of your kingdom. You come to a realization. You pick up and onion and begin peeling.\n\nOnion layer one. Onion layer two. Onion layer three ... Onion layer n^n. Aeons have passed and the onion is fully peeled. Nothing remains. It's perfect. You get lost in the point that remains where the onion used to be. Synaptic cascade, neurological catastrophe. The point becomes infinitely dense, the universe condenses into a unicellular being. It screams sin. It craves happiness. It's done with this world. It tries to commit suicide but it fails. Sad pathetic mess. You feel pity and disgust but in a way only a being of pure grace can. In your violent mercy you terminate the worldlife.",
"Please do not reply to or quote my posts in the future.",
];
+16
View File
@@ -0,0 +1,16 @@
import { Snowflake } from "discord.js";
import { integer } from "tachi-common";
export interface DiscordUserMapDocument {
userID: integer;
discordID: Snowflake;
tachiApiToken: string;
}
export interface QuoteDocument {
quoteID: string;
text: string;
quotedAt: integer;
quotedBy: integer;
hits: integer;
}
+35 -14
View File
@@ -1,21 +1,42 @@
import { Snowflake } from "discord.js/typings/index.js";
// import monk from "monk";
import { integer } from "tachi-common";
// import { ProcessEnv } from "../setup";
import monk from "monk";
import { ProcessEnv } from "../config";
import { LoggerLayers } from "../data/data";
import { CreateLayeredLogger } from "../utils/logger";
import { DiscordUserMapDocument, QuoteDocument } from "./documents";
// const monkDB = monk(ProcessEnv.MONGO_URL);
const logger = CreateLayeredLogger(LoggerLayers.database);
export interface DiscordUserMapDocument {
userID: integer;
discordID: Snowflake;
tachiApiToken: string;
}
logger.info(`Connecting to ${ProcessEnv.mongoUrl}...`);
const monkDB = monk(ProcessEnv.mongoUrl);
monkDB
.then(() => {
logger.info(`Database connection successful.`);
})
.catch((err) => {
logger.crit(err);
process.exit(1);
});
const db = {
discordUserMap: (() => {
console.error("DATABASE HAS BEEN STUBBED OUT TEMPORARILY. SIGTERMING.");
process.exit(1);
}) as any, // monkDB.get<DiscordUserMapDocument>("discordUserMap")
discordUserMap: monkDB.get<DiscordUserMapDocument>("discord-user-map"),
quotes: monkDB.get<QuoteDocument>("quotes"),
};
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.quotes.dropIndexes();
}
await db.discordUserMap.createIndex({ discordID: 1 }, { unique: true });
await db.quotes.createIndex({ quoteID: 1 }, { unique: true });
logger.info(`Indexes have been set.`);
}
export default db;
+18
View File
@@ -0,0 +1,18 @@
import { LoggerLayers } from "../data/data";
import { CreateLayeredLogger } from "../utils/logger";
import { DiscordUserMapDocument, QuoteDocument } from "./documents";
import db from "./mongo";
const logger = CreateLayeredLogger(LoggerLayers.databaseQuery);
export function GetUserForDiscordID(discordID: string): Promise<DiscordUserMapDocument | null> {
logger.verbose(`Fetching linked user with DiscordID: ${discordID}.`);
return db.discordUserMap.findOne({ discordID: discordID });
}
export function GetQuoteWithID(quoteID: string): Promise<QuoteDocument | null> {
logger.verbose(`Fetching quote with quoteID: ${quoteID}.`);
return db.quotes.findOne({ quoteID });
}
-78
View File
@@ -1,78 +0,0 @@
import { Client, Intents } from "discord.js";
import { handleIsCommand } from "./interactionHandlers/command/handleIsCommand";
import { handleIsSelectMenu } from "./interactionHandlers/selectMenu/handleIsSelectMenu";
import { app } from "./server/server";
import { BotConfig, ProcessEnv } from "./setup";
import { LoggerLayers, theFunny } from "./config";
import { registerSlashCommands } from "./slashCommands/register";
import { getTachiIdByDiscordId } from "./utils/discord-to-tachi";
import { createLayeredLogger } from "./utils/logger";
import { initWatchHandler } from "./utils/utils";
const logger = createLayeredLogger(LoggerLayers.client);
const client = new Client({
intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.DIRECT_MESSAGES, Intents.FLAGS.GUILD_MESSAGES],
});
client.on("messageCreate", async (message) => {
if (message.content === "!!") {
const index = Math.floor(Math.random() * theFunny.length);
await message.channel.send(
theFunny[index].length > 1997
? `${theFunny[index].substring(0, 1997).trimEnd()}...`
: theFunny[index]
);
}
});
client.on("interactionCreate", async (interaction) => {
try {
const TachiObject = await getTachiIdByDiscordId(interaction.user.id);
const oAuthLink = `https://kamaitachi.xyz/oauth/request-auth?clientID=${process.env.BOT_CLIENT_ID}&context=${interaction.user.id}`;
if (interaction.isSelectMenu()) {
if (!TachiObject) {
await (await interaction.user.createDM()).send(`Please log in: ${oAuthLink}`);
return await interaction.reply(
"Please link your discord account, You have been messaged a URL"
);
}
return await handleIsSelectMenu(interaction);
}
if (interaction.isCommand()) {
if (!TachiObject) {
await (await interaction.user.createDM()).send(`Please log in: ${oAuthLink}`);
return await interaction.reply(
"Please link your discord account, You have been messaged a URL"
);
}
return await handleIsCommand(interaction);
}
} catch (e) {
logger.error("Failed to run interaction");
}
});
(async () => {
try {
if (!ProcessEnv.ENV) {
throw new Error("Environment not configured");
}
logger.info(`Running on ${ProcessEnv.ENV} environment`);
await client.login(ProcessEnv.DISCORD_TOKEN);
logger.info(`Logged in successfully to ${client.guilds.cache.size} guilds`);
initWatchHandler(client);
app.listen(BotConfig.SERVER_PORT);
logger.info(
`Invite URL: https://discord.com/api/oauth2/authorize?client_id=${
client.application!.id
}&permissions=8&scope=applications.commands%20bot`
);
await registerSlashCommands(client);
} catch (e) {
logger.error("Log in Failed:", e);
}
})();
@@ -1,26 +0,0 @@
import { Interaction } from "discord.js";
import { LoggerLayers } from "../../config";
import { SlashCommand, slashCommands } from "../../slashCommands/register";
import { createLayeredLogger } from "../../utils/logger";
const logger = createLayeredLogger(LoggerLayers.slashCommands);
export const handleIsCommand = async (interaction: Interaction): Promise<void> => {
try {
/** Rechecking required to enforce types */
if (interaction.isCommand()) {
const command = slashCommands.find(
(command: SlashCommand) => command.info.name === interaction.commandName
);
const requestingUserInfo = GetRequestingUserInfo(interaction);
if (command && command.exec) {
logger.info(`Running ${command.info.name} interaction`);
command.exec(interaction);
}
}
} catch (e) {
logger.error("Failed to handle isCommand interaction");
}
};
@@ -0,0 +1,49 @@
import { CommandInteraction, MessageEmbed } from "discord.js";
import { SLASH_COMMANDS } from "../commands/commands";
import { LoggerLayers } from "../data/data";
import { DiscordUserMapDocument } from "../database/documents";
import { CreateLayeredLogger } from "../utils/logger";
const logger = CreateLayeredLogger(LoggerLayers.slashCommands);
/**
* Handles incoming command requests by resolving the interaction to the command
* it refers to, and calling it.
*
* @param interaction - The interaction the user made. This contains things like what
* command they called and with what arguments.
* @param requestingUser - The user who interacted with this command.
*/
export async function handleIsCommand(
interaction: CommandInteraction,
requestingUser: DiscordUserMapDocument
) {
try {
const command = SLASH_COMMANDS.get(interaction.commandName);
if (!command) {
throw new Error(`A command was requested that does not exist.`);
}
if (command) {
logger.verbose(`Running ${command.info.name} interaction.`);
try {
const response = await command.exec(interaction, requestingUser);
if (response instanceof MessageEmbed) {
await interaction.reply({ embeds: [response] });
} else {
await interaction.reply(response);
}
} catch (err) {
logger.error(`An error occured while executing a command.`, { command, err });
interaction.reply(
`An error has occured while executing this command. This has been reported.`
);
}
}
} catch (e) {
logger.error("Failed to handle isCommand interaction", { error: e });
throw e;
}
}
@@ -0,0 +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";
// 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 = 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");
// }
// };
@@ -1,70 +0,0 @@
import { Interaction, SelectMenuInteraction } from "discord.js";
import { Game, Playtypes } from "tachi-common";
import { buildChartEmbed } from "../../commands/chartSearch/buildChartEmbed";
import { LoggerLayers } from "../../config";
import { buildProfileIntractable } from "../../profile/buildProfileEmbed";
import { createLayeredLogger } from "../../utils/logger";
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 = 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");
}
};
+86
View File
@@ -0,0 +1,86 @@
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 { app } from "./server/server";
import { RegisterSlashCommands } from "./slashCommands/register";
import { CreateLayeredLogger } from "./utils/logger";
import { RFA, TruncateString } from "./utils/misc";
const logger = CreateLayeredLogger(LoggerLayers.client);
const client = new Client({
intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.DIRECT_MESSAGES, Intents.FLAGS.GUILD_MESSAGES],
});
client.on("messageCreate", async (message) => {
// KT1 Easter Egg.
if (message.content === "!!") {
await message.channel.send(
`Invalid Command. (${TruncateString(RFA(METALLIC_MIND_SPLASHES), 500)})`
);
}
});
client.on("interactionCreate", async (interaction) => {
try {
if (!interaction.isSelectMenu() && !interaction.isCommand()) {
return; // We don't deal with any of these interactions atm.
}
const requestingUser = await GetUserForDiscordID(interaction.user.id);
if (!requestingUser) {
return RequireUserAuth(interaction);
}
if (interaction.isSelectMenu()) {
// return handleIsSelectMenu(interaction);
}
if (interaction.isCommand()) {
return handleIsCommand(interaction, requestingUser);
}
} catch (e) {
logger.error("Failed to run 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=${BotConfig.OAUTH.CLIENT_ID}&context=${interaction.user.id}`;
const dmChannel = await interaction.user.createDM();
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 ${ServerConfig.name}.
We've sent you a DM with instructions on how to link your account.`
);
}
(async () => {
try {
// Login to discord.
await client.login(BotConfig.DISCORD.TOKEN);
logger.info(`Logged in successfully to ${client.guilds.cache.size} guilds.`);
// Mount our express server.
app.listen(BotConfig.HTTP_SERVER.PORT);
logger.info(
`Invite URL: https://discord.com/api/oauth2/authorize?client_id=${
client.application!.id
}&permissions=8&scope=applications.commands%20bot`
);
await RegisterSlashCommands(client);
} catch (err) {
logger.crit("Failed to properly boot.", err);
process.exit(1);
}
})();
-150
View File
@@ -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 "../config";
import { validSelectCustomIdPrefaces } from "../interactionHandlers/selectMenu/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");
}
};
-36
View File
@@ -1,36 +0,0 @@
import { CommandInteraction } from "discord.js";
import { IDStrings } from "tachi-common";
import { LoggerLayers } from "../config";
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);
}
};
+8
View File
@@ -0,0 +1,8 @@
import { SetIndexes } from "../database/mongo";
if (require.main === module) {
(async () => {
await SetIndexes(true);
process.exit(0);
})();
}
+5 -5
View File
@@ -1,9 +1,9 @@
import { LoggerLayers } from "../config";
import { RequestHandler } from "express";
import { ProcessEnv } from "../setup";
import { createLayeredLogger } from "../utils/logger";
import { LoggerLayers } from "../data/data";
import { BotConfig } from "../config";
import { CreateLayeredLogger } from "../utils/logger";
const logger = createLayeredLogger(LoggerLayers.serverAuth);
const logger = CreateLayeredLogger(LoggerLayers.serverAuth);
/**
* Middleware that checks that a webhook request has Authorization set to
@@ -33,7 +33,7 @@ export const ValidateWebhookRequest: RequestHandler = (req, res, next) => {
});
}
if (value !== ProcessEnv.BOT_CLIENT_SECRET) {
if (value !== BotConfig.OAUTH.CLIENT_SECRET) {
logger.warn(
`Recieved invalid auth value from ${req.ip}. Has the client secret been changed?`
);
+27 -23
View File
@@ -1,15 +1,16 @@
import express, { Express } from "express";
import { LoggerLayers } from "../config";
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 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";
export const app: Express = express();
const logger = createLayeredLogger(LoggerLayers.server);
const logger = CreateLayeredLogger(LoggerLayers.server);
app.use(express.json());
@@ -31,7 +32,6 @@ app.get("/", (req, res) =>
description: "Bot is online!",
body: {
time: Date.now(),
// @todo Versioning information, put it here?
},
})
);
@@ -60,6 +60,11 @@ app.post("/webhook", ValidateWebhookRequest, (req, res) => {
// However, tachi-server/common may recieve an update
// to define new webhooks, and the bot might not
// get around to updating in time.
logger.warn(
`Received unknown webhook event ${
(webhookEvent as WebhookEvents).type
}. Have we got support for this?`
);
return res.status(501).json({
success: false,
description: `The type ${(webhookEvent as WebhookEvents).type} is unsupported.`,
@@ -90,19 +95,19 @@ app.get("/oauth/callback", async (req, res) => {
const tokenRes = await TachiServerV1Request<APITokenDocument>(
RequestTypes.POST,
"/oauth/token",
"GIVE_BOT_A_VALID_TOKEN_TO_HIT_AUTH",
null,
{
code: req.query.code,
client_id: ProcessEnv.BOT_CLIENT_ID,
client_secret: ProcessEnv.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,
@@ -113,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;
@@ -135,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"));
});
/**
@@ -187,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}.`);
-85
View File
@@ -1,85 +0,0 @@
import { LoggerLayers } from "./config";
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_PORT: number;
TACHI_SERVER_LOCATION: string;
OUR_URL: string;
GENERIC_ERROR_MESSAGE: 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",
});
if (err) {
logger.error(FormatPrError(err, "Invalid conf.json5 file. Cannot safely boot."));
throw err;
}
return data;
}
export interface ProcessEnvironment {
ENV: string;
DISCORD_TOKEN: string;
BOT_CLIENT_SECRET: string;
BOT_CLIENT_ID: string;
MONGO_URL: string;
}
function ParseEnvVars() {
const err = Prudence(
process.env,
{
ENV: "string",
DISCORD_TOKEN: "string",
BOT_CLIENT_SECRET: "string",
BOT_CLIENT_ID: "string",
MONGO_URL: "string",
},
{},
{ allowExcessKeys: true }
);
if (err) {
logger.error(FormatPrError(err, "Invalid environment. Cannot safely boot."));
throw err;
}
return process.env as unknown as ProcessEnvironment;
}
export const BotConfig: BotConfig = ParseBotConfig(process.env.CONF_JSON5_LOCATION);
export const ProcessEnv = ParseEnvVars();
+57 -107
View File
@@ -1,121 +1,71 @@
import { SlashCommandBuilder } from "@discordjs/builders";
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 "../commands/chartSearch/chartSearch";
import { getProfileByName } from "../profile/fetch";
import { ProcessEnv } from "../setup";
import { LoggerLayers } from "../config";
import { help } from "../commands/help/help";
import { createLayeredLogger } from "../utils/logger";
import { gamesToChoicesObject } from "../utils/utils";
import { Client } from "discord.js";
import { SLASH_COMMANDS } from "../commands/commands";
import { BotConfig } from "../config";
import { LoggerLayers } from "../data/data";
import { CreateLayeredLogger } from "../utils/logger";
const logger = createLayeredLogger(LoggerLayers.slashCommands);
export interface SlashCommand {
info: {
name: string;
description: string;
options: APIApplicationCommandOption[];
};
exec(interaction: CommandInteraction): Promise<void>;
}
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")
/** @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 logger = CreateLayeredLogger(LoggerLayers.slashCommands);
const rest = new REST({
version: "9",
}).setToken(ProcessEnv.DISCORD_TOKEN);
}).setToken(BotConfig.DISCORD.TOKEN);
export const registerSlashCommands = async (client: Client): Promise<void> => {
/**
* Register our slash commands. If in prod, these
* @param client
*/
export async function RegisterSlashCommands(client: Client): Promise<void> {
try {
if (process.env.ENV === "PROD") {
logger.info("Registering global slash commands");
const commandsArray = [...SLASH_COMMANDS.values()];
await rest.put(Routes.applicationCommands(client.application!.id), {
body: slashCommands.map((command) => command.info),
});
} else {
logger.info("Registering guild slash commands");
await UnregisterAllCommands(client);
await tidyOldGuildCommands(client);
if (process.env.DEV_SERVER_ID) {
await rest.put(
Routes.applicationGuildCommands(
client.application!.id,
process.env.DEV_SERVER_ID
),
{
body: slashCommands.map((command) => command.info),
}
);
logger.info("Registering guild slash commands.");
await rest.put(
Routes.applicationGuildCommands(client.application!.id, BotConfig.DISCORD.SERVER_ID),
{
body: commandsArray.map((command) => command.info),
}
);
logger.info("Successfully registered guild slash commands.");
} catch (err) {
logger.error("Failed to register guild slash commands.", err);
throw err;
}
}
/**
* Unregister all the commmands we have.
*/
export async function UnregisterAllCommands(client: Client): Promise<void> {
try {
logger.info("Tidying old guild slash commands.");
const guilds = client.guilds.cache;
// discord.js doesn't use arrays because those aren't cool anymore
// so we have to discard the left side of this.
// They use collections, which inherit from ES6's Map. Ah well.
const promises = [];
for (const [, guild] of guilds) {
const commands = guild.commands.cache;
for (const [, command] of commands) {
promises.push(command.delete());
}
}
logger.info("Successfully registered slash commands");
} catch (e) {
logger.error("Failed to register slash commands", e);
// parallelise waiting for these to be deleted.
await Promise.all(promises);
logger.info("Successfully tidied old guild slash commands.");
} catch (err) {
logger.error("Failed to tidy old guild slash commands.", err);
throw err;
}
};
export const tidyOldGuildCommands = async (client: Client): Promise<void> => {
try {
logger.info("Tidying old guild slash commands");
const guilds = client.guilds.cache;
guilds.forEach((guild) => {
const commands = guild.commands.cache;
commands.forEach((command) => {
command.delete();
});
});
logger.info("Successfully tidied old guild slash commands");
} catch (e) {
logger.error("Failed to tidy old guild slash commands");
}
};
}
+12
View File
@@ -0,0 +1,12 @@
import { integer, PublicUserDocument } from "tachi-common";
import { TachiServerV1Get } from "./fetch-tachi";
export async function GetUserInfo(userID: integer) {
const res = await TachiServerV1Get<PublicUserDocument>(`/users/${userID}`, null);
if (!res.success) {
throw new Error(`Failed to fetch user with userID ${userID}.`);
}
return res.body;
}
-16
View File
@@ -1,16 +0,0 @@
import { LoggerLayers } from "../config";
import { createLayeredLogger } from "./logger";
import db, { DiscordUserMapDocument } from "../mongo/mongo";
import { Interaction } from "discord.js";
const logger = createLayeredLogger(LoggerLayers.tachiAuth);
export async function GetRequestingUserInfo(
interaction: Interaction
): Promise<DiscordUserMapDocument | null> {
const discordID = interaction.id;
logger.verbose(`Fetching linked ID for ${discordID}`);
return db.discordUserMap.findOne({ discordID: discordID });
}
+6
View File
@@ -0,0 +1,6 @@
import { MessageEmbed } from "discord.js";
import { BotConfig, ServerConfig } from "../config";
export function CreateEmbed() {
return new MessageEmbed().setColor(ServerConfig.type === "ktchi" ? "#e61c6e" : "#527acc");
}
+19 -24
View File
@@ -1,11 +1,10 @@
import axios from "axios";
import { LoggerLayers } from "../config";
import { BotConfig } from "../setup";
import fetch from "node-fetch";
import { LoggerLayers } from "../data/data";
import { BotConfig } from "../config";
import { integer, SuccessfulAPIResponse, UnsuccessfulAPIResponse } from "tachi-common";
import { getTachiIdByDiscordId } from "./discord-to-tachi";
import { createLayeredLogger } from "./logger";
import { CreateLayeredLogger } from "./logger";
const logger = createLayeredLogger(LoggerLayers.tachiFetch);
const logger = CreateLayeredLogger(LoggerLayers.tachiFetch);
export type APIResponse<T> = (
| SuccessfulAPIResponse<T>
@@ -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}`;
}
+15 -18
View File
@@ -1,21 +1,18 @@
import { createLogger, transports, format, Logger } from "winston";
import { LoggerLayers } from "../config";
const { combine, timestamp, label, printf, align, colorize } = format;
import { CreateLogger, MeiLogger } from "mei-logger";
import { LoggerLayers } from "../data/data";
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;
}
+29
View File
@@ -0,0 +1,29 @@
import { BotConfig } from "../config";
import { DateTime } from "luxon";
/**
* Random From Array - Selects a random value from an array.
*/
export function RFA<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
export function TruncateString(string: string, len = 30) {
if (string.length < len) {
return string;
}
return `${string.substring(0, len - 3)}...`;
}
/**
* Checks whether a discord user has admin permissions or not. This lets them do
* slightly more destructive things.
*/
export function IsAdmin(discordID: string) {
return BotConfig.DISCORD.ADMIN_USERS.includes(discordID);
}
export function FormatDate(ms: number) {
return DateTime.fromMillis(ms).toLocaleString(DateTime.DATE_HUGE);
}
+16
View File
@@ -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[];
}
-109
View File
@@ -1,109 +0,0 @@
import {
FormatGame,
Game,
Playtypes,
IDStrings,
UGSRatingsLookup,
GetGamePTConfig,
GetGameConfig,
} from "tachi-common";
export interface SimpleGameType<T extends Game> {
game: T;
playtype: Playtypes[T];
}
export const simpleGameTypeToString = <T extends Game>(gpt: SimpleGameType<T>): IDStrings =>
`${gpt.game}:${gpt.playtype}` as IDStrings;
export const stringToSimpleGameType = (gptString: string): SimpleGameType<Game> => ({
game: <Game>gptString.split(":")[0],
playtype: <Playtypes[never]>gptString.split(":")[1],
});
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)) || "";
export const prettyRatingString = <I extends IDStrings = IDStrings>(
rating: UGSRatingsLookup[I]
): string => capitalise(rating);
export const getPfpUrl = (userId: number): string =>
`https://kamaitachi.xyz/api/v1/users/${userId}-pfp`;
export const getGameImage = (gameId: string, game: Game): string =>
`https://cdn.kamaitachi.xyz/game-icons/${game}/${gameId}`;
export const gameIdentifierStrings = [
"iidx:SP",
"iidx:DP",
"sdvx:Single",
"usc:Single",
"ddr:SP",
"ddr:DP",
"maimai:Single",
"museca:Single",
"bms:7K",
"bms:14K",
"chunithm:Single",
"gitadora:Gita",
"gitadora:Dora",
];
export const simpleGameIdentifierStrings = [
"iidx",
"museca",
"maimai",
"sdvx",
"ddr",
"bms",
"chunithm",
"gitadora",
"usc",
];
export const gamesToChoicesObject = (): [name: string, value: string][] =>
gameIdentifierStrings.map((identifier) => [
formatGameWrapper(stringToSimpleGameType(identifier)),
identifier,
]);
/** @description excludes play-types from options */
export const gamesToGenericChoicesObject = (): [name: string, value: string][] =>
simpleGameIdentifierStrings.map((identifier) => [
formatGameWrapperSimple(<Game>identifier),
identifier,
]);
export const formatGameScoreRating = <I extends IDStrings = IDStrings>(
gpt: SimpleGameType<Game>,
ratingName: UGSRatingsLookup[I],
value: number
): string => {
const gptConfig = GetGamePTConfig(gpt.game, gpt.playtype);
const val = gptConfig.profileRatingAlgFormatters[ratingName];
if (val) {
return val(value);
}
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
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"module": "commonjs",
"module": "CommonJS",
"noImplicitAny": true,
"strict": true,
"target": "ES2020",
-4753
View File
File diff suppressed because it is too large Load Diff