import "./loadEnv"; import path from "path"; import yaml from "js-yaml"; import fs from "fs"; import { Knub, PluginError } from "knub"; import { SimpleError } from "./SimpleError"; import { Configs } from "./data/Configs"; // Always use UTC internally // This is also enforced for the database in data/db.ts import moment from "moment-timezone"; import { Client, TextChannel } from "eris"; import { connect } from "./data/db"; import { baseGuildPlugins, globalPlugins, guildPlugins } from "./plugins/availablePlugins"; import { errorMessage, isDiscordHTTPError, isDiscordRESTError, MINUTES, successMessage } from "./utils"; import { startUptimeCounter } from "./uptime"; import { AllowedGuilds } from "./data/AllowedGuilds"; import { ZeppelinGlobalConfig, ZeppelinGuildConfig } from "./types"; import { RecoverablePluginError } from "./RecoverablePluginError"; import { GuildLogs } from "./data/GuildLogs"; import { LogType } from "./data/LogType"; import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin"; import { logger } from "./logger"; import { PluginLoadError } from "knub/dist/plugins/PluginLoadError"; const fsp = fs.promises; if (!process.env.KEY) { // tslint:disable-next-line:no-console console.error("Project root .env with KEY is required!"); process.exit(1); } declare global { // This is here so TypeScript doesn't give an error when importing twemoji // since one of the signatures of twemoji.parse() takes an HTMLElement but // we're not in a browser environment so including the DOM lib would not make // sense type HTMLElement = unknown; } // Error handling let recentPluginErrors = 0; const RECENT_PLUGIN_ERROR_EXIT_THRESHOLD = 5; let recentDiscordErrors = 0; const RECENT_DISCORD_ERROR_EXIT_THRESHOLD = 5; setInterval(() => (recentPluginErrors = Math.max(0, recentPluginErrors - 1)), 2500); setInterval(() => (recentDiscordErrors = Math.max(0, recentDiscordErrors - 1)), 2500); if (process.env.NODE_ENV === "production") { const errorHandler = err => { const guildName = err.guild?.name || "Global"; const guildId = err.guild?.id || "0"; if (err instanceof RecoverablePluginError) { // Recoverable plugin errors can be, well, recovered from. // Log it in the console as a warning and post a warning to the guild's log. // tslint:disable:no-console console.warn(`${guildName}: [${err.code}] ${err.message}`); if (err.guild) { const logs = new GuildLogs(err.guild.id); logs.log(LogType.BOT_ALERT, { body: `\`[${err.code}]\` ${err.message}` }); } return; } if (err instanceof PluginLoadError) { // tslint:disable:no-console console.warn(`${guildName} (${guildId}): Failed to load plugin '${err.pluginName}': ${err.message}`); return; } // tslint:disable:no-console console.error(err); if (err instanceof PluginError) { // Tolerate a few recent plugin errors before crashing if (++recentPluginErrors >= RECENT_PLUGIN_ERROR_EXIT_THRESHOLD) { console.error(`Exiting after ${RECENT_PLUGIN_ERROR_EXIT_THRESHOLD} plugin errors`); process.exit(1); } } else if (isDiscordRESTError(err) || isDiscordHTTPError(err)) { // Discord API errors, usually safe to just log instead of crash // We still bail if we get a ton of them in a short amount of time if (++recentDiscordErrors >= RECENT_DISCORD_ERROR_EXIT_THRESHOLD) { console.error(`Exiting after ${RECENT_DISCORD_ERROR_EXIT_THRESHOLD} API errors`); process.exit(1); } } else { // On other errors, crash immediately process.exit(1); } // tslint:enable:no-console }; process.on("uncaughtException", errorHandler); } // Verify required Node.js version const REQUIRED_NODE_VERSION = "14.0.0"; const requiredParts = REQUIRED_NODE_VERSION.split(".").map(v => parseInt(v, 10)); const actualVersionParts = process.versions.node.split(".").map(v => parseInt(v, 10)); for (const [i, part] of actualVersionParts.entries()) { if (part > requiredParts[i]) break; if (part === requiredParts[i]) continue; throw new SimpleError(`Unsupported Node.js version! Must be at least ${REQUIRED_NODE_VERSION}`); } moment.tz.setDefault("UTC"); logger.info("Connecting to database"); connect().then(async () => { const client = new Client(`Bot ${process.env.TOKEN}`, { getAllUsers: false, restMode: true, compress: false, intents: [ // Privileged "guildMembers", "guildPresences", "guildMessageTyping", // Regular "directMessages", "guildBans", "guildEmojis", "guildInvites", "guildMessageReactions", "guildMessages", "guilds", "guildVoiceStates", ], }); client.setMaxListeners(200); client.on("debug", message => { if (message.includes(" 429 ")) { logger.info(`[429] ${message}`); } }); const allowedGuilds = new AllowedGuilds(); const guildConfigs = new Configs(); const bot = new Knub(client, { guildPlugins, globalPlugins, options: { canLoadGuild(guildId): Promise { return allowedGuilds.isAllowed(guildId); }, /** * Plugins are enabled if they... * - are base plugins, i.e. always enabled, or * - are explicitly enabled in the guild config * Dependencies are also automatically loaded by Knub. */ async getEnabledGuildPlugins(ctx, plugins): Promise { if (!ctx.config || !ctx.config.plugins) { return []; } const configuredPlugins = ctx.config.plugins; const basePluginNames = baseGuildPlugins.map(p => p.name); return Array.from(plugins.keys()).filter(pluginName => { if (basePluginNames.includes(pluginName)) return true; return configuredPlugins[pluginName] && configuredPlugins[pluginName].enabled !== false; }); }, async getConfig(id) { const key = id === "global" ? "global" : `guild-${id}`; const row = await guildConfigs.getActiveByKey(key); if (row) { return yaml.safeLoad(row.config); } logger.warn(`No config with key "${key}"`); return {}; }, logFn: (level, msg) => { if (level === "debug") return; if (logger[level]) { logger[level](msg); } else { logger.log(`[${level.toUpperCase()}] ${msg}`); } }, performanceDebug: { enabled: false, size: 30, threshold: 200, }, sendSuccessMessageFn(channel, body) { const guildId = channel instanceof TextChannel ? channel.guild.id : undefined; const emoji = guildId ? bot.getLoadedGuild(guildId).config.success_emoji : undefined; channel.createMessage(successMessage(body, emoji)); }, sendErrorMessageFn(channel, body) { const guildId = channel instanceof TextChannel ? channel.guild.id : undefined; const emoji = guildId ? bot.getLoadedGuild(guildId).config.error_emoji : undefined; channel.createMessage(errorMessage(body, emoji)); }, }, }); client.once("ready", () => { startUptimeCounter(); }); logger.info("Starting the bot"); bot.run(); });