UNPKG

@koishijs/core

Version:

Core Features for Koishi

325 lines (277 loc) 10.5 kB
import { coerce, makeArray, Random } from '@koishijs/utils' import { Awaitable, defineProperty, Dict, Time } from 'cosmokit' import { EventOptions, Fragment, h, Hook } from '@satorijs/core' import { Session } from './session' import { Context } from './context' import { Channel, User } from './database' declare module './context' { interface Context { $processor: Processor middleware<S extends Session = Session>(middleware: Middleware<S>, prepend?: boolean): () => boolean match(pattern: string | RegExp, response: Fragment, options?: Matcher.Options & { i18n?: false }): () => boolean match(pattern: string, response: string, options: Matcher.Options & { i18n: true }): () => boolean } interface Events { 'before-attach-channel'(session: Session, fields: Set<Channel.Field>): void 'attach-channel'(session: Session): Awaitable<void | boolean> 'before-attach-user'(session: Session, fields: Set<User.Field>): void 'attach-user'(session: Session): Awaitable<void | boolean> 'before-attach'(session: Session): void 'attach'(session: Session): void 'middleware'(session: Session): void } } export class SessionError extends Error { constructor(public path: string | string[], public param?: Dict) { super(makeArray(path)[0]) } } export type Next = (next?: Next.Callback) => Promise<void | Fragment> export type Middleware<S extends Session = Session> = (session: S, next: Next) => Awaitable<void | Fragment> export namespace Next { export const MAX_DEPTH = 64 export type Queue = ((next?: Next) => Awaitable<void | Fragment>)[] export type Callback = void | string | ((next?: Next) => Awaitable<void | Fragment>) export async function compose(callback: Callback, next?: Next) { return typeof callback === 'function' ? callback(next) : callback } } export interface Matcher extends Matcher.Options { context: Context pattern: string | RegExp response: Matcher.Response } export namespace Matcher { export type Response = Fragment | ((session: Session, params: [string, ...string[]]) => Awaitable<Fragment>) export interface Options { i18n?: boolean appel?: boolean fuzzy?: boolean regex?: boolean } } export class Processor { _hooks: Hook[] = [] _sessions: Dict<Session> = Object.create(null) _userCache = new SharedCache<User.Observed<keyof User>>() _channelCache = new SharedCache<Channel.Observed<keyof Channel>>() _matchers = new Set<Matcher>() constructor(private ctx: Context) { defineProperty(this, Context.current, ctx) // bind built-in event listeners this.middleware(this.attach.bind(this), true) ctx.on('message', this._handleMessage.bind(this)) ctx.before('attach-user', (session, fields) => { session.collect('user', session.argv, fields) }) ctx.before('attach-channel', (session, fields) => { session.collect('channel', session.argv, fields) }) ctx.component('execute', async (attrs, children, session) => { return session.execute(children.join(''), true) }, { session: true }) ctx.component('prompt', async (attrs, children, session) => { await session.send(children) return session.prompt() }, { session: true }) ctx.component('i18n', async (attrs, children, session) => { return session.i18n(attrs.path, children) }, { session: true }) ctx.component('random', async (attrs, children) => { return Random.pick(children) }) ctx.component('plural', async (attrs, children) => { const path = attrs.count in children ? attrs.count : children.length - 1 return children[path] }) const units = ['day', 'hour', 'minute', 'second'] as const ctx.component('i18n:time', (attrs, children, session) => { let ms = +attrs.value for (let index = 0; index < 3; index++) { const major = Time[units[index]] const minor = Time[units[index + 1]] if (ms >= major - minor / 2) { ms += minor / 2 let result = Math.floor(ms / major) + ' ' + session.text('general.' + units[index]) if (ms % major > minor) { result += ` ${Math.floor(ms % major / minor)} ` + session.text('general.' + units[index + 1]) } return result } } return Math.round(ms / Time.second) + ' ' + session.text('general.second') }, { session: true }) ctx.before('attach', (session) => { for (const matcher of this._matchers) { this._executeMatcher(session, matcher) if (session.response) return } }) } middleware(middleware: Middleware, options?: boolean | EventOptions) { if (typeof options !== 'object') { options = { prepend: options } } return this.ctx.lifecycle.register('middleware', this._hooks, middleware, options) } match(pattern: string | RegExp, response: Matcher.Response, options: Matcher.Options) { const matcher: Matcher = { ...options, context: this.ctx, pattern, response } this._matchers.add(matcher) return this.ctx.collect('shortcut', () => { return this._matchers.delete(matcher) }) } private _executeMatcher(session: Session, matcher: Matcher) { const { stripped, quote } = session const { appel, context, i18n, regex, fuzzy, pattern, response } = matcher if ((appel || stripped.hasAt) && !stripped.appel) return if (!context.filter(session)) return let content = stripped.content if (quote?.content) content += ' ' + quote.content let params: [string, ...string[]] = null const match = (pattern: any) => { if (!pattern) return if (typeof pattern === 'string') { if (!fuzzy && content !== pattern || !content.startsWith(pattern)) return params = [content, content.slice(pattern.length)] if (fuzzy && !stripped.appel && params[1].match(/^\S/)) { params = null } } else { params = pattern.exec(content) } } if (!i18n) { match(pattern) } else { for (const locale of this.ctx.i18n.fallback([])) { const store = this.ctx.i18n._data[locale] let value = store?.[pattern as string] as string | RegExp if (!value) continue if (regex) { const rest = fuzzy ? `(?:${stripped.appel ? '' : '\\s+'}([\\s\\S]*))?` : '' value = new RegExp(`^(?:${value})${rest}$`) } match(value) if (!params) continue session.locales = [locale] break } } if (!params) return session.response = async () => { const output = await session.resolve(response, params) return h.normalize(output, params.map(source => source ? h.parse(source) : '')) } } private async attach(session: Session, next: Next) { this.ctx.emit(session, 'before-attach', session) if (this.ctx.database) { if (!session.isDirect) { // attach group data const channelFields = new Set<Channel.Field>(['flag', 'assignee', 'guildId', 'permissions', 'locales']) this.ctx.emit('before-attach-channel', session, channelFields) const channel = await session.observeChannel(channelFields) // for backwards compatibility channel.guildId = session.guildId // emit attach event if (await this.ctx.serial(session, 'attach-channel', session)) return // ignore some group calls if (channel.flag & Channel.Flag.ignore) return if (channel.assignee !== session.selfId && !session.stripped.atSelf) return } // attach user data // authority is for suggestion const userFields = new Set<User.Field>(['id', 'flag', 'authority', 'permissions', 'locales']) this.ctx.emit('before-attach-user', session, userFields) const user = await session.observeUser(userFields) // emit attach event if (await this.ctx.serial(session, 'attach-user', session)) return // ignore some user calls if (user.flag & User.Flag.ignore) return } this.ctx.emit(session, 'attach', session) if (session.response) return session.response() return next() } private async _handleMessage(session: Session) { // ignore self messages if (session.selfId === session.userId) return // preparation this._sessions[session.id] = session const queue: Next.Queue = this.ctx.lifecycle .filterHooks(this._hooks, session) .map(({ callback }) => callback.bind(null, session)) // execute middlewares let index = 0 const next: Next = async (callback) => { try { if (!this._sessions[session.id]) { throw new Error('isolated next function detected') } 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 await queue[index++]?.(next) } catch (error) { if (error instanceof SessionError) { return session.text(error.path, error.param) } const stack = coerce(error) this.ctx.logger('session').warn(`${session.content}\n${stack}`) } } try { const result = await next() if (result) await session.send(result) } finally { // update session map delete this._sessions[session.id] this._userCache.delete(session.id) this._channelCache.delete(session.id) // flush user & group data await session.user?.$update() await session.channel?.$update() await session.guild?.$update() this.ctx.emit(session, 'middleware', session) } } } export namespace SharedCache { export interface Entry<T> { value: T key: string refs: Set<number> } } export class SharedCache<T> { #keyMap = new Map<string, SharedCache.Entry<T>>() get(ref: number, key: string) { const entry = this.#keyMap.get(key) if (!entry) return entry.refs.add(ref) return entry.value } set(ref: number, key: string, value: T) { let entry = this.#keyMap.get(key) if (entry) { entry.value = value } else { entry = { value, key, refs: new Set() } this.#keyMap.set(key, entry) } entry.refs.add(ref) } delete(ref: number) { for (const key of [...this.#keyMap.keys()]) { const { refs } = this.#keyMap.get(key) refs.delete(ref) if (!refs.size) { this.#keyMap.delete(key) } } } }