@koishijs/core
Version:
Core Features for Koishi
452 lines (404 loc) • 16.2 kB
text/typescript
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
}
}