diff options
-rw-r--r-- | config/Config.ts | 214 | ||||
-rw-r--r-- | config/example-options.ts | 2 | ||||
-rw-r--r-- | src/bot.ts | 2 | ||||
-rw-r--r-- | src/commands/info/help.ts | 13 | ||||
-rw-r--r-- | src/commands/info/links.ts | 11 | ||||
-rw-r--r-- | src/commands/utilities/wolframAlpha.ts | 13 | ||||
-rw-r--r-- | src/lib/common/Sentry.ts | 6 | ||||
-rw-r--r-- | src/lib/utils/BushClientUtils.ts | 46 | ||||
-rw-r--r-- | src/lib/utils/BushLogger.ts | 3 | ||||
-rw-r--r-- | src/lib/utils/BushUtils.ts | 6 | ||||
-rw-r--r-- | src/listeners/message/directMessage.ts | 12 |
11 files changed, 266 insertions, 62 deletions
diff --git a/config/Config.ts b/config/Config.ts index 7504658..4aae10a 100644 --- a/config/Config.ts +++ b/config/Config.ts @@ -1,15 +1,52 @@ import type { Snowflake } from 'discord.js'; -export class Config { +/** + * Different options for the bot. + */ +export class Config implements ConfigOptions { + /** + * Credentials for various services that the bot depends on. + */ public credentials: Credentials; + + /** + * The environment that the bot is operating under. + */ public environment: Environment; + + /** + * Bot developers. + */ public owners: Snowflake[]; + + /** + * A string that needs to come before text commands. + */ public prefix: string; + + /** + * Various discord channels that logs will be sent in. + */ public channels: Channels; + + /** + * Options for the Postgres database connection. + */ public db: DataBase; + + /** + * Options for what events to log. + */ public logging: Logging; + + /** + * Information regarding the bot's support server. + */ public supportGuild: SupportGuild; + /** + * @param options The options + */ public constructor(options: ConfigOptions) { this.credentials = options.credentials; this.environment = options.environment; @@ -32,6 +69,8 @@ export class Config { return this.credentials.betaToken; case 'development': return this.credentials.devToken; + default: + throw new TypeError(`Unexpected environment: "${this.environment}"`); } } @@ -57,52 +96,179 @@ export class Config { } } +/** + * The options to be provided to the {@link Config} class. + */ export interface ConfigOptions { + /** + * Credentials for various services that the bot depends on. + */ credentials: Credentials; + + /** + * The environment that the bot is operating under. + */ environment: Environment; + + /** + * Bot developers. + */ owners: Snowflake[]; + + /** + * A string that needs to come before text commands. + */ prefix: string; + + /** + * Various discord channels that logs will be sent in. + */ channels: Channels; + + /** + * Options for the Postgres database connection. + */ db: DataBase; + + /** + * Options for what events to log. + */ logging: Logging; + + /** + * Information regarding the bot's support server. + */ supportGuild: SupportGuild; } -interface Credentials { +/** + * Credentials for various services that the bot depends on. + */ +export interface Credentials { + /** + * The discord bot token - used when in a 'production' environment. + */ token: string; + + /** + * The discord bot token - used when in a 'beta' environment. + */ betaToken: string; + + /** + * The discord bot token - used when in a 'development' environment. + */ devToken: string; - hypixelApiKey: string; - wolframAlphaAppId: string; - imgurClientId: string; - imgurClientSecret: string; - sentryDsn: string; - perspectiveApiKey: string; -} -type Environment = 'production' | 'beta' | 'development'; + /** + * Api Key for the Hypixel Minecraft Java server. + * @see {@link https://api.hypixel.net/#section/Authentication/ApiKey} + */ + hypixelApiKey: string | null; + + /** + * The app id for an API Application for WorlframAlpha + * @see {@link https://products.wolframalpha.com/api/} + */ + wolframAlphaAppId: string | null; -interface Channels { - log: Snowflake; - error: Snowflake; - dm: Snowflake; - servers: Snowflake; + /** + * The client id for Imgur's API + * @see {@link https://apidocs.imgur.com/#authorization-and-oauth} + */ + imgurClientId: string | null; + + /** + * The client secret for Imgur's API + * @see {@link https://apidocs.imgur.com/#authorization-and-oauth} + */ + imgurClientSecret: string | null; + + /** + * The sentry DSN (Data Source Name) for error reporting + * @see {@link https://docs.sentry.io/product/sentry-basics/dsn-explainer/} + */ + sentryDsn: string | null; + + /** + * The Perspective API Key + * @see {@link https://perspectiveapi.com/} + */ + perspectiveApiKey: string | null; } -interface DataBase { +/** + * The possible environments that the bot can be running in. + */ +export type Environment = 'production' | 'beta' | 'development'; + +/** + * Various discord channels that logs will be sent in. + */ +export type Channels = { + /** + * The id of a channel to send logging messages in, + * use an empty string for no channel to be used. + */ + [Key in ConfigChannelKey]: Snowflake | ''; +}; + +/** + * The type of information to be sent in the configured channel. + */ +export type ConfigChannelKey = 'log' | 'error' | 'dm' | 'servers'; + +/** + * Options for the Postgres database connection. + */ +export interface DataBase { + /** + * The host of the database. + */ host: string; + + /** + * The port of the database. + */ port: number; + + /** + * The username which is used to authenticate against the database. + */ username: string; + + /** + * The password which is used to authenticate against the database. + */ password: string; } -interface Logging { - db: boolean; - verbose: boolean; - info: boolean; -} +/** + * Options for what events to log. + */ +export type Logging = { + /** + * Whether or not to log database queries, verbose logs, or informational logs + */ + [Key in LoggingType]: boolean; +}; -interface SupportGuild { - id: Snowflake; - invite: string; +/** + * The logging level that can be changed. + */ +export type LoggingType = 'db' | 'verbose' | 'info'; + +/** + * Information regarding the bot's support server. + */ +export interface SupportGuild { + /** + * The id of the support server. + */ + id: Snowflake | null; + + /** + * An invite link to the support server. + */ + invite: string | null; } diff --git a/config/example-options.ts b/config/example-options.ts index 1289c8d..43b805e 100644 --- a/config/example-options.ts +++ b/config/example-options.ts @@ -9,7 +9,7 @@ export default new Config({ wolframAlphaAppId: '[APP_ID]', imgurClientId: '[CLIENT_ID]', imgurClientSecret: '[CLIENT_SECRET]', - sentryDsn: 'SENTRY_DSN', + sentryDsn: '[SENTRY_DSN]', perspectiveApiKey: '[PERSPECTIVE_API_KEY]' }, environment: 'development', @@ -10,7 +10,7 @@ const { Sentry } = await import('./lib/common/Sentry.js'); const { BushClient } = await import('./lib/index.js'); const isDry = process.argv.includes('dry'); -if (!isDry) new Sentry(dirname(fileURLToPath(import.meta.url)) || process.cwd()); +if (!isDry && config.credentials.sentryDsn !== null) new Sentry(dirname(fileURLToPath(import.meta.url)) || process.cwd(), config); BushClient.extendStructures(); const client = new BushClient(config); if (!isDry) await client.dbPreInit(); diff --git a/src/commands/info/help.ts b/src/commands/info/help.ts index ec079e7..d8d91d5 100644 --- a/src/commands/info/help.ts +++ b/src/commands/info/help.ts @@ -211,14 +211,17 @@ export default class HelpCommand extends BushCommand { private addLinks(message: CommandMessage | SlashMessage) { const row = new ActionRowBuilder<ButtonBuilder>(); + const config = this.client.config; - if (!this.client.config.isDevelopment && !this.client.guilds.cache.some((guild) => guild.ownerId === message.author.id)) { + if (!config.isDevelopment && !this.client.guilds.cache.some((guild) => guild.ownerId === message.author.id)) { row.addComponents(new ButtonBuilder({ style: ButtonStyle.Link, label: 'Invite Me', url: invite(this.client) })); } - if (!this.client.guilds.cache.get(this.client.config.supportGuild.id)?.members.cache.has(message.author.id)) { - row.addComponents( - new ButtonBuilder({ style: ButtonStyle.Link, label: 'Support Server', url: this.client.config.supportGuild.invite }) - ); + if ( + config.supportGuild.id && + config.supportGuild.invite && + !this.client.guilds.cache.get(config.supportGuild.id)?.members.cache.has(message.author.id) + ) { + row.addComponents(new ButtonBuilder({ style: ButtonStyle.Link, label: 'Support Server', url: config.supportGuild.invite })); } if (packageDotJSON?.repository) row.addComponents(new ButtonBuilder({ style: ButtonStyle.Link, label: 'GitHub', url: packageDotJSON.repository })); diff --git a/src/commands/info/links.ts b/src/commands/info/links.ts index 3671c6c..3c7add2 100644 --- a/src/commands/info/links.ts +++ b/src/commands/info/links.ts @@ -24,10 +24,13 @@ export default class LinksCommand extends BushCommand { if (!this.client.config.isDevelopment || message.author.isOwner()) { buttonRow.addComponents(new ButtonBuilder({ style: ButtonStyle.Link, label: 'Invite Me', url: invite(this.client) })); } - buttonRow.addComponents( - new ButtonBuilder({ style: ButtonStyle.Link, label: 'Support Server', url: this.client.config.supportGuild.invite }), - new ButtonBuilder({ style: ButtonStyle.Link, label: 'GitHub', url: packageDotJSON.repository }) - ); + const supportInvite = this.client.config.supportGuild.invite; + + if (supportInvite) { + buttonRow.addComponents(new ButtonBuilder({ style: ButtonStyle.Link, label: 'Support Server', url: supportInvite })); + } + + buttonRow.addComponents(new ButtonBuilder({ style: ButtonStyle.Link, label: 'GitHub', url: packageDotJSON.repository })); return await message.util.reply({ content: 'Here are some useful links:', components: [buttonRow] }); } } diff --git a/src/commands/utilities/wolframAlpha.ts b/src/commands/utilities/wolframAlpha.ts index 3cd0653..bac9f58 100644 --- a/src/commands/utilities/wolframAlpha.ts +++ b/src/commands/utilities/wolframAlpha.ts @@ -54,8 +54,17 @@ export default class WolframAlphaCommand extends BushCommand { ) { if (message.util.isSlashMessage(message)) await message.interaction.deferReply(); - args.image && void message.util.reply({ content: `${emojis.loading} Loading...`, embeds: [] }); - const waApi = WolframAlphaAPI(this.client.config.credentials.wolframAlphaAppId); + const appId = this.client.config.credentials.wolframAlphaAppId; + + if (appId === null || appId === '' || appId === '[APP_ID]') + return message.util.reply( + message.author.isSuperUser() + ? `${emojis.error} The 'wolframAlphaAppId' credential isn't set so this command cannot be used.` + : `${emojis.error} Sorry, this command is unavailable.` + ); + + if (args.image) void message.util.reply({ content: `${emojis.loading} Loading...`, embeds: [] }); + const waApi = WolframAlphaAPI(appId); const decodedEmbed = new EmbedBuilder().addFields({ name: '📥 Input', diff --git a/src/lib/common/Sentry.ts b/src/lib/common/Sentry.ts index 34bc06f..2792203 100644 --- a/src/lib/common/Sentry.ts +++ b/src/lib/common/Sentry.ts @@ -1,10 +1,12 @@ import { RewriteFrames } from '@sentry/integrations'; import * as SentryNode from '@sentry/node'; import { Integrations } from '@sentry/node'; -import config from '../../../config/options.js'; +import type { Config } from '../../../config/Config.js'; export class Sentry { - public constructor(rootdir: string) { + public constructor(rootdir: string, config: Config) { + if (config.credentials.sentryDsn === null) throw TypeError('sentryDsn cannot be null'); + SentryNode.init({ dsn: config.credentials.sentryDsn, environment: config.environment, diff --git a/src/lib/utils/BushClientUtils.ts b/src/lib/utils/BushClientUtils.ts index 44a08ef..af49803 100644 --- a/src/lib/utils/BushClientUtils.ts +++ b/src/lib/utils/BushClientUtils.ts @@ -1,11 +1,13 @@ import assert from 'assert'; import { cleanCodeBlockContent, + DMChannel, escapeCodeBlock, GuildMember, Message, + PartialDMChannel, Routes, - TextChannel, + TextBasedChannel, ThreadMember, User, type APIMessage, @@ -15,6 +17,7 @@ import { } from 'discord.js'; import got from 'got'; import _ from 'lodash'; +import { ConfigChannelKey } from '../../../config/Config.js'; import CommandErrorListener from '../../listeners/commands/commandError.js'; import { BushInspectOptions } from '../common/typings/BushInspectOptions.js'; import { CodeBlockLang } from '../common/typings/CodeBlockLang.js'; @@ -146,15 +149,16 @@ export class BushClientUtils { * @returns The readable version of the key or the original key if there isn't a mapping. */ #mapCredential(key: string): string { - const mapping = { - token: 'Main Token', - devToken: 'Dev Token', - betaToken: 'Beta Token', - hypixelApiKey: 'Hypixel Api Key', - wolframAlphaAppId: 'Wolfram|Alpha App ID', - dbPassword: 'Database Password' - }; - return mapping[key as keyof typeof mapping] || key; + return ( + { + token: 'Main Token', + devToken: 'Dev Token', + betaToken: 'Beta Token', + hypixelApiKey: 'Hypixel Api Key', + wolframAlphaAppId: 'Wolfram|Alpha App ID', + dbPassword: 'Database Password' + }[key] ?? key + ); } /** @@ -167,6 +171,7 @@ export class BushClientUtils { const credential = { ...this.client.config.credentials, dbPassword: this.client.config.db.password }[ credentialName as keyof typeof this.client.config.credentials ]; + if (credential === null || credential === '') continue; const replacement = this.#mapCredential(credentialName); const escapeRegex = /[.*+?^${}()|[\]\\]/g; text = text.replace(new RegExp(credential.toString().replace(escapeRegex, '\\$&'), 'g'), `[${replacement} Omitted]`); @@ -456,11 +461,24 @@ export class BushClientUtils { } /** - * Gets a a configured channel as a TextChannel. - * @channel The channel to retrieve. + * Resolves a channel from the config and ensures it is a non-dm-based-text-channel. + * @param channel The channel to retrieve. */ - public async getConfigChannel(channel: keyof Client['config']['channels']): Promise<TextChannel> { - return (await this.client.channels.fetch(this.client.config.channels[channel])) as unknown as TextChannel; + public async getConfigChannel( + channel: ConfigChannelKey + ): Promise<Exclude<TextBasedChannel, DMChannel | PartialDMChannel> | null> { + const channels = this.client.config.channels; + if (!(channel in channels)) + throw new TypeError(`Invalid channel provided (${channel}), must be one of ${Object.keys(channels).join(' ')}`); + + const channelId = channels[channel]; + if (channelId === '') return null; + + const res = await this.client.channels.fetch(channelId); + + if (!res?.isTextBased() || res.isDMBased()) return null; + + return res; } } diff --git a/src/lib/utils/BushLogger.ts b/src/lib/utils/BushLogger.ts index 3cfd860..5c98760 100644 --- a/src/lib/utils/BushLogger.ts +++ b/src/lib/utils/BushLogger.ts @@ -155,6 +155,7 @@ export class BushLogger { */ public async channelLog(message: SendMessageType): Promise<Message | null> { const channel = await this.client.utils.getConfigChannel('log'); + if (channel === null) return null; return await channel.send(message).catch(() => null); } @@ -322,5 +323,3 @@ export class BushLogger { await this.channelLog({ embeds: [embed] }).catch(() => {}); } } - -/** @typedef {PartialTextBasedChannelFields} vscodeDontDeleteMyImportTy */ diff --git a/src/lib/utils/BushUtils.ts b/src/lib/utils/BushUtils.ts index 059d001..e3539a1 100644 --- a/src/lib/utils/BushUtils.ts +++ b/src/lib/utils/BushUtils.ts @@ -11,7 +11,7 @@ import { } from '#lib'; import { humanizeDuration as humanizeDurationMod } from '@notenoughupdates/humanize-duration'; import assert from 'assert'; -import { exec } from 'child_process'; +import cp from 'child_process'; import deepLock from 'deep-lock'; import { Util as AkairoUtil } from 'discord-akairo'; import { @@ -43,13 +43,15 @@ export function capitalize(text: string): string { return text.charAt(0).toUpperCase() + text.slice(1); } +export const exec = promisify(cp.exec); + /** * Runs a shell command and gives the output * @param command The shell command to run * @returns The stdout and stderr of the shell command */ export async function shell(command: string): Promise<{ stdout: string; stderr: string }> { - return await promisify(exec)(command); + return await exec(command); } /** diff --git a/src/listeners/message/directMessage.ts b/src/listeners/message/directMessage.ts index 91733a5..7278e63 100644 --- a/src/listeners/message/directMessage.ts +++ b/src/listeners/message/directMessage.ts @@ -14,7 +14,11 @@ export default class DirectMessageListener extends BushListener { if (message.channel.type === ChannelType.DM) { if (!(message.author.id == this.client.user!.id) && message.author.bot) return; if (this.client.cache.global.blacklistedUsers.includes(message.author.id)) return; - const dmLogEmbed = new EmbedBuilder().setTimestamp().setFooter({ text: `User ID • ${message.channel.recipientId}` }); + + const dmLogEmbed = new EmbedBuilder() + .setTimestamp() + .setFooter({ text: `User ID • ${message.channel.recipientId}` }) + .setDescription(`**DM:**\n${message.content}`); if (message.author.id != this.client.user!.id) { dmLogEmbed @@ -22,7 +26,6 @@ export default class DirectMessageListener extends BushListener { name: `From: ${message.author.username}`, iconURL: `${message.author.displayAvatarURL()}` }) - .setDescription(`**DM:**\n${message}`) .setColor(colors.blue); } else { dmLogEmbed @@ -30,9 +33,7 @@ export default class DirectMessageListener extends BushListener { name: `To: ${message.channel.recipient?.username}`, iconURL: `${message.channel.recipient?.displayAvatarURL()}` }) - .setDescription(`**DM:**\n${message}`) - .setColor(colors.red) - .setTimestamp(); + .setColor(colors.red); } if (message.attachments.filter((a) => typeof a.size == 'number').size == 1) { dmLogEmbed.setImage(message.attachments.filter((a) => typeof a.size == 'number').first()!.proxyURL); @@ -40,6 +41,7 @@ export default class DirectMessageListener extends BushListener { dmLogEmbed.addFields({ name: 'Attachments', value: message.attachments.map((a) => a.proxyURL).join('\n') }); } const dmChannel = await this.client.utils.getConfigChannel('dm'); + if (dmChannel === null) return; await dmChannel.send({ embeds: [dmLogEmbed] }); } } |