From a1e75c082d97fbc36d21d96077d425143767994f Mon Sep 17 00:00:00 2001 From: Shigma <1700011071@pku.edu.cn> Date: Thu, 27 Aug 2020 16:46:25 +0800 Subject: [PATCH] eval: support user setter --- packages/plugin-eval-addons/src/index.ts | 21 ++-- packages/plugin-eval-addons/src/worker.ts | 9 +- packages/plugin-eval/src/index.ts | 119 +++++++++------------- packages/plugin-eval/src/internal.ts | 12 +-- packages/plugin-eval/src/main.ts | 89 ++++++++++++++++ packages/plugin-eval/src/trap.ts | 31 ------ packages/plugin-eval/src/worker.ts | 25 +++-- 7 files changed, 171 insertions(+), 135 deletions(-) create mode 100644 packages/plugin-eval/src/main.ts delete mode 100644 packages/plugin-eval/src/trap.ts diff --git a/packages/plugin-eval-addons/src/index.ts b/packages/plugin-eval-addons/src/index.ts index dd887e8819..a5d726b312 100644 --- a/packages/plugin-eval-addons/src/index.ts +++ b/packages/plugin-eval-addons/src/index.ts @@ -93,8 +93,6 @@ export function apply(ctx: Context, config: Config) { const { commands = [] } = manifest commands.forEach((config) => { const { name: rawName, desc, options = [], userFields = [] } = config - const { readable = [] } = Array.isArray(userFields) ? { readable: userFields } : userFields - const [name] = rawName.split(' ', 1) if (!response.commands.includes(name)) { return logger.warn('unregistered command manifest: %c', name) @@ -102,16 +100,15 @@ export function apply(ctx: Context, config: Config) { const cmd = addon .subcommand(rawName, desc, config) - .option('debug', '启用调试模式', { type: 'boolean' }) - .action(async ({ session, command, options }, ...args) => { - const { $app, $user, $uuid } = session - const { name } = command - const user = UserTrap.get($user, readable) - const result = await $app.evalRemote.callAddon($uuid, user, { name, args, options }) - return result - }) - - UserTrap.prepare(cmd, readable) + .option('debug', '启用调试模式', { type: 'boolean', hidden: true }) + + UserTrap.attach(cmd, userFields, async ({ session, command, options, user, writable }, ...args) => { + const { $app, $uuid } = session + const { name } = command + const result = await $app.evalRemote.callAddon($uuid, user, writable, { name, args, options }) + return result + }) + options.forEach((config) => { const { name, desc } = config cmd.option(name, desc, config) diff --git a/packages/plugin-eval-addons/src/worker.ts b/packages/plugin-eval-addons/src/worker.ts index fdcc660a06..de7b1ad401 100644 --- a/packages/plugin-eval-addons/src/worker.ts +++ b/packages/plugin-eval-addons/src/worker.ts @@ -20,7 +20,7 @@ declare module 'koishi-plugin-eval/dist/worker' { } interface WorkerAPI { - callAddon(sid: string, user: Partial, argv: AddonArgv): Promise + callAddon(sid: string, user: Partial, writable: User.Field[], argv: AddonArgv): Promise } interface Response { @@ -39,10 +39,13 @@ interface AddonContext extends AddonArgv, Context {} type AddonAction = (ctx: AddonContext) => string | void | Promise const commandMap: Record = {} -WorkerAPI.prototype.callAddon = async function (sid, user, argv) { +WorkerAPI.prototype.callAddon = async function (sid, user, writable, argv) { const callback = commandMap[argv.name] try { - return await callback({ ...argv, ...Context(sid, user) }) + const context = { ...argv, ...Context(sid, user, writable) } + const result = await callback(context) + await context.user._update() + return result } catch (error) { if (!argv.options.debug) return logger.warn(error) return formatError(error) diff --git a/packages/plugin-eval/src/index.ts b/packages/plugin-eval/src/index.ts index d3b3ef884a..c985d4d381 100644 --- a/packages/plugin-eval/src/index.ts +++ b/packages/plugin-eval/src/index.ts @@ -1,12 +1,12 @@ -import { App, Context, Session, User } from 'koishi-core' -import { CQCode, Logger, defineProperty, Random, pick } from 'koishi-utils' +import { Context, Session, User } from 'koishi-core' import { Worker, ResourceLimits } from 'worker_threads' +import { CQCode, Logger, defineProperty, Random, pick } from 'koishi-utils' import { WorkerAPI, WorkerConfig, WorkerData, Response } from './worker' import { wrap, expose, Remote } from './transfer' +import { MainAPI, Access, UserTrap } from './main' import { resolve } from 'path' -import { UserTrap } from './trap' -export * from './trap' +export * from './main' declare module 'koishi-core/dist/app' { interface App { @@ -35,7 +35,7 @@ declare module 'koishi-core/dist/session' { interface Session { $uuid: string _isEval: boolean - _logCount: number + _sendCount: number } } @@ -43,7 +43,7 @@ export interface MainConfig { prefix?: string timeout?: number maxLogs?: number - userFields?: User.Field[] + userFields?: Access resourceLimits?: ResourceLimits dataKeys?: (keyof WorkerData)[] } @@ -63,33 +63,6 @@ const defaultConfig: EvalConfig = { const logger = new Logger('eval') -export class MainAPI { - constructor(public app: App) {} - - private getSession(uuid: string) { - const session = this.app._sessions[uuid] - if (!session) throw new Error(`session ${uuid} not found`) - return session - } - - async execute(uuid: string, message: string) { - const session = this.getSession(uuid) - const send = session.$send - const sendQueued = session.$sendQueued - await session.$execute(message) - session.$sendQueued = sendQueued - session.$send = send - } - - async send(uuid: string, message: string) { - const session = this.getSession(uuid) - if (!session._logCount) session._logCount = 0 - if (this.app.evalConfig.maxLogs > session._logCount++) { - return await session.$sendQueued(message) - } - } -} - export const workerScript = `require(${JSON.stringify(resolve(__dirname, 'worker.js'))});` export function apply(ctx: Context, config: Config = {}) { @@ -160,54 +133,54 @@ export function apply(ctx: Context, config: Config = {}) { .before((session) => { if (!session['_redirected'] && session.$user?.authority < 2) return '权限不足。' }) - .action(async ({ session, options }, expr) => { - if (options.restart) { - await session.$app.evalWorker.terminate() - return '子线程已重启。' - } - if (!expr) return '请输入要执行的脚本。' - expr = CQCode.unescape(expr) + UserTrap.attach(cmd, config.userFields, async ({ session, options, user, writable }, expr) => { + if (options.restart) { + await session.$app.evalWorker.terminate() + return '子线程已重启。' + } - return await new Promise((resolve) => { - logger.debug(expr) - defineProperty(session, '_isEval', true) + if (!expr) return '请输入要执行的脚本。' + expr = CQCode.unescape(expr) - const _resolve = (result?: string) => { - clearTimeout(timer) - app.evalWorker.off('error', listener) - session._isEval = false - resolve(result) - } + return await new Promise((resolve) => { + logger.debug(expr) + defineProperty(session, '_isEval', true) - const timer = setTimeout(async () => { - await app.evalWorker.terminate() - _resolve(!session._logCount && '执行超时。') - }, config.timeout) - - const listener = (error: Error) => { - let message = ERROR_CODES[error['code']] - if (!message) { - logger.warn(error) - message = '执行过程中遇到错误。' - } - _resolve(message) - } + const _resolve = (result?: string) => { + clearTimeout(timer) + app.evalWorker.off('error', listener) + session._isEval = false + resolve(result) + } + + const timer = setTimeout(async () => { + await app.evalWorker.terminate() + _resolve(!session._sendCount && '执行超时。') + }, config.timeout) - app.evalWorker.on('error', listener) - app.evalRemote.eval({ - sid: session.$uuid, - user: UserTrap.get(session.$user, config.userFields), - silent: options.slient, - source: expr, - }).then(_resolve, (error) => { + const listener = (error: Error) => { + let message = ERROR_CODES[error['code']] + if (!message) { logger.warn(error) - _resolve() - }) + message = '执行过程中遇到错误。' + } + _resolve(message) + } + + app.evalWorker.on('error', listener) + app.evalRemote.eval({ + user, + writable, + sid: session.$uuid, + silent: options.slient, + source: expr, + }).then(_resolve, (error) => { + logger.warn(error) + _resolve() }) }) - - UserTrap.prepare(cmd, config.userFields) + }) if (prefix) { cmd.shortcut(prefix, { oneArg: true, fuzzy: true }) diff --git a/packages/plugin-eval/src/internal.ts b/packages/plugin-eval/src/internal.ts index 308d40c31a..231247575e 100644 --- a/packages/plugin-eval/src/internal.ts +++ b/packages/plugin-eval/src/internal.ts @@ -314,7 +314,7 @@ Helper.function = function (this: Helper, fnc, traps, deepTraps, mock) { if (mock && host.Object.prototype.hasOwnProperty.call(mock, key)) return mock[key] if (key === 'constructor') return this.local.Function if (key === '__proto__') return this.local.Function.prototype - if (key === 'toString' && deepTraps === frozenTraps) return () => `function ${fnc.name}() { [native code] }` + if (key === 'toString' && this === Contextify) return () => `function ${fnc.name}() { [native code] }` } catch (e) { // Never pass the handled expcetion through! This block can't throw an exception under normal conditions. return null @@ -622,23 +622,17 @@ const frozenTraps: Trap = createObject({ }) function readonly(value: any, mock: any = {}) { - for (const key in mock) { - const value = mock[key] - if (typeof value === 'function') { - value.toString = () => `function ${value.name}() { [native code] }` - } - } return Contextify.value(value, null, frozenTraps, mock) } -export function setGlobal(name: keyof any, value: any, writable = false, configurable = false) { +export function setGlobal(name: keyof any, value: any, writable = false) { const prop = Contextify.value(name) try { Object.defineProperty(GLOBAL, prop, { value: writable ? Contextify.value(value) : readonly(value), enumerable: true, + configurable: writable, writable, - configurable, }) } catch (e) { throw Decontextify.value(e) diff --git a/packages/plugin-eval/src/main.ts b/packages/plugin-eval/src/main.ts new file mode 100644 index 0000000000..dde0a36a51 --- /dev/null +++ b/packages/plugin-eval/src/main.ts @@ -0,0 +1,89 @@ +import { App, Command, CommandAction, ParsedArgv, User } from 'koishi-core' + +interface TrappedArgv extends ParsedArgv { + user: Partial + writable: User.Field[] +} + +type TrappedAction = (argv: TrappedArgv, ...args: string[]) => ReturnType + +export interface UserTrap { + fields: Iterable + get(data: Pick): T + set(data: Pick, value: T): void +} + +export namespace UserTrap { + const traps: Record> = {} + + export function define(key: string, trap: UserTrap) { + traps[key] = trap + } + + export function attach(command: Command, fields: Access, action: TrappedAction) { + const { readable = [], writable = [] } = Array.isArray(fields) ? { readable: fields } : fields + for (const field of readable) { + const trap = traps[field] + command.userFields(trap ? trap.fields : [field]) + command.action((argv, ...args) => { + const user = get(argv.session.$user, readable) + return action({ ...argv, user, writable }, ...args) + }) + } + } + + export function get($user: User.Observed, fields: string[]) { + if (!$user) return {} + const result: Partial = {} + for (const field of fields) { + const trap = traps[field] + Reflect.set(result, field, trap ? trap.get($user) : $user[field]) + } + return result + } + + export function set($user: User.Observed, data: Partial) { + for (const field in data) { + const trap = traps[field] + trap ? trap.set($user, data[field]) : $user[field] = data[field] + } + return $user._update() + } +} + +export type Access = T[] | { + readable?: T[] + writable?: T[] +} + +export class MainAPI { + constructor(public app: App) {} + + private getSession(uuid: string) { + const session = this.app._sessions[uuid] + if (!session) throw new Error(`session ${uuid} not found`) + return session + } + + async execute(uuid: string, message: string) { + const session = this.getSession(uuid) + const send = session.$send + const sendQueued = session.$sendQueued + await session.$execute(message) + session.$sendQueued = sendQueued + session.$send = send + } + + async send(uuid: string, message: string) { + const session = this.getSession(uuid) + if (!session._sendCount) session._sendCount = 0 + if (this.app.evalConfig.maxLogs > session._sendCount++) { + return await session.$sendQueued(message) + } + } + + async updateUser(uuid: string, data: Partial) { + const session = this.getSession(uuid) + return UserTrap.set(session.$user, data) + } +} diff --git a/packages/plugin-eval/src/trap.ts b/packages/plugin-eval/src/trap.ts deleted file mode 100644 index 7d8560a41f..0000000000 --- a/packages/plugin-eval/src/trap.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Command, User } from 'koishi-core' - -export interface UserTrap { - fields: Iterable - get(data: Pick): T -} - -export namespace UserTrap { - const traps: Record> = {} - - export function define(key: string, trap: UserTrap) { - traps[key] = trap - } - - export function prepare(cmd: Command, fields: string[]) { - for (const field of fields) { - const trap = traps[field] - cmd.userFields(trap ? trap.fields : [field]) - } - } - - export function get($user: {}, fields: string[]) { - if (!$user) return - const result: Partial = {} - for (const field of fields) { - const trap = traps[field] - Reflect.set(result, field, trap ? trap.get($user) : $user[field]) - } - return result - } -} diff --git a/packages/plugin-eval/src/worker.ts b/packages/plugin-eval/src/worker.ts index 026642eefe..92b2a35be9 100644 --- a/packages/plugin-eval/src/worker.ts +++ b/packages/plugin-eval/src/worker.ts @@ -1,4 +1,4 @@ -import { Logger, escapeRegExp } from 'koishi-utils' +import { Logger, escapeRegExp, observe, contain } from 'koishi-utils' import { parentPort, workerData } from 'worker_threads' import { InspectOptions, formatWithOptions } from 'util' import { findSourceMap } from 'module' @@ -34,6 +34,7 @@ interface EvalOptions { user: Partial silent: boolean source: string + writable: User.Field[] } const vm = new VM() @@ -75,16 +76,24 @@ export function formatError(error: Error) { const main = wrap(parentPort) export interface Context { - user: Partial + user: User.Observed send(...param: any[]): Promise exec(message: string): Promise } -export const Context = (sid: string, user: Partial): Context => ({ - user, +export const Context = (sid: string, user: Partial, writable: User.Field[]): Context => ({ + user: observe(user, async (diff) => { + const diffKeys = Object.keys(diff) + if (!contain(writable, diffKeys)) { + throw new TypeError(`cannot set user field: ${diffKeys.join(', ')}`) + } + await main.updateUser(sid, diff) + }), + async send(...param: [string, ...any[]]) { return await main.send(sid, formatResult(...param)) }, + async exec(message: string) { if (typeof message !== 'string') { throw new TypeError('The "message" argument must be of type string') @@ -103,10 +112,11 @@ export class WorkerAPI { } async eval(options: EvalOptions) { - const { sid, user, source, silent } = options + const { sid, user, source, silent, writable } = options - const key = 'koishi-eval-session:' + sid - internal.setGlobal(Symbol.for(key), Context(sid, user), false, true) + const key = 'koishi-eval-context:' + sid + const ctx = Context(sid, user, writable) + internal.setGlobal(Symbol.for(key), ctx, true) let result: any try { @@ -118,6 +128,7 @@ export class WorkerAPI { filename: 'stdin', lineOffset: -4, }) + await ctx.user._update() } catch (error) { return formatError(error) }