monitor/core/modules/DiscordBot/index.ts
2025-04-16 22:30:27 +07:00

505 lines
19 KiB
TypeScript

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<string|void>((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`);
}
}
};