UNPKG

@koishijs/core

Version:

Core Features for Koishi

452 lines (404 loc) 16.2 kB
import { Awaitable, defineProperty, Time } from 'cosmokit' import { Bot, Fragment, h, Schema, Universal } from '@satorijs/core' import { Command } from './command' import { Argv } from './parser' import validate from './validate' import { Channel, User } from '../database' import { Computed } from '../filter' import { Context } from '../context' import { Session } from '../session' export * from './command' export * from './parser' export * from './validate' declare module '../context' { interface Context { $commander: Commander command<D extends string>(def: D, config?: Command.Config): Command<never, never, Argv.ArgumentType<D>> command<D extends string>(def: D, desc: string, config?: Command.Config): Command<never, never, Argv.ArgumentType<D>> } interface Events { 'before-parse'(content: string, session: Session): Argv 'command-added'(command: Command): void 'command-updated'(command: Command): void 'command-removed'(command: Command): void 'command-error'(argv: Argv, error: any): void 'command/before-execute'(argv: Argv): Awaitable<void | Fragment> 'command/before-attach-channel'(argv: Argv, fields: Set<Channel.Field>): void 'command/before-attach-user'(argv: Argv, fields: Set<User.Field>): void } } // https://github.com/microsoft/TypeScript/issues/17002 // it never got fixed so we have to do this const isArray = Array.isArray as (arg: any) => arg is readonly any[] const BRACKET_REGEXP = /<[^>]+>|\[[^\]]+\]/g interface DeclarationList extends Array<Argv.Declaration> { stripped: string } export namespace Commander { export interface Config { prefix?: Computed<string | string[]> prefixMode?: 'auto' | 'strict' } } export class Commander { _commandList: Command[] = [] constructor(private ctx: Context, private config: Commander.Config = {}) { defineProperty(this, Context.current, ctx) ctx.plugin(validate) ctx.before('parse', (content, session) => { // we need to make sure that the user truly has the intension to call a command const { isDirect, stripped: { prefix, appel } } = session if (!isDirect && typeof prefix !== 'string' && !appel) return return Argv.parse(content) }) ctx.on('interaction/command', (session) => { if (session.event?.argv) { const { name, options, arguments: args } = session.event.argv session.execute({ name, args, options }) } else { session.stripped.hasAt = true session.stripped.appel = true session.stripped.atSelf = true session.stripped.prefix = '' defineProperty(session, 'argv', ctx.bail('before-parse', session.content, session)) if (!session.argv) { ctx.logger('command').warn('failed to parse interaction command:', session.content) return } session.argv.root = true session.argv.session = session session.execute(session.argv) } }) ctx.before('attach', (session) => { const { hasAt, appel } = session.stripped if (!appel && hasAt) return // strip prefix let content = session.stripped.content for (const prefix of this._resolvePrefixes(session)) { if (!content.startsWith(prefix)) continue session.stripped.prefix = prefix content = content.slice(prefix.length) break } defineProperty(session, 'argv', ctx.bail('before-parse', content, session)) if (!session.argv) return session.argv.root = true session.argv.session = session }) ctx.middleware((session, next) => { // execute command if (!this.resolveCommand(session.argv)) return next() return session.execute(session.argv, next) }) ctx.middleware((session, next) => { // use `!prefix` instead of `prefix === null` to prevent from blocking other middlewares // we need to make sure that the user truly has the intension to call a command const { argv, quote, isDirect, stripped: { prefix, appel } } = session if (argv?.command || !isDirect && !prefix && !appel) return next() const content = session.stripped.content.slice((prefix ?? '').length) const actual = content.split(/\s/, 1)[0].toLowerCase() if (!actual) return next() return next(async (next) => { const cache = new Map<string, Promise<boolean>>() const name = await session.suggest({ actual, expect: this.available(session), suffix: session.text('internal.suggest-command'), filter: (name) => { const command = this.resolve(name, session) if (!command) return false return ctx.permissions.test(`command:${command.name}`, session, cache) }, }) if (!name) return next() const message = name + content.slice(actual.length) + (quote?.content ? ' ' + quote.content : '') return session.execute(message, next) }) }) ctx.schema.extend('command', Command.Config, 1000) ctx.schema.extend('command-option', Schema.object({ permissions: Schema.array(String).role('perms').default(['authority:0']).description('权限继承。'), dependencies: Schema.array(String).role('perms').description('权限依赖。'), }), 1000) ctx.on('ready', () => { const bots = ctx.bots.filter(v => v.status === Universal.Status.ONLINE && v.updateCommands) bots.forEach(bot => this.updateCommands(bot)) }) ctx.on('bot-status-updated', async (bot) => { if (bot.status !== Universal.Status.ONLINE || !bot.updateCommands) return this.updateCommands(bot) }) this.domain('el', source => h.parse(source), { greedy: true }) this.domain('elements', source => h.parse(source), { greedy: true }) this.domain('string', source => h.unescape(source)) this.domain('text', source => h.unescape(source), { greedy: true }) this.domain('rawtext', source => h('', h.parse(source)).toString(true), { greedy: true }) this.domain('boolean', () => true) this.domain('number', (source, session) => { // support `,` and `_` as delimiters // https://github.com/koishijs/koishi/issues/1386 const value = +source.replace(/[,_]/g, '') if (Number.isFinite(value)) return value throw new Error('internal.invalid-number') }, { numeric: true }) this.domain('integer', (source, session) => { const value = +source.replace(/[,_]/g, '') if (value * 0 === 0 && Math.floor(value) === value) return value throw new Error('internal.invalid-integer') }, { numeric: true }) this.domain('posint', (source, session) => { const value = +source.replace(/[,_]/g, '') if (value * 0 === 0 && Math.floor(value) === value && value > 0) return value throw new Error('internal.invalid-posint') }, { numeric: true }) this.domain('natural', (source, session) => { const value = +source.replace(/[,_]/g, '') if (value * 0 === 0 && Math.floor(value) === value && value >= 0) return value throw new Error('internal.invalid-natural') }, { numeric: true }) this.domain('bigint', (source, session) => { try { return BigInt(source.replace(/[,_]/g, '')) } catch { throw new Error('internal.invalid-integer') } }, { numeric: true }) this.domain('date', (source, session) => { const timestamp = Time.parseDate(source) if (+timestamp) return timestamp throw new Error('internal.invalid-date') }) this.domain('user', (source, session) => { if (source.startsWith('@')) { source = source.slice(1) if (source.includes(':')) return source return `${session.platform}:${source}` } const code = h.from(source) if (code && code.type === 'at') { return `${session.platform}:${code.attrs.id}` } throw new Error('internal.invalid-user') }) this.domain('channel', (source, session) => { if (source.startsWith('#')) { source = source.slice(1) if (source.includes(':')) return source return `${session.platform}:${source}` } const code = h.from(source) if (code && code.type === 'sharp') { return `${session.platform}:${code.attrs.id}` } throw new Error('internal.invalid-channel') }) this.defineElementDomain('image', 'image', 'img') this.defineElementDomain('img', 'image', 'img') this.defineElementDomain('audio') this.defineElementDomain('video') this.defineElementDomain('file') } private defineElementDomain(name: keyof Argv.Domain, key = name, type = name) { this.domain(name, (source, session) => { const code = h.from(source, { type }) if (code && code.type === type) { return code.attrs } throw new Error(`internal.invalid-${key}`) }) } get(name: string, session?: Session) { return this._commandList.find((cmd) => { const alias = cmd._aliases[name] return alias && (session?.resolve(alias.filter) ?? true) }) } updateCommands(bot: Bot) { return bot.updateCommands(this._commandList .filter(cmd => !cmd.name.includes('.') && cmd.config.slash) .map(cmd => cmd.toJSON())) } private _resolvePrefixes(session: Session) { const value = session.resolve(this.config.prefix) const result = Array.isArray(value) ? value : [value || ''] return result.map(source => h.escape(source)).sort().reverse() } available(session: Session) { return this._commandList .filter(cmd => cmd.match(session)) .flatMap(cmd => Object.keys(cmd._aliases)) } resolve(key: string, session?: Session) { return this._resolve(key, session).command } _resolve(key: string, session?: Session) { if (!key) return {} const segments = Command.normalize(key).split('.') let i = 1, name = segments[0], command: Command while ((command = this.get(name, session)) && i < segments.length) { name = command.name + '.' + segments[i++] } return { command, name } } inferCommand(argv: Argv) { if (!argv) return if (argv.command) return argv.command if (argv.name) return argv.command = this.resolve(argv.name, argv.session) const { stripped, isDirect, quote } = argv.session // guild message should have prefix or appel to be interpreted as a command call const isStrict = this.config.prefixMode === 'strict' || !isDirect && !stripped.appel if (argv.root && stripped.prefix === null && isStrict) return const segments: string[] = [] while (argv.tokens.length) { const { content } = argv.tokens[0] segments.push(content) const { name, command } = this._resolve(segments.join('.'), argv.session) if (!command) break argv.tokens.shift() argv.command = command argv.args = command._aliases[name].args argv.options = command._aliases[name].options if (command._arguments.length) break } // https://github.com/koishijs/koishi/issues/1432 // https://github.com/koishijs/koishi/issues/1441 if (argv.root && argv.command?.config.captureQuote !== false && quote?.content) { argv.tokens.push({ content: quote.content, quoted: true, inters: [], terminator: '', }) } return argv.command } resolveCommand(argv: Argv) { if (!this.inferCommand(argv)) return if (argv.tokens?.every(token => !token.inters.length)) { const { options, args, error } = argv.command.parse(argv) argv.options = options argv.args = args argv.error = error } return argv.command } command(def: string, ...args: [Command.Config?] | [string, Command.Config?]) { const desc = typeof args[0] === 'string' ? args.shift() as string : '' const config = args[0] as Command.Config const path = Command.normalize(def.split(' ', 1)[0]) const decl = def.slice(path.length) const segments = path.split(/(?=[./])/g) /** parent command in the chain */ let parent: Command /** the first created command */ let root: Command const created: Command[] = [] segments.forEach((segment, index) => { const code = segment.charCodeAt(0) const name = code === 46 ? parent.name + segment : code === 47 ? segment.slice(1) : segment let command = this.get(name) if (command) { if (parent) { if (command === parent) { throw new Error(`cannot set a command (${command.name}) as its own subcommand`) } if (command.parent) { if (command.parent !== parent) { throw new Error(`cannot create subcommand ${path}: ${command.parent.name}/${command.name} already exists`) } } else { command.parent = parent } } return parent = command } const isLast = index === segments.length - 1 command = new Command(name, isLast ? decl : '', this.ctx, isLast ? config : {}) command._disposables.push(this.ctx.i18n.define('', { [`commands.${command.name}.$`]: '', [`commands.${command.name}.description`]: isLast ? desc : '', })) created.push(command) root ||= command if (parent) { command.parent = parent } parent = command }) Object.assign(parent.config, config) // Make sure `command.config` is set before emitting any events created.forEach(command => this.ctx.emit('command-added', command)) parent[Context.current] = this.ctx if (root) this.ctx.collect(`command <${root.name}>`, () => root.dispose()) return parent } domain<K extends keyof Argv.Domain>(name: K): Argv.DomainConfig<Argv.Domain[K]> domain<K extends keyof Argv.Domain>(name: K, transform: Argv.Transform<Argv.Domain[K]>, options?: Argv.DomainConfig<Argv.Domain[K]>): () => void domain<K extends keyof Argv.Domain>(name: K, transform?: Argv.Transform<Argv.Domain[K]>, options?: Argv.DomainConfig<Argv.Domain[K]>) { const service = 'domain:' + name if (!transform) return this.ctx.get(service) return this.ctx.set(service, { transform, ...options }) } resolveDomain(type: Argv.Type) { if (typeof type === 'function') { return { transform: type } } else if (type instanceof RegExp) { const transform = (source: string) => { if (type.test(source)) return source throw new Error() } return { transform } } else if (isArray(type)) { const transform = (source: string) => { if (type.includes(source)) return source throw new Error() } return { transform } } else if (typeof type === 'object') { return type ?? {} } return this.ctx.get(`domain:${type}`) ?? {} } parseValue(source: string, kind: string, argv: Argv, decl: Argv.Declaration = {}) { const { name, type = 'string' } = decl // apply domain callback const domain = this.resolveDomain(type) try { return domain.transform(source, argv.session) } catch (err) { if (!argv.session) { argv.error = `internal.invalid-${kind}` } else { const message = argv.session.text(err['message'] || 'internal.check-syntax') argv.error = argv.session.text(`internal.invalid-${kind}`, [name, message]) } } } parseDecl(source: string) { let cap: RegExpExecArray const result = [] as DeclarationList // eslint-disable-next-line no-cond-assign while (cap = BRACKET_REGEXP.exec(source)) { let rawName = cap[0].slice(1, -1) let variadic = false if (rawName.startsWith('...')) { rawName = rawName.slice(3) variadic = true } const [name, rawType] = rawName.split(':') const type = rawType ? rawType.trim() as Argv.DomainType : undefined result.push({ name, variadic, type, required: cap[0][0] === '<', }) } result.stripped = source.replace(/:[\w-]+(?=[>\]])/g, str => { const domain = this.ctx.get(`domain:${str.slice(1)}`) return domain?.greedy ? '...' : '' }).trimEnd() return result } }