harmony/src/commands/client.ts

405 lines
13 KiB
TypeScript

import type { Message } from '../structures/message.ts'
import type { GuildTextBasedChannel } from '../structures/guildTextChannel.ts'
import { Client, ClientOptions } from '../client/mod.ts'
import {
CategoriesManager,
Command,
CommandContext,
CommandOptions,
CommandsManager,
parseCommand
} from './command.ts'
import { parseArgs } from '../utils/command.ts'
import { Extension, ExtensionsManager } from './extension.ts'
type PrefixReturnType = string | string[] | Promise<string | string[]>
/** Command Client options extending Client Options to provide a lot of Commands-related customizations */
export interface CommandClientOptions extends ClientOptions {
/** Global prefix(s) of the bot. */
prefix: string | string[]
/** Whether to enable mention prefix or not. */
mentionPrefix?: boolean
/** Method to get a Guild's custom prefix(s). */
getGuildPrefix?: (guildID: string) => PrefixReturnType
/** Method to get a User's custom prefix(s). */
getUserPrefix?: (userID: string) => PrefixReturnType
/** Method to get a Channel's custom prefix(s). */
getChannelPrefix?: (channelID: string) => PrefixReturnType
/** Method to check if certain Guild is blacklisted from using Commands. */
isGuildBlacklisted?: (guildID: string) => boolean | Promise<boolean>
/** Method to check if certain User is blacklisted from using Commands. */
isUserBlacklisted?: (guildID: string) => boolean | Promise<boolean>
/** Method to check if certain Channel is blacklisted from using Commands. */
isChannelBlacklisted?: (guildID: string) => boolean | Promise<boolean>
/** Allow spaces after prefix? Recommended with Mention Prefix ON. */
spacesAfterPrefix?: boolean
/** List of Bot's Owner IDs whom can access `ownerOnly` commands. */
owners?: string[]
/** Whether to allow Bots to use Commands or not, not allowed by default. */
allowBots?: boolean
/** Whether to allow Commands in DMs or not, allowed by default. */
allowDMs?: boolean
/** Whether Commands should be case-sensitive or not, not by default. */
caseSensitive?: boolean
}
/**
* Harmony Client with extended functionality for Message based Commands parsing and handling.
*
* See SlashClient (`Client#slash`) for more info about Slash Commands.
*/
export class CommandClient extends Client implements CommandClientOptions {
prefix: string | string[]
mentionPrefix: boolean
getGuildPrefix: (guildID: string) => PrefixReturnType
getUserPrefix: (userID: string) => PrefixReturnType
getChannelPrefix: (channelID: string) => PrefixReturnType
isGuildBlacklisted: (guildID: string) => boolean | Promise<boolean>
isUserBlacklisted: (guildID: string) => boolean | Promise<boolean>
isChannelBlacklisted: (guildID: string) => boolean | Promise<boolean>
spacesAfterPrefix: boolean
owners: string[]
allowBots: boolean
allowDMs: boolean
caseSensitive: boolean
extensions: ExtensionsManager = new ExtensionsManager(this)
commands: CommandsManager = new CommandsManager(this)
categories: CategoriesManager = new CategoriesManager(this)
constructor(options: CommandClientOptions) {
super(options)
this.prefix = options.prefix
this.mentionPrefix =
options.mentionPrefix === undefined ? false : options.mentionPrefix
this.getGuildPrefix =
options.getGuildPrefix === undefined
? (id: string) => this.prefix
: options.getGuildPrefix
this.getUserPrefix =
options.getUserPrefix === undefined
? (id: string) => this.prefix
: options.getUserPrefix
this.getChannelPrefix =
options.getChannelPrefix === undefined
? (id: string) => this.prefix
: options.getChannelPrefix
this.isUserBlacklisted =
options.isUserBlacklisted === undefined
? (id: string) => false
: options.isUserBlacklisted
this.isGuildBlacklisted =
options.isGuildBlacklisted === undefined
? (id: string) => false
: options.isGuildBlacklisted
this.isChannelBlacklisted =
options.isChannelBlacklisted === undefined
? (id: string) => false
: options.isChannelBlacklisted
this.spacesAfterPrefix =
options.spacesAfterPrefix === undefined
? false
: options.spacesAfterPrefix
this.owners = options.owners === undefined ? [] : options.owners
this.allowBots = options.allowBots === undefined ? false : options.allowBots
this.allowDMs = options.allowDMs === undefined ? true : options.allowDMs
this.caseSensitive =
options.caseSensitive === undefined ? false : options.caseSensitive
const self = this as any
if (self._decoratedCommands !== undefined) {
Object.values(self._decoratedCommands).forEach((entry: any) => {
this.commands.add(entry)
})
self._decoratedCommands = undefined
}
this.on(
'messageCreate',
async (msg: Message) => await this.processMessage(msg)
)
}
/** Processes a Message to Execute Command. */
async processMessage(msg: Message): Promise<any> {
if (!this.allowBots && msg.author.bot === true) return
const isUserBlacklisted = await this.isUserBlacklisted(msg.author.id)
if (isUserBlacklisted) return
const isChannelBlacklisted = await this.isChannelBlacklisted(msg.channel.id)
if (isChannelBlacklisted) return
if (msg.guild !== undefined) {
const isGuildBlacklisted = await this.isGuildBlacklisted(msg.guild.id)
if (isGuildBlacklisted) return
}
let prefix: string | string[] = []
if (typeof this.prefix === 'string') prefix = [...prefix, this.prefix]
else prefix = [...prefix, ...this.prefix]
const userPrefix = await this.getUserPrefix(msg.author.id)
if (userPrefix !== undefined) {
if (typeof userPrefix === 'string') prefix = [...prefix, userPrefix]
else prefix = [...prefix, ...userPrefix]
}
if (msg.guild !== undefined) {
const guildPrefix = await this.getGuildPrefix(msg.guild.id)
if (guildPrefix !== undefined) {
if (typeof guildPrefix === 'string') prefix = [...prefix, guildPrefix]
else prefix = [...prefix, ...guildPrefix]
}
}
prefix = [...new Set(prefix)]
let mentionPrefix = false
let usedPrefix = prefix
.filter((v) => msg.content.startsWith(v))
.sort((b, a) => a.length - b.length)[0]
if (usedPrefix === undefined && this.mentionPrefix) mentionPrefix = true
if (mentionPrefix) {
if (msg.content.startsWith(this.user?.mention as string) === true)
usedPrefix = this.user?.mention as string
else if (
msg.content.startsWith(this.user?.nickMention as string) === true
)
usedPrefix = this.user?.nickMention as string
else return
}
if (typeof usedPrefix !== 'string') return
prefix = usedPrefix
const parsed = parseCommand(this, msg, prefix)
if (parsed === undefined) return
const command = this.commands.fetch(parsed)
if (command === undefined) return
const category =
command.category !== undefined
? this.categories.get(command.category)
: undefined
// Guild whitelist exists, and if does and Command used in a Guild, is this Guild allowed?
// This is a bit confusing here, if these settings on a Command exist, and also do on Category, Command overrides them
if (
command.whitelistedGuilds === undefined &&
category?.whitelistedGuilds !== undefined &&
msg.guild !== undefined &&
category.whitelistedGuilds.includes(msg.guild.id) === false
)
return
if (
command.whitelistedGuilds !== undefined &&
msg.guild !== undefined &&
command.whitelistedGuilds.includes(msg.guild.id) === false
)
return
// Checks for Channel Whitelist
if (
command.whitelistedChannels === undefined &&
category?.whitelistedChannels !== undefined &&
category.whitelistedChannels.includes(msg.channel.id) === false
)
return
if (
command.whitelistedChannels !== undefined &&
command.whitelistedChannels.includes(msg.channel.id) === false
)
return
// Checks for Users Whitelist
if (
command.whitelistedUsers === undefined &&
category?.whitelistedUsers !== undefined &&
category.whitelistedUsers.includes(msg.author.id) === false
)
return
if (
command.whitelistedUsers !== undefined &&
command.whitelistedUsers.includes(msg.author.id) === false
)
return
const ctx: CommandContext = {
client: this,
name: parsed.name,
prefix,
args: parseArgs(command.args, parsed.args),
argString: parsed.argString,
message: msg,
author: msg.author,
command,
channel: msg.channel,
guild: msg.guild
}
// In these checks too, Command overrides Category if present
// Checks if Command is only for Owners
if (
(command.ownerOnly !== undefined || category === undefined
? command.ownerOnly
: category.ownerOnly) === true &&
!this.owners.includes(msg.author.id)
)
return this.emit('commandOwnerOnly', ctx)
// Checks if Command is only for Guild
if (
(command.guildOnly !== undefined || category === undefined
? command.guildOnly
: category.guildOnly) === true &&
msg.guild === undefined
)
return this.emit('commandGuildOnly', ctx)
// Checks if Command is only for DMs
if (
(command.dmOnly !== undefined || category === undefined
? command.dmOnly
: category.dmOnly) === true &&
msg.guild !== undefined
)
return this.emit('commandDmOnly', ctx)
if (
command.nsfw === true &&
(msg.guild === undefined ||
((msg.channel as unknown) as GuildTextBasedChannel).nsfw !== true)
)
return this.emit('commandNSFW', ctx)
const allPermissions =
command.permissions !== undefined
? command.permissions
: category?.permissions
if (
(command.botPermissions !== undefined ||
category?.botPermissions !== undefined ||
allPermissions !== undefined) &&
msg.guild !== undefined
) {
// TODO: Check Overwrites too
const me = await msg.guild.me()
const missing: string[] = []
let permissions =
command.botPermissions === undefined
? category?.permissions
: command.botPermissions
if (permissions !== undefined) {
if (typeof permissions === 'string') permissions = [permissions]
if (allPermissions !== undefined)
permissions = [...new Set(...permissions, ...allPermissions)]
for (const perm of permissions) {
if (me.permissions.has(perm) === false) missing.push(perm)
}
if (missing.length !== 0)
return this.emit('commandBotMissingPermissions', ctx, missing)
}
}
if (
(command.userPermissions !== undefined ||
category?.userPermissions !== undefined ||
allPermissions !== undefined) &&
msg.guild !== undefined
) {
let permissions =
command.userPermissions !== undefined
? command.userPermissions
: category?.userPermissions
if (permissions !== undefined) {
if (typeof permissions === 'string') permissions = [permissions]
if (allPermissions !== undefined)
permissions = [...new Set(...permissions, ...allPermissions)]
const missing: string[] = []
for (const perm of permissions) {
const has = msg.member?.permissions.has(perm)
if (has !== true) missing.push(perm)
}
if (missing.length !== 0)
return this.emit('commandUserMissingPermissions', ctx, missing)
}
}
if (command.args !== undefined) {
if (typeof command.args === 'boolean' && parsed.args.length === 0)
return this.emit('commandMissingArgs', ctx)
else if (
typeof command.args === 'number' &&
parsed.args.length < command.args
)
this.emit('commandMissingArgs', ctx)
}
try {
this.emit('commandUsed', ctx)
const beforeExecute = await command.beforeExecute(ctx)
if (beforeExecute === false) return
const result = await command.execute(ctx)
await command.afterExecute(ctx, result)
} catch (e) {
try {
await command.onError(ctx, e)
} catch (e) {
this.emit('commandError', ctx, e)
}
this.emit('commandError', ctx, e)
}
}
}
/**
* Command decorator. Decorates the function with optional metadata as a Command registered upon constructing class.
*/
export function command(options?: CommandOptions) {
return function (target: CommandClient | Extension, name: string) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const c = target as any
if (c._decoratedCommands === undefined) c._decoratedCommands = {}
const prop = c[name]
if (typeof prop !== 'function')
throw new Error('@command decorator can only be used on class methods')
const command = new Command()
command.name = name
command.execute = prop
if (options !== undefined) Object.assign(command, options)
if (target instanceof Extension) command.extension = target
c._decoratedCommands[command.name] = command
}
}