diff --git a/.gitignore b/.gitignore index 14e9bb4..f1b57d3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ dist tsconfig.tsbuildinfo # secret tokens -*.env +*.env* # database db/** diff --git a/README.md b/README.md index 449307b..898376a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,20 @@ slabbot is also built on [node.js](https://nodejs.org) v18.x (v18.4.0 as of writ Install dependencies with `pnpm i` and then build with `pnpm build` (or `pnpm tsc`). -Create a .env file with `pnpm run setup` (see [setup.js](/setup.js)). +Create a .env file with `pnpm run setup` (see [setup.js](/setup.js)). You can also fill this out if you'd like: +```sh +# Discord (https://discord.com/developers/applications) +DISCORD_TOKEN= # required for bot to functi:won +CLIENT_ID= # required to deploy commands +GUILD_ID= # deploying commands to testing guild + +# MongoDB - used for stats (/slabbot and exp) +MONGO_URL= + +# osu! (https://osu.ppy.sh/home/account/edit#oauth) - +OSU_ID= +OSU_SECRET= +``` Run with `pnpm dev` or `pnpm start`. diff --git a/src/Utilities.exp.ts b/src/Utilities.exp.ts index 50ad369..81c048c 100644 --- a/src/Utilities.exp.ts +++ b/src/Utilities.exp.ts @@ -3,6 +3,8 @@ import {ChatInputCommandInteraction, EmbedBuilder, Message, User} from "discord.js"; import {UsersModel} from "./models.js"; import {newUser} from "./Utilities.Db.js"; +import logger from "./logger.js"; +import mongoose from "mongoose"; /** * Gets the required Total EXP required to reach a specified level. @@ -12,6 +14,11 @@ import {newUser} from "./Utilities.Db.js"; export const expNeededForLevel = (level: number): number => level * (2500 + ((level - 1) * 100)); export async function grantExp(user: User, event: Message | ChatInputCommandInteraction) { + if (mongoose.connection.readyState !== 1) { + logger.trace("no database, not granting exp"); + return; + } + if (!user.bot) { const userInDb = await UsersModel.findById(user.id); diff --git a/src/commands/osu.ts b/src/commands/osu.ts index 9306a65..24e5472 100644 --- a/src/commands/osu.ts +++ b/src/commands/osu.ts @@ -29,7 +29,11 @@ const rankEmojis = { SSH: "<:osu_SSH:994113327481499728>", } as const; -auth(); +if (OSU_ID && OSU_SECRET) { + auth(); +} else { + logger.warn("missing osu!api credentials, skipping authentication; osu command will not work."); +} export default class implements Command { data = new SlashCommandBuilder() @@ -89,6 +93,13 @@ export default class implements Command { const subcommand = interaction.options.getSubcommand(); let embed; + if (!token) { + return interaction.reply({ + content: "this command isn't available.", + embeds: [generateCommandProblemEmbed("missing credentials", "there's no token available to log into the osu!api. contact the person who runs this bot.", "error")], + }); + } + if (subcommand === "user") { const user = interaction.options.getString("user", true); const mode = interaction.options.getString("mode", false) as GameMode || undefined; @@ -101,7 +112,7 @@ export default class implements Command { return interaction.reply("vs"); } - if(embed) { + if (embed) { return interaction.reply({ content: "here's what i found:", embeds: [embed], @@ -242,7 +253,7 @@ interface User { async function getUser(username: string, mode?: GameMode): Promise { const userString = username + (mode ? `/${mode}` : ""); if (osuUserCache.has(userString)) { - logger.debug(`${userString} found in cache!`); + logger.trace(`${userString} found in cache!`); return osuUserCache.get(userString) as User; } @@ -262,7 +273,7 @@ async function getUser(username: string, mode?: GameMode): Promise { return response; } -async function makeUserEmbed(user: string, mode: GameMode | undefined){ +async function makeUserEmbed(user: string, mode: GameMode | undefined) { // Fetch from osu!api const data = await getUser(user, mode) as User; @@ -270,7 +281,7 @@ async function makeUserEmbed(user: string, mode: GameMode | undefined){ return generateCommandProblemEmbed( "user not found!", `The osu!api returned an error when looking for user \`${user}\`. The user may have changed their username, their account may be unavailable due to security issues or a restriction, or you may have made a typo!`, - "error" + "error", ); } diff --git a/src/commands/slabbot.ts b/src/commands/slabbot.ts index d6c95fe..84acbbd 100644 --- a/src/commands/slabbot.ts +++ b/src/commands/slabbot.ts @@ -7,6 +7,7 @@ import {formatNum, generateCommandProblemEmbed, generateProgressBar, msToDuratio import {newUser} from "../Utilities.Db.js"; import {CommandUsageModel, SlabbotCommand, SlabbotUser, UsersModel} from "../models.js"; import {expNeededForLevel, generateLargeNumber} from "../Utilities.exp.js"; +import mongoose from "mongoose"; export default class implements Command { data = new SlashCommandBuilder() @@ -36,6 +37,8 @@ export default class implements Command { return logger.error("client.user or client.uptime is null… which really shouldn't happen."); } + const slabbotStats = await getSlabbotStats(); + const embed = new EmbedBuilder() .setColor(colors.orange) .setTitle("[= ^ x ^ =] hello!") @@ -52,44 +55,44 @@ export default class implements Command { inline: true, }); - const commandUsage = await CommandUsageModel.find() as SlabbotCommand[]; - if (commandUsage) { - const totalCommandsUsed = commandUsage.reduce((total, i) => total + i.value, 0); - const mostUsedCommands = commandUsage.sort((a, b) => b.value - a.value); - let mostUsedCommandsString = ""; - for (let i = 0; i < 5; i++) { - if (mostUsedCommands[i]) { - mostUsedCommandsString += `${i + 1}. /${mostUsedCommands[i]._id} (used ${formatNum(mostUsedCommands[i].value)} times)`; - mostUsedCommandsString += "\n"; - } else { - break; - } - } - - embed.addFields( - { - name: "Commands Run", - value: formatNum(totalCommandsUsed) || "None yet…", - inline: true, - }, { - name: "Most Used Commands", - value: mostUsedCommandsString || "Nothing yet…", - inline: false, - }, - ); + if (slabbotStats) { + embed.addFields({ + name: "Commands Run", + value: slabbotStats.commandsRun, + inline: true, + }, { + name: "Most Used Commands", + value: slabbotStats.mostUsedCommands, + inline: false, + }); } return interaction.reply({ - content: "hi am slabbot [= ^ x ^ =]", + content: "oh hi! [= ^ x ^ =]", embeds: [embed], }); } /* /slabbot profile */ if (subcommand === "profile") { + if (mongoose.connection.readyState !== 1) { + logger.trace("no database, no stats"); + return interaction.reply({ + content: "database missing…", + embeds: [ + generateCommandProblemEmbed( + "database not connected", + "there's no database connected, and therefore no stats to get. contact the person running the bot.", + "error", + ), + ], + }); + } + let member: GuildMember | null = null; let user: User; + // In server or in DMs if (interaction.inGuild()) { member = interaction.options.getMember("user") as GuildMember || interaction.member; user = member.user; @@ -186,3 +189,33 @@ const botProfileError = generateCommandProblemEmbed( "See title.", "error", ); + +async function getSlabbotStats(): Promise<{commandsRun: string, mostUsedCommands: string} | null> { + if (mongoose.connection.readyState !== 1) { + logger.trace("no database, no stats"); + return null; + } + + const commandUsage = await CommandUsageModel.find() as SlabbotCommand[]; + if (!commandUsage) { + logger.error("no commandUsage???"); + return null; + } + + const totalCommandsUsed = commandUsage.reduce((total, i) => total + i.value, 0); + const mostUsedCommands = commandUsage.sort((a, b) => b.value - a.value); + let mostUsedCommandsString = ""; + for (let i = 0; i < 5; i++) { + if (mostUsedCommands[i]) { + mostUsedCommandsString += `${i + 1}. /${mostUsedCommands[i]._id} (used ${formatNum(mostUsedCommands[i].value)} times)`; + mostUsedCommandsString += "\n"; + } else { + break; + } + } + + return { + commandsRun: formatNum(totalCommandsUsed) || "None yet…", + mostUsedCommands: mostUsedCommandsString || "Nothing yet…", + }; +} diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index ee9cd47..ba652c7 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -5,6 +5,7 @@ import {CommandUsageModel, UsersModel} from "../models.js"; import {newUser} from "../Utilities.Db.js"; import {grantExp} from "../Utilities.exp.js"; import {generateCommandProblemEmbed} from "../Utilities.js"; +import mongoose from "mongoose"; // Object of when user last used any command. const lastUseTimes: {[key: Snowflake]: number | undefined} = {}; @@ -16,6 +17,7 @@ const lastCommandUseTimes: {[key: string]: {[key: Snowflake]: number | undefined export default class implements DJSEvent { name = "interactionCreate"; once = false; + execute = async function (interaction: ChatInputCommandInteraction) { if (!interaction.isCommand()) { return; @@ -68,45 +70,49 @@ export default class implements DJSEvent { // Run the command… command.execute(interaction, client); - // Database name… - const subcommand = interaction.options.getSubcommand(false) ?? ""; - const commandNameInDb = (command.data.name + " " + subcommand).trim(); + if (mongoose.connection.readyState === 1) { + // Database name… + const subcommand = interaction.options.getSubcommand(false) ?? ""; + const commandNameInDb = (command.data.name + " " + subcommand).trim(); - // Global command usage. - const commandUsage = await CommandUsageModel.findById(commandNameInDb); - if (commandUsage) { - const {value} = commandUsage; - commandUsage.set("value", value + 1); - commandUsage.save(); - } else { - const usage = new CommandUsageModel({ - _id: commandNameInDb, - value: 1, - }); - await usage.save(); - logger.debug(`Added command ${commandNameInDb} to usage database.`); - } + // Global command usage. + const commandUsage = await CommandUsageModel.findById(commandNameInDb); + if (commandUsage) { + const {value} = commandUsage; + commandUsage.set("value", value + 1); + commandUsage.save(); + } else { + const usage = new CommandUsageModel({ + _id: commandNameInDb, + value: 1, + }); + await usage.save(); + logger.debug(`Added command ${commandNameInDb} to usage database.`); + } - // User command usage. - const slabbotUser = await UsersModel.findById(interaction.user.id); - if (slabbotUser && slabbotUser.commandUsage) { - const {commandUsage} = slabbotUser; - const commandUsageTimes = commandUsage.get(commandNameInDb); + // User command usage. + const slabbotUser = await UsersModel.findById(interaction.user.id); + if (slabbotUser && slabbotUser.commandUsage) { + const {commandUsage} = slabbotUser; + const commandUsageTimes = commandUsage.get(commandNameInDb); - if (commandUsageTimes) { - commandUsage.set(commandNameInDb, commandUsageTimes + 1); + if (commandUsageTimes) { + commandUsage.set(commandNameInDb, commandUsageTimes + 1); + } else { + commandUsage.set(commandNameInDb, 1); + } + + slabbotUser.save(); } else { - commandUsage.set(commandNameInDb, 1); + newUser(interaction.user.id); } - slabbotUser.save(); + grantExp(interaction.user, interaction); + lastUseTimes[interaction.user.id] = interaction.createdTimestamp; } else { - newUser(interaction.user.id); + logger.trace("no database, skipping stats"); } - grantExp(interaction.user, interaction); - lastUseTimes[interaction.user.id] = interaction.createdTimestamp; - if (command.cooldown) { lastCommandUseTimes[command.data.name][interaction.user.id] = interaction.createdTimestamp; } diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 23bcc9c..85cc859 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -1,10 +1,10 @@ import {Message} from "discord.js"; import {DJSEvent} from "../Interfaces"; import {grantExp} from "../Utilities.exp.js"; - export default class implements DJSEvent { name = "messageCreate"; once = false; + execute = async function (message: Message) { grantExp(message.author, message); }; diff --git a/src/index.ts b/src/index.ts index da7ce52..44de752 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,33 +1,30 @@ -// Imports for loading commands import fs from "node:fs"; import {fileURLToPath} from "node:url"; import "reflect-metadata"; // Required by tsyringe import {container} from "tsyringe"; -// Discord.js import {Client, ClientOptions, Collection, GatewayIntentBits} from "discord.js"; import {Command, DJSEvent} from "./Interfaces"; -// Database import mongoose from "mongoose"; const db = mongoose.connection; -// Logger import logger from "./logger.js"; -// Environment variables import "dotenv/config"; -/* Database connecting */ -logger.info("connecting to database…"); -logger.info(process.env.MONGO_URL || "mongodb://127.0.0.1:27017"); -await mongoose.connect(process.env.MONGO_URL || "mongodb://127.0.0.1:27017", {}) - .then(() => { - logger.info("connected to database!"); - }) - .catch(error => { +if (process.env.MONGO_URL) { + logger.info(`connecting to database… (${process.env.MONGO_URL})`); + await mongoose.connect(process.env.MONGO_URL, {}) + .then(() => { + logger.info("connected to database!"); + }) + .catch(error => { + logger.error(error); + }); + + db.on("error", error => { logger.error(error); }); - -db.on("error", error => { - logger.error(error); -}); +} else { + logger.warn("missing MONGO_URL, skipping database connection; exp, stats, and profile will not work"); +} class ExtendedClient extends Client { constructor(options: ClientOptions) { @@ -51,7 +48,7 @@ const commandFiles = fs.readdirSync(fileURLToPath(new URL("./commands", import.m let commandCount = 0; for await (const file of commandFiles) { - logger.debug("Loading command file " + file); + logger.debug("Loading command file: " + file); const command = container.resolve((await import(new URL("./commands/" + file, import.meta.url).href)).default); client.commands.set(command.data.name, command); @@ -65,7 +62,7 @@ const eventFiles = fs.readdirSync(fileURLToPath(new URL("./events", import.meta. let eventCount = 0; for await (const file of eventFiles) { - logger.debug("Loading event watcher file " + file); + logger.debug("Loading event watcher file: " + file); const event = container.resolve((await import(new URL("./events/" + file, import.meta.url).href)).default); if (event.once) { diff --git a/src/setup.js b/src/setup.js index fb97130..168fda1 100644 --- a/src/setup.js +++ b/src/setup.js @@ -26,9 +26,9 @@ const DISCORD_TOKEN = await password({ }); const CLIENT_ID = await input({ - message: "Discord Application ID (required):", + message: "Discord Application ID (required to deploy commands):", validate(id) { - return (id.length >= 17 && id.length <= 20) || "That doesn't seem like a valid client ID."; + return (!id || (id.length >= 17 && id.length <= 20)) || "That doesn't seem like a valid client ID."; }, }); @@ -56,11 +56,11 @@ const OSU_SECRET = await password({ const output = ` # Discord (https://discord.com/developers/applications) -DISCORD_TOKEN=${DISCORD_TOKEN} -CLIENT_ID=${CLIENT_ID} -GUILD_ID=${GUILD_ID} +DISCORD_TOKEN=${DISCORD_TOKEN} # required for bot to function +CLIENT_ID=${CLIENT_ID} # required to deploy commands +GUILD_ID=${GUILD_ID} # used to deploy commands to specific guilds -# MongoDB +# MongoDB - used for stats (/slabbot and exp) MONGO_URL=${MONGO_URL} # osu! (https://osu.ppy.sh/home/account/edit#oauth)