const modulename = 'DiscordBot'; import Discord, { ActivityType, ChannelType, Client, EmbedBuilder, GatewayIntentBits } from 'discord.js'; import slashCommands from './slash'; import interactionCreateHandler from './interactionCreateHandler'; import { generateStatusMessage } from './commands/status'; import consoleFactory from '@lib/console'; import { embedColors } from './discordHelpers'; import { DiscordBotStatus } from '@shared/enums'; import { UpdateConfigKeySet } from '@modules/ConfigStore/utils'; const console = consoleFactory(modulename); //Types type MessageTranslationType = { key: string; data?: object; } type AnnouncementType = { title?: string | MessageTranslationType; description: string | MessageTranslationType; type: keyof typeof embedColors; } type SpawnConfig = Pick< TxConfigs['discordBot'], 'enabled' | 'token' | 'guild' | 'warningsChannel' >; /** * Module that handles the discord bot, provides methods to resolve members and send announcements, as well as * providing discord slash commands. */ export default class DiscordBot { //NOTE: only listening to embed changes, as the settings page boots the bot if enabled static readonly configKeysWatched = [ 'discordBot.embedJson', 'discordBot.embedConfigJson', ]; readonly #clientOptions: Discord.ClientOptions = { intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, ], allowedMentions: { parse: ['users'], repliedUser: true, }, //FIXME: fixme // http: { // agent: { // localAddress: txHostConfig.netInterface, // } // } } readonly cooldowns = new Map(); #client: Client | undefined; guild: Discord.Guild | undefined; guildName: string | undefined; announceChannel: Discord.TextBasedChannel | undefined; #lastDisallowedIntentsError: number = 0; //ms #lastGuildMembersCacheRefresh: number = 0; //ms #lastStatus = DiscordBotStatus.Disabled; #lastExplicitStatus = DiscordBotStatus.Disabled; constructor() { // FIXME: Hacky solution to fix the issue with disallowed intents // Remove this when issue below is fixed // https://github.com/discordjs/discord.js/issues/9621 process.on('unhandledRejection', (error: Error) => { if (error.message === 'Used disallowed intents') { this.#lastDisallowedIntentsError = Date.now(); } }); setImmediate(() => { if (txConfig.discordBot.enabled) { this.startBot()?.catch((e) => { }); } }); //Cron setInterval(() => { if (txConfig.discordBot.enabled) { this.updateBotStatus().catch((e) => { }); } }, 60_000); //Not sure how often do we want to do this, or if we need to do this at all, //but previously this was indirectly refreshed every 5 seconds by the health monitor setInterval(() => { this.refreshWsStatus(); }, 7500); } /** * Handle updates to the config by resetting the required metrics */ public handleConfigUpdate(updatedConfigs: UpdateConfigKeySet) { return this.updateBotStatus(); } /** * Called by settings save to attempt to restart the bot with new settings */ async attemptBotReset(botCfg: SpawnConfig | false) { this.#lastGuildMembersCacheRefresh = 0; if (this.#client) { console.warn('Stopping Discord Bot'); this.#client.destroy(); this.refreshWsStatus(); setTimeout(() => { if (!botCfg || !botCfg.enabled) this.#client = undefined; }, 1000); } if (botCfg && botCfg.enabled) { return await this.startBot(botCfg); } else { return true; } } /** * Passthrough to discord.js isReady() */ get isClientReady() { return (this.#client) ? this.#client.isReady() : false; } /** * Passthrough to discord.js websocket status */ get status(): DiscordBotStatus { if (!txConfig.discordBot.enabled) { return DiscordBotStatus.Disabled; } else if (this.#client?.ws.status === Discord.Status.Ready) { return DiscordBotStatus.Ready; } else { return this.#lastExplicitStatus; } } /** * Updates the bot client status and pushes to the websocket */ refreshWsStatus() { if (this.#lastStatus !== this.status) { this.#lastStatus = this.status; txCore.webServer.webSocket.pushRefresh('status'); } } /** * Send an announcement to the configured channel */ async sendAnnouncement(content: AnnouncementType) { if (!txConfig.discordBot.enabled) return; if ( !txConfig.discordBot.warningsChannel || !this.#client?.isReady() || !this.announceChannel ) { console.verbose.warn('not ready yet to send announcement'); return false; } try { let title; if (content.title) { title = (typeof content.title === 'string') ? content.title : txCore.translator.t(content.title.key, content.title.data); } let description; if (content.description) { description = (typeof content.description === 'string') ? content.description : txCore.translator.t(content.description.key, content.description.data); } const embed = new EmbedBuilder({ title, description }).setColor(embedColors[content.type]); await this.announceChannel.send({ embeds: [embed] }); } catch (error) { console.error(`Error sending Discord announcement: ${(error as Error).message}`); } } /** * Update persistent status and activity */ async updateBotStatus() { if (!this.#client?.isReady() || !this.#client.user) { console.verbose.warn('not ready yet to update status'); return false; } //Updating bot activity try { const serverClients = txCore.fxPlayerlist.onlineCount; const serverMaxClients = txCore.cacheStore.get('fxsRuntime:maxClients') ?? '??'; const serverName = txConfig.general.serverName; const message = `[${serverClients}/${serverMaxClients}] on ${serverName}`; this.#client.user.setActivity(message, { type: ActivityType.Watching }); } catch (error) { console.verbose.warn(`Failed to set bot activity: ${(error as Error).message}`); } //Updating server status embed try { const oldChannelId = txCore.cacheStore.get('discord:status:channelId'); const oldMessageId = txCore.cacheStore.get('discord:status:messageId'); if (typeof oldChannelId === 'string' && typeof oldMessageId === 'string') { const oldChannel = await this.#client.channels.fetch(oldChannelId); if (!oldChannel) throw new Error(`oldChannel could not be resolved`); if (oldChannel.type !== ChannelType.GuildText && oldChannel.type !== ChannelType.GuildAnnouncement) { throw new Error(`oldChannel is not guild text or announcement channel`); } await oldChannel.messages.edit(oldMessageId, generateStatusMessage()); } } catch (error) { console.verbose.warn(`Failed to update status embed: ${(error as Error).message}`); } } /** * Starts the discord client */ startBot(botCfg?: SpawnConfig) { const isConfigSaveAttempt = !!botCfg; botCfg ??= { enabled: txConfig.discordBot.enabled, token: txConfig.discordBot.token, guild: txConfig.discordBot.guild, warningsChannel: txConfig.discordBot.warningsChannel, } if (!botCfg.enabled) return; return new Promise((resolve, reject) => { type ErrorOptData = { code?: string; clientId?: string; prohibitedPermsInUse?: string[]; } const sendError = (msg: string, data: ErrorOptData = {}) => { console.error(msg); const e = new Error(msg); Object.assign(e, data); console.warn('Stopping Discord Bot'); this.#client?.destroy(); setImmediate(() => { this.#lastExplicitStatus = DiscordBotStatus.Error; this.refreshWsStatus(); this.#client = undefined; }); return reject(e); } //Check for configs if (typeof botCfg.token !== 'string' || !botCfg.token.length) { return sendError('Discord bot enabled while token is not set.'); } if (typeof botCfg.guild !== 'string' || !botCfg.guild.length) { return sendError('Discord bot enabled while guild id is not set.'); } //State check if (this.#client && this.#client.ws.status !== 3 && this.#client.ws.status !== 5) { console.verbose.warn('Destroying client before restart.'); this.#client.destroy(); } //Setting up client object this.#lastExplicitStatus = DiscordBotStatus.Starting; this.refreshWsStatus(); this.#client = new Client(this.#clientOptions); //Setup disallowed intents unhandled rejection watcher const lastKnownDisallowedIntentsError = this.#lastDisallowedIntentsError; const disallowedIntentsWatcherId = setInterval(() => { if (this.#lastDisallowedIntentsError !== lastKnownDisallowedIntentsError) { clearInterval(disallowedIntentsWatcherId); return sendError( `This bot does not have a required privileged intent.`, { code: 'DisallowedIntents' } ); } }, 250); //Setup Ready listener this.#client.on('ready', async () => { clearInterval(disallowedIntentsWatcherId); if (!this.#client?.isReady() || !this.#client.user) throw new Error(`ready event while not being ready`); //Fetching guild const guild = this.#client.guilds.cache.find((guild) => guild.id === botCfg.guild); if (!guild) { return sendError( `Discord bot could not resolve guild/server ID ${botCfg.guild}.`, { code: 'CustomNoGuild', clientId: this.#client.user.id } ); } this.guild = guild; this.guildName = guild.name; //Checking for dangerous permissions // https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags // These are the same perms that require 2fa enabled - although it doesn't apply here const prohibitedPerms = [ 'Administrator', //'ADMINISTRATOR', 'BanMembers', //'BAN_MEMBERS' 'KickMembers', //'KICK_MEMBERS' 'ManageChannels', //'MANAGE_CHANNELS', 'ManageGuildExpressions', //'MANAGE_GUILD_EXPRESSIONS' 'ManageGuild', //'MANAGE_GUILD', 'ManageMessages', //'MANAGE_MESSAGES' 'ManageRoles', //'MANAGE_ROLES', 'ManageThreads', //'MANAGE_THREADS' 'ManageWebhooks', //'MANAGE_WEBHOOKS' 'ViewCreatorMonetizationAnalytics', //'VIEW_CREATOR_MONETIZATION_ANALYTICS' ] const botPerms = this.guild.members.me?.permissions.serialize(); if (!botPerms) { return sendError(`Discord bot could not detect its own permissions.`); } const prohibitedPermsInUse = Object.entries(botPerms) .filter(([permName, permEnabled]) => prohibitedPerms.includes(permName) && permEnabled) .map((x) => x[0]) if (prohibitedPermsInUse.length) { const name = this.#client.user.username; const perms = prohibitedPermsInUse.includes('Administrator') ? 'Administrator' : prohibitedPermsInUse.join(', '); return sendError( `This bot (${name}) has dangerous permissions (${perms}) and for your safety the bot has been disabled.`, { code: 'DangerousPermission' } ); } //Fetching announcements channel if (botCfg.warningsChannel) { const fetchedChannel = this.#client.channels.cache.find((x) => x.id === botCfg.warningsChannel); if (!fetchedChannel) { return sendError(`Channel ${botCfg.warningsChannel} not found.`); } else if (fetchedChannel.type !== ChannelType.GuildText && fetchedChannel.type !== ChannelType.GuildAnnouncement) { return sendError(`Channel ${botCfg.warningsChannel} - ${(fetchedChannel as any)?.name} is not a text or announcement channel.`); } else { this.announceChannel = fetchedChannel; } } // if previously registered by tx before v6 or other bot this.guild.commands.set(slashCommands).catch(console.dir); this.#client.application?.commands.set([]).catch(console.dir); //The settings save will the updateBotStatus, so no need to call it here if (!isConfigSaveAttempt) { this.updateBotStatus().catch((e) => { }); } const successMsg = `Discord bot running as \`${this.#client.user.tag}\` on \`${guild.name}\`.`; console.ok(successMsg); this.refreshWsStatus(); return resolve(successMsg); }); //Setup remaining event listeners this.#client.on('error', (error) => { this.refreshWsStatus(); clearInterval(disallowedIntentsWatcherId); console.error(`Error from Discord.js client: ${error.message}`); return reject(error); }); this.#client.on('resume', () => { console.verbose.ok('Connection with Discord API server resumed'); this.updateBotStatus().catch((e) => { }); this.refreshWsStatus(); }); this.#client.on('interactionCreate', interactionCreateHandler); // this.#client.on('debug', console.verbose.debug); //Start bot this.#client.login(botCfg.token).catch((error) => { clearInterval(disallowedIntentsWatcherId); //for some reason, this is not throwing unhandled rejection anymore /shrug if (error.message === 'Used disallowed intents') { return sendError( `This bot does not have a required privileged intent.`, { code: 'DisallowedIntents' } ); } //set status to error this.#lastExplicitStatus = DiscordBotStatus.Error; this.refreshWsStatus(); //if no message, create one if (!('message' in error) || !error.message) { error.message = 'no reason available - ' + JSON.stringify(error); } console.error(`Discord login failed with error: ${error.message}`); return reject(error); }); }); } /** * Refreshes the bot guild member cache */ async refreshMemberCache() { if (!txConfig.discordBot.enabled) throw new Error(`discord bot is disabled`); if (!this.#client?.isReady()) throw new Error(`discord bot not ready yet`); if (!this.guild) throw new Error(`guild not resolved`); //Check when the cache was last refreshed const currTs = Date.now(); if (currTs - this.#lastGuildMembersCacheRefresh > 60_000) { try { await this.guild.members.fetch(); this.#lastGuildMembersCacheRefresh = currTs; return true; } catch (error) { return false; } } return false; } /** * Return if an ID is a guild member, and their roles */ async resolveMemberRoles(uid: string) { if (!txConfig.discordBot.enabled) throw new Error(`discord bot is disabled`); if (!this.#client?.isReady()) throw new Error(`discord bot not ready yet`); if (!this.guild) throw new Error(`guild not resolved`); //Try to get member from cache or refresh cache then try again let member = this.guild.members.cache.find(m => m.id === uid); if (!member && await this.refreshMemberCache()) { member = this.guild.members.cache.find(m => m.id === uid); } //Return result if (member) { return { isMember: true, memberRoles: member.roles.cache.map((role) => role.id), }; } else { return { isMember: false } } } /** * Resolves a user by its discord identifier. */ async resolveMemberProfile(uid: string) { if (!this.#client?.isReady()) throw new Error(`discord bot not ready yet`); const avatarOptions: Discord.ImageURLOptions = { size: 64, forceStatic: true }; //Check if in guild member if (this.guild) { //Try to get member from cache or refresh cache then try again let member = this.guild.members.cache.find(m => m.id === uid); if (!member && await this.refreshMemberCache()) { member = this.guild.members.cache.find(m => m.id === uid); } if (member) { return { tag: member.nickname ?? member.user.username, avatar: member.displayAvatarURL(avatarOptions) ?? member.user.displayAvatarURL(avatarOptions), }; } } //Checking if user resolvable //NOTE: this one might still spam the API // https://discord.js.org/#/docs/discord.js/14.11.0/class/UserManager?scrollTo=fetch const user = await this.#client.users.fetch(uid); if (user) { return { tag: user.username, avatar: user.displayAvatarURL(avatarOptions), }; } else { throw new Error(`could not resolve discord user`); } } };