import * as Files from './utilities/readFile'; import type * as Utils from './utilities/preprocessors/args'; import type { possibleOutput, Visibility, Context, Arg } from '../types/handler'; import type { ApplicationCommandOptionData, Awaitable, Client, CommandInteraction, Message } from 'discord.js'; import { Ok, None, Some } from 'ts-results'; import { isNotFromBot, hasPrefix, fmt } from './utilities/messageHelpers'; import Logger from './logger'; import { AllTrue } from './utilities/higherOrders'; /** * @class */ export class Handler { private wrapper: Wrapper; /** * * @constructor * @param {Wrapper} wrapper The data that is required to run sern handler */ constructor(wrapper: Wrapper) { this.wrapper = wrapper; this.client /** * On ready, builds command data and registers them all * from command directory **/ .on('ready', async () => { Files.buildData(this) .then(data => this.registerModules(data)); if (wrapper.init !== undefined) wrapper.init(this); new Logger().tableRam(); }) .on('messageCreate', async (message: Message) => { const isExecutable = AllTrue(isNotFromBot, hasPrefix); if (!isExecutable(message, this.prefix)) return; if (message.channel.type === 'DM') return; // TODO: Handle dms const module = this.findModuleFrom(message); if (module === undefined) { message.channel.send('Unknown legacy command'); return; } const cmdResult = await this.commandResult(module, message); if (cmdResult === undefined) return; message.channel.send(cmdResult); }) .on('interactionCreate', async (interaction) => { if (!interaction.isCommand()) return; if (interaction.guild === null) return; // TODO : handle dms const module = this.findModuleFrom(interaction); if (module === undefined) { interaction.channel!.send('Unknown slash command!'); return; } const res = await this.interactionResult(module, interaction); if (res === undefined) return; await interaction.reply(res); }); } /** * * @param {Files.CommandVal} module Command file information * @param {CommandInteraction} interaction The Discord.js command interaction (DiscordJS#CommandInteraction)) * @returns {possibleOutput | undefined} Takes return value and replies it, if possible input */ private async interactionResult( module: Files.CommandVal, interaction: CommandInteraction, ): Promise { const name = this.findModuleFrom(interaction); if (name === undefined) return `Could not find ${interaction.commandName} command!`; if (module.mod.type < CommandType.SLASH) return 'This is not a slash command'; const context = { message: None, interaction: Some(interaction) }; const parsedArgs = module.mod.parse?.(context, ['slash', interaction.options]) ?? Ok(''); if (parsedArgs.err) return parsedArgs.val; return (await module.mod.execute?.(context, parsedArgs) as possibleOutput | undefined); } /** * * @param {Files.CommandVal} module Command file information * @param {Message} message The message object * @param {string} args Anything after the command * @returns Takes return value and replies it, if possible input */ private async commandResult( module: Files.CommandVal, message: Message, ): Promise { if (module.mod.type === CommandType.SLASH) return `This may be a slash command and not a legacy command`; if (module.mod.visibility === 'private') { const checkIsTestServer = this.privateServers.find(({ id }) => id === message.guildId!)?.test; if (checkIsTestServer === undefined) return 'This command has the private modifier but is not registered under Handler#privateServers'; if (checkIsTestServer !== module.mod.test) { const msg = `This command is only available on test servers.`; // TODO: Customizable private message return msg; } } const context = { message: Some(message), interaction: None, }; const args = message.content.slice(this.prefix.length).trim().split(/s+/g); const parsedArgs = module.mod.parse?.(context, ['text', args]) ?? Ok(args); if (parsedArgs.err) return parsedArgs.val; return (await module.mod.execute?.(context, parsedArgs) as possibleOutput | undefined); } /** * This function chains `Files.buildData` * @param {{name: string, mod: Module, absPath: string}} modArr module information */ private async registerModules( modArr: { name: string; mod: Module; absPath: string; }[], ) { for await (const { name, mod, absPath } of modArr) { const cmdName = Files.fmtFileName(name); switch (mod.type) { case 1: Files.Commands.set(cmdName, { mod, options: [] }); break; case 2: case 1 | 2: { const options = (await import(absPath)).options as ApplicationCommandOptionData[]; Files.Commands.set(cmdName, { mod, options: options ?? [] }); switch (mod.visibility) { case 'private': { // Reloading guild slash commands await this.reloadSlash(cmdName, mod.desc, options); } case 'public': { // Creating global commands await this.client.application!.commands.create({ name: cmdName, description: mod.desc, options, }); } } } break; default: throw Error(`SernHandlerError: ${name} with ${mod.visibility} is not a valid module type.`); } if (mod.alias.length > 0) { for (const alias of mod.alias) { Files.Alias.set(alias, { mod, options: [] }); } } } } /** * * @param {T extends Message | CommandInteraction} ctx name of possible command * @returns {Files.CommandVal | undefined} */ private findModuleFrom(ctx: T): Files.CommandVal | undefined { const name = ctx.applicationId === null ? fmt(ctx as Message, this.prefix).shift()! : (ctx as CommandInteraction).commandName; const posCommand = Files.Commands.get(name) ?? Files.Alias.get(name); return posCommand; } /** * * @param {string} cmdName name of command * @param {string} description description of command * @param {ApplicationCommandOptionData[]} options any options for the slash command */ private async reloadSlash( cmdName: string, description: string, options: ApplicationCommandOptionData[], ): Promise { for (const { id } of this.privateServers) { const guild = await this.client.guilds.fetch(id); guild.commands.create({ name: cmdName, description, options, }); } } /** * @readonly * @returns {string} The prefix used for legacy commands */ get prefix(): string { return this.wrapper.prefix; } /** * @readonly * @returns {string} Directory of the commands folder */ get commandDir(): string { return this.wrapper.commands; } /** * @readonly * @returns {Client} the discord.js client (DiscordJS#Client)); */ get client(): Client { return this.wrapper.client; } /** * @readonly * @returns {{test: boolean, id: string}[]} Private server ID for testing or personal use */ get privateServers(): { test: boolean; id: string }[] { return this.wrapper.privateServers; } } /** * An object to be passed into Sern.Handler constructor. * @typedef {object} Wrapper * @property {readonly Client} client * @property {readonly string} prefix * @property {readonly string} commands * @prop {(handler : Handler) => void)} init * @property {readonly {test: boolean, id: string}[]} privateServers */ export interface Wrapper { readonly client: Client; readonly prefix: string; readonly commands: string; init?: (handler: Handler) => void; readonly privateServers: { test: boolean; id: string }[]; } /** * An object that gets imported and acts as a command. * @typedef {object} Module * @property {string} desc * @property {Visibility} visibility * @property {CommandType} type * @property {(eventParams : Context, args : Ok) => Awaitable)} execute * @prop {(ctx: Context, args: Arg) => Utils.ArgType} parse */ export interface Module { alias: string[]; desc: string; visibility: Visibility; type: CommandType; test: boolean; execute: (eventParams: Context, args: Ok) => Awaitable; parse?: (ctx: Context, args: Arg) => Utils.ArgType; } /** * @enum { number }; */ export enum CommandType { TEXT = 1, SLASH = 2, }