350 lines
8.4 KiB
TypeScript
350 lines
8.4 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import {
|
|
AutocompleteInteraction,
|
|
BaseInteraction,
|
|
ChatInputCommandInteraction,
|
|
GuildMember,
|
|
Interaction,
|
|
Message,
|
|
MessageFlags,
|
|
PermissionFlagsBits,
|
|
REST,
|
|
Routes,
|
|
SlashCommandOptionsOnlyBuilder,
|
|
VoiceState
|
|
} from 'discord.js';
|
|
import { Logger } from './utils/log';
|
|
import { config } from './utils/config';
|
|
import { Bot } from './bot';
|
|
import { isModule } from './utils/misc';
|
|
|
|
export interface Command {
|
|
name?: string;
|
|
builder?: SlashCommandOptionsOnlyBuilder;
|
|
requiresAdmin?: boolean;
|
|
ownerOnly?: boolean;
|
|
|
|
guild_keys?: Record<string, unknown>;
|
|
user_keys?: Record<string, unknown>;
|
|
|
|
execute?: (interaction: ChatInputCommandInteraction) => Promise<void>;
|
|
autocomplete?: (interaction: AutocompleteInteraction) => Promise<void>;
|
|
messageListener?: (msg: Message) => Promise<void>;
|
|
voiceStateListener?: (
|
|
prevState: VoiceState,
|
|
newState: VoiceState
|
|
) => Promise<void>;
|
|
}
|
|
|
|
export interface CommandCategoryInfo {
|
|
name: string;
|
|
description: string;
|
|
}
|
|
|
|
export interface CommandCategory {
|
|
info: CommandCategoryInfo;
|
|
commands: Command[];
|
|
}
|
|
|
|
export class CommandManager {
|
|
private readonly folderPath: string;
|
|
private readonly log: Logger;
|
|
|
|
private initialized: boolean = false;
|
|
private categories: Array<CommandCategory> = [];
|
|
|
|
/*
|
|
public
|
|
*/
|
|
public constructor(cmdFolder: string) {
|
|
this.folderPath = path.resolve(__dirname, cmdFolder);
|
|
this.log = new Logger('Command manager');
|
|
}
|
|
|
|
public async init(): Promise<void> {
|
|
if (this.initialized)
|
|
throw new Error('CommandManager was already initialized');
|
|
this.initialized = true;
|
|
await this.populateCommands();
|
|
this.registerSlashCommands();
|
|
|
|
const bot = Bot.get;
|
|
|
|
bot.on('interactionCreate', (interaction: Interaction) => {
|
|
this.onInteraction(interaction);
|
|
});
|
|
bot.on('messageCreate', (message: Message) => {
|
|
this.onMessage(message);
|
|
});
|
|
bot.on('voiceStateUpdate', (oldState: VoiceState, newState: VoiceState) => {
|
|
this.onVoiceStateUpdate(oldState, newState);
|
|
});
|
|
}
|
|
|
|
public getCategories(): Array<CommandCategory> {
|
|
return this.categories;
|
|
}
|
|
|
|
public get(commandName: string): Command | null {
|
|
return this.getAll().find((cmd) => cmd.name === commandName) || null;
|
|
}
|
|
|
|
public getAll(): Command[] {
|
|
return this.categories.flatMap((cat) => cat.commands);
|
|
}
|
|
|
|
public getAllInteractable(): Command[] {
|
|
return this.getAll().filter((cmd) => !!cmd.name);
|
|
}
|
|
|
|
public getGuildKeys(): Record<string, unknown> {
|
|
return this.mergeKeys('guild_keys');
|
|
}
|
|
|
|
public getUserKeys(): Record<string, unknown> {
|
|
return this.mergeKeys('user_keys');
|
|
}
|
|
|
|
private mergeKeys(
|
|
keyType: 'guild_keys' | 'user_keys'
|
|
): Record<string, unknown> {
|
|
return this.getAll()
|
|
.flatMap((cmd) => (cmd[keyType] ? [cmd[keyType]] : []))
|
|
.reduce((acc, val) => Object.assign(acc, val), {});
|
|
}
|
|
|
|
/*
|
|
internal
|
|
*/
|
|
private async populateCommands() {
|
|
if (!fs.existsSync(this.folderPath))
|
|
throw new Error(`Command directory not found: ${this.folderPath}`);
|
|
|
|
const categoryFolders = fs.readdirSync(this.folderPath);
|
|
|
|
for (const categoryFolder of categoryFolders) {
|
|
const catPath = path.join(this.folderPath, categoryFolder);
|
|
await this.processCategory(catPath, categoryFolder);
|
|
}
|
|
}
|
|
|
|
private async processCategory(
|
|
catPath: string,
|
|
folderName: string
|
|
): Promise<void> {
|
|
try {
|
|
const stat = fs.statSync(catPath);
|
|
if (!stat.isDirectory()) {
|
|
this.log.warning(
|
|
'Skipping non-directory entry found on cmd folder (%s)',
|
|
folderName
|
|
);
|
|
return;
|
|
}
|
|
|
|
const catInfo = await this.loadCatInfo(catPath);
|
|
const commands = await this.loadCatCommands(catPath);
|
|
|
|
if (catInfo == undefined) {
|
|
this.log.warning('Folder %s was missing info, ignoring', folderName);
|
|
return;
|
|
}
|
|
|
|
this.categories.push({
|
|
info: catInfo,
|
|
commands: commands
|
|
});
|
|
} catch (err) {
|
|
this.log.error('Error processing category %s: %s', folderName, err);
|
|
}
|
|
}
|
|
|
|
private async loadCatInfo(
|
|
catPath: string
|
|
): Promise<CommandCategoryInfo | undefined> {
|
|
try {
|
|
const extensions = ['js', 'mjs', 'cjs', 'ts'];
|
|
|
|
for (const ext of extensions) {
|
|
const descriptorPath = path.join(catPath, `_category.${ext}`);
|
|
if (!fs.existsSync(descriptorPath)) continue;
|
|
|
|
const module = await import(`file://${descriptorPath}`);
|
|
return (
|
|
module.default?.default ||
|
|
module.default ||
|
|
(module as CommandCategoryInfo)
|
|
);
|
|
}
|
|
|
|
throw new Error('Missing categoryinfo.json');
|
|
} catch (err) {
|
|
this.log.error('Error loading category info at %s: %s', catPath, err);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private async loadCatCommands(catPath: string): Promise<Array<Command>> {
|
|
const promises = fs
|
|
.readdirSync(catPath)
|
|
.filter((file) => isModule(file))
|
|
.map(
|
|
async (file) => await this.attemptLoadCommand(path.join(catPath, file))
|
|
);
|
|
return (await Promise.all(promises)).filter((cmd): cmd is Command => !!cmd);
|
|
}
|
|
|
|
/*
|
|
cmd parsing
|
|
*/
|
|
private async attemptLoadCommand(filePath: string): Promise<Command | null> {
|
|
try {
|
|
const module = await import(`file://${filePath}`);
|
|
const command =
|
|
module.default?.default || module.default || (module as Command);
|
|
return command;
|
|
} catch (error) {
|
|
this.log.error(
|
|
`Error loading command ${path.basename(filePath)}: ${error}`
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/*
|
|
misc functions
|
|
*/
|
|
private async registerSlashCommands(): Promise<void> {
|
|
this.log.info('Registering slash commands...');
|
|
|
|
try {
|
|
const cmdJSONList = this.getAll()
|
|
.filter((cmd) => cmd.builder !== undefined)
|
|
.map((cmd) => cmd.builder?.toJSON())
|
|
.filter((cmd) => cmd !== undefined);
|
|
|
|
const rest = new REST({ version: '10' }).setToken(config.token as string);
|
|
await rest.put(Routes.applicationCommands(config.client_id), {
|
|
body: cmdJSONList
|
|
});
|
|
|
|
this.log.info('Registered %i commands', cmdJSONList.length);
|
|
} catch (err) {
|
|
this.log.warning(
|
|
'Error occurred while registering slash commands: %s',
|
|
err
|
|
);
|
|
}
|
|
}
|
|
|
|
/*
|
|
interaction handling
|
|
*/
|
|
private async executeCommandInteraction(
|
|
interaction: ChatInputCommandInteraction
|
|
): Promise<void> {
|
|
const cmdName = interaction.commandName;
|
|
const command = this.get(cmdName);
|
|
if (!command)
|
|
return this.log.error(
|
|
'Attempted to execute non-existing command (%s)',
|
|
cmdName
|
|
);
|
|
|
|
if (command.requiresAdmin) {
|
|
const member = interaction.member as GuildMember;
|
|
if (!member.permissions.has(PermissionFlagsBits.Administrator) || member.id != config.owner_id) {
|
|
await interaction.reply({
|
|
content:
|
|
"You don't have the permissions required to execute this command.",
|
|
flags: MessageFlags.Ephemeral
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (command.ownerOnly) {
|
|
const member = interaction.member as GuildMember;
|
|
if (member.id != config.owner_id) {
|
|
await interaction.reply({
|
|
content: 'This command is restricted.',
|
|
flags: MessageFlags.Ephemeral
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (command.execute) {
|
|
try {
|
|
await command.execute(interaction);
|
|
} catch (error) {
|
|
this.log.error(
|
|
'Error occurred while executing command %s: %s',
|
|
cmdName,
|
|
error
|
|
);
|
|
}
|
|
} else this.log.error('Command is missing execute method: %s', cmdName);
|
|
}
|
|
|
|
private async autocompleteCommandInteraction(
|
|
interaction: AutocompleteInteraction
|
|
): Promise<void> {
|
|
const cmdName = interaction.commandName;
|
|
const command = this.get(cmdName);
|
|
|
|
if (!command)
|
|
return this.log.error(
|
|
'Attempted to execute unexisting command (%s)',
|
|
cmdName
|
|
);
|
|
|
|
if (command.autocomplete) {
|
|
try {
|
|
await command.autocomplete(interaction);
|
|
} catch (error) {
|
|
this.log.error(
|
|
'Error occurred while autocompleting command %s: %s',
|
|
cmdName,
|
|
error
|
|
);
|
|
}
|
|
} else
|
|
this.log.error('Command is missing autocomplete method: %s', cmdName);
|
|
}
|
|
|
|
/*
|
|
event listeners
|
|
*/
|
|
private async onInteraction(interaction: BaseInteraction): Promise<void> {
|
|
/*
|
|
cmd execution
|
|
*/
|
|
if (interaction.isChatInputCommand())
|
|
return this.executeCommandInteraction(interaction);
|
|
|
|
if (interaction.isAutocomplete())
|
|
return this.autocompleteCommandInteraction(interaction);
|
|
|
|
return;
|
|
}
|
|
|
|
private async onMessage(message: Message<boolean>): Promise<void> {
|
|
if (message.author.bot) return;
|
|
|
|
for (const cmd of this.getAll())
|
|
if (cmd.messageListener) await cmd.messageListener(message);
|
|
}
|
|
|
|
private async onVoiceStateUpdate(
|
|
oldState: VoiceState,
|
|
newState: VoiceState
|
|
): Promise<void> {
|
|
for (const cmd of this.getAll())
|
|
if (cmd.voiceStateListener)
|
|
await cmd.voiceStateListener(oldState, newState);
|
|
}
|
|
}
|