505 lines
19 KiB
TypeScript
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`);
|
|
}
|
|
}
|
|
};
|