@koishijs/core
Version:
Core Features for Koishi
391 lines (343 loc) • 13 kB
text/typescript
import { Awaitable, camelize, Dict, isNullable, remove } from 'cosmokit'
import { coerce } from '@koishijs/utils'
import { Fragment, Logger, Schema, Universal } from '@satorijs/core'
import { Argv } from './parser'
import { Next, SessionError } from '../middleware'
import { Channel, User } from '../database'
import { FieldCollector, Session } from '../session'
import { Permissions } from '../permission'
import { Context } from '../context'
import { Computed } from '../filter'
const logger = new Logger('command')
export type Extend<O extends {}, K extends string, T> = {
[P in K | keyof O]?: (P extends keyof O ? O[P] : unknown) & (P extends K ? T : unknown)
}
export namespace Command {
export interface Alias {
options?: Dict
args?: string[]
filter?: Computed<boolean>
}
export interface Shortcut {
i18n?: boolean
name?: string | RegExp
command?: Command
prefix?: boolean
fuzzy?: boolean
args?: string[]
options?: Dict
}
export type Action<U extends User.Field = never, G extends Channel.Field = never, A extends any[] = any[], O extends {} = {}>
= (argv: Argv<U, G, A, O>, ...args: A) => Awaitable<void | Fragment>
export type Usage<U extends User.Field = never, G extends Channel.Field = never>
= string | ((session: Session<U, G>) => Awaitable<string>)
}
export class Command<
U extends User.Field = never,
G extends Channel.Field = never,
A extends any[] = any[],
O extends {} = {},
> extends Argv.CommandBase<Command.Config> {
children: Command[] = []
_parent: Command = null
_aliases: Dict<Command.Alias> = Object.create(null)
_examples: string[] = []
_usage?: Command.Usage
private _userFields: FieldCollector<'user'>[] = [['locales']]
private _channelFields: FieldCollector<'channel'>[] = [['locales']]
private _actions: Command.Action[] = []
private _checkers: Command.Action[] = [async (argv) => {
return this.ctx.serial(argv.session, 'command/before-execute', argv)
}]
constructor(name: string, decl: string, ctx: Context, config: Command.Config) {
super(name, decl, ctx, {
showWarning: true,
handleError: true,
slash: true,
...config,
})
this.config.permissions ??= [`authority:${config?.authority ?? 1}`]
this._registerAlias(name)
ctx.$commander._commandList.push(this)
}
get caller(): Context {
return this[Context.current] || this.ctx
}
get displayName() {
return Object.keys(this._aliases)[0]
}
set displayName(name) {
this._registerAlias(name, true)
}
get parent() {
return this._parent
}
set parent(parent: Command) {
if (this._parent === parent) return
if (this._parent) {
remove(this._parent.children, this)
}
this._parent = parent
if (parent) {
parent.children.push(this)
}
}
static normalize(name: string) {
return name.toLowerCase().replace(/_/g, '-')
}
private _registerAlias(name: string, prepend = false, options: Command.Alias = {}) {
name = Command.normalize(name)
if (name.startsWith('.')) name = this.parent.name + name
// check global
const previous = this.ctx.$commander.get(name)
if (previous && previous !== this) {
throw new Error(`duplicate command names: "${name}"`)
}
// add to list
const existing = this._aliases[name]
if (existing) {
if (prepend) {
this._aliases = { [name]: existing, ...this._aliases }
}
} else if (prepend) {
this._aliases = { [name]: options, ...this._aliases }
} else {
this._aliases[name] = options
}
}
[Symbol.for('nodejs.util.inspect.custom')]() {
return `Command <${this.name}>`
}
userFields<T extends User.Field>(fields: FieldCollector<'user', T, A, O>): Command<U | T, G, A, O> {
this._userFields.push(fields)
return this as any
}
channelFields<T extends Channel.Field>(fields: FieldCollector<'channel', T, A, O>): Command<U, G | T, A, O> {
this._channelFields.push(fields)
return this as any
}
alias(...names: string[]): this
alias(name: string, options: Command.Alias): this
alias(...args: any[]) {
if (typeof args[1] === 'object') {
this._registerAlias(args[0], false, args[1])
} else {
for (const name of args) {
this._registerAlias(name)
}
}
this.caller.emit('command-updated', this)
return this
}
_escape(source: any) {
if (typeof source !== 'string') return source
return source
.replace(/\$\$/g, '@@__PLACEHOLDER__@@')
.replace(/\$\d/g, s => `{${s[1]}}`)
.replace(/@@@/g, '$')
}
/** @deprecated please use `cmd.alias()` instead */
shortcut(pattern: string | RegExp, config?: Command.Shortcut & { i18n?: false }): this
/** @deprecated please use `cmd.alias()` instead */
shortcut(pattern: string, config: Command.Shortcut & { i18n: true }): this
shortcut(pattern: string | RegExp, config: Command.Shortcut = {}) {
let content = this.displayName
for (const key in config.options || {}) {
content += ` --${camelize(key)}`
const value = config.options[key]
if (value !== true) {
content += ' ' + this._escape(value)
}
}
for (const arg of config.args || []) {
content += ' ' + this._escape(arg)
}
if (config.fuzzy) content += ' {1}'
const regex = config.i18n
if (typeof pattern === 'string') {
if (config.i18n) {
pattern = `commands.${this.name}.shortcuts.${pattern}`
} else {
config.i18n = true
const key = `commands.${this.name}.shortcuts._${Math.random().toString(36).slice(2)}`
this.ctx.i18n.define('', key, pattern)
pattern = key
}
}
const dispose = this.ctx.match(pattern, `<execute>${content}</execute>`, {
appel: config.prefix,
fuzzy: config.fuzzy,
i18n: config.i18n as never,
regex,
})
this._disposables.push(dispose)
return this
}
subcommand<D extends string>(def: D, config?: Command.Config): Command<never, never, Argv.ArgumentType<D>>
subcommand<D extends string>(def: D, desc: string, config?: Command.Config): Command<never, never, Argv.ArgumentType<D>>
subcommand(def: string, ...args: any[]) {
def = this.name + (def.charCodeAt(0) === 46 ? '' : '/') + def
const desc = typeof args[0] === 'string' ? args.shift() as string : ''
const config = args[0] as Command.Config || {}
return this.ctx.command(def, desc, config)
}
usage(text: Command.Usage<U, G>) {
this._usage = text
return this
}
example(example: string) {
this._examples.push(example)
return this
}
option<K extends string>(name: K, desc: string, config: Argv.TypedOptionConfig<RegExp>): Command<U, G, A, Extend<O, K, string>>
option<K extends string, R>(name: K, desc: string, config: Argv.TypedOptionConfig<(source: string) => R>): Command<U, G, A, Extend<O, K, R>>
option<K extends string, R extends string>(name: K, desc: string, config: Argv.TypedOptionConfig<R[]>): Command<U, G, A, Extend<O, K, R>>
option<K extends string, D extends string>(name: K, desc: D, config?: Argv.OptionConfig): Command<U, G, A, Extend<O, K, Argv.OptionType<D>>>
option(name: string, ...args: [Argv.OptionConfig?] | [string, Argv.OptionConfig?]) {
let desc = ''
if (typeof args[0] === 'string') {
desc = args.shift() as string
}
const config = { ...args[0] as Argv.OptionConfig }
config.permissions ??= [`authority:${config.authority ?? 0}`]
this._createOption(name, desc, config)
this.caller.emit('command-updated', this)
this.caller.collect('option', () => this.removeOption(name))
return this
}
match(session: Session) {
return this.ctx.filter(session)
}
check(callback: Command.Action<U, G, A, O>, append = false) {
return this.before(callback, append)
}
before(callback: Command.Action<U, G, A, O>, append = false) {
if (append) {
this._checkers.push(callback)
} else {
this._checkers.unshift(callback)
}
this.caller.scope.disposables?.push(() => remove(this._checkers, callback))
return this
}
action(callback: Command.Action<U, G, A, O>, prepend = false) {
if (prepend) {
this._actions.unshift(callback)
} else {
this._actions.push(callback)
}
this.caller.scope.disposables?.push(() => remove(this._actions, callback))
return this
}
/** @deprecated */
use<T extends Command, R extends any[]>(callback: (command: this, ...args: R) => T, ...args: R): T {
return callback(this, ...args)
}
async execute(argv: Argv<U, G, A, O>, fallback: Next = Next.compose): Promise<Fragment> {
argv.command ??= this
argv.args ??= [] as any
argv.options ??= {} as any
const { args, options, error } = argv
if (error) return error
if (logger.level >= 3) logger.debug(argv.source ||= this.stringify(args, options))
// before hooks
for (const validator of this._checkers) {
const result = await validator.call(this, argv, ...args)
if (!isNullable(result)) return result
}
// FIXME empty actions will cause infinite loop
if (!this._actions.length) return ''
let index = 0
const queue: Next.Queue = this._actions.map(action => async () => {
return await action.call(this, argv, ...args)
})
queue.push(fallback)
const length = queue.length
argv.next = async (callback) => {
if (callback !== undefined) {
queue.push(next => Next.compose(callback, next))
if (queue.length > Next.MAX_DEPTH) {
throw new Error(`middleware stack exceeded ${Next.MAX_DEPTH}`)
}
}
return queue[index++]?.(argv.next)
}
try {
const result = await argv.next()
if (!isNullable(result)) return result
} catch (error) {
if (index === length) throw error
if (error instanceof SessionError) {
return argv.session.text(error.path, error.param)
}
const stack = coerce(error)
logger.warn(`${argv.source ||= this.stringify(args, options)}\n${stack}`)
this.ctx.emit(argv.session, 'command-error', argv, error)
if (typeof this.config.handleError === 'function') {
const result = await this.config.handleError(error, argv)
if (!isNullable(result)) return result
} else if (this.config.handleError) {
return argv.session.text('internal.error-encountered')
}
}
return ''
}
dispose() {
this._disposables.splice(0).forEach(dispose => dispose())
this.ctx.emit('command-removed', this)
for (const cmd of this.children.slice()) {
cmd.dispose()
}
remove(this.ctx.$commander._commandList, this)
this.parent = null
}
toJSON(): Universal.Command {
return {
name: this.name,
description: this.ctx.i18n.get(`commands.${this.name}.description`),
arguments: this._arguments.map(arg => ({
name: arg.name,
type: toStringType(arg.type),
description: this.ctx.i18n.get(`commands.${this.name}.arguments.${arg.name}`),
required: arg.required,
})),
options: Object.entries(this._options).map(([name, option]) => ({
name,
type: toStringType(option.type),
description: this.ctx.i18n.get(`commands.${this.name}.options.${name}`),
required: option.required,
})),
children: this.children
.filter(child => child.name.includes('.'))
.map(child => child.toJSON()),
}
}
}
function toStringType(type: Argv.Type) {
return typeof type === 'string' ? type : 'string'
}
export namespace Command {
export interface Config extends Argv.CommandBase.Config, Permissions.Config {
captureQuote?: boolean
/** disallow unknown options */
checkUnknown?: boolean
/** check argument count */
checkArgCount?: boolean
/** show command warnings */
showWarning?: boolean
/** handle error */
handleError?: boolean | ((error: Error, argv: Argv) => Awaitable<void | Fragment>)
/** enable slash command */
slash?: boolean
}
export const Config: Schema<Config> = Schema.object({
permissions: Schema.array(String).role('perms').default(['authority:1']).description('权限继承。'),
dependencies: Schema.array(String).role('perms').description('权限依赖。'),
slash: Schema.boolean().description('启用斜线指令功能。').default(true),
captureQuote: Schema.boolean().description('是否捕获引用文本。').default(true).hidden(),
checkUnknown: Schema.boolean().description('是否检查未知选项。').default(false).hidden(),
checkArgCount: Schema.boolean().description('是否检查参数数量。').default(false).hidden(),
showWarning: Schema.boolean().description('是否显示警告。').default(true).hidden(),
handleError: Schema.union([Schema.boolean(), Schema.function()]).description('是否处理错误。').default(true).hidden(),
})
}