From 9d0935897b1dc51fc9c86995deb6be2ab0ea5968 Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 18 Jun 2026 03:08:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E9=80=9A=E7=94=A8?= =?UTF-8?q?=E6=8F=92=E4=BB=B6host=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/qqbot-config.service.ts | 12 + .../runtime/plugin-host-bridge.service.ts | 360 ++++++++++++++++++ .../runtime/plugin-host-bridge.types.ts | 9 + .../runtime/worker-runtime.types.ts | 8 + .../plugin-host-bridge.spec.ts | 197 ++++++++++ .../plugin-platform/worker-runtime.spec.ts | 61 ++- 6 files changed, 640 insertions(+), 7 deletions(-) create mode 100644 src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service.ts create mode 100644 src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.types.ts create mode 100644 test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts diff --git a/src/modules/qqbot/core/application/config/qqbot-config.service.ts b/src/modules/qqbot/core/application/config/qqbot-config.service.ts index 0dd8537..2cc44aa 100644 --- a/src/modules/qqbot/core/application/config/qqbot-config.service.ts +++ b/src/modules/qqbot/core/application/config/qqbot-config.service.ts @@ -39,6 +39,18 @@ export class QqbotConfigService { return { allowlistEnabled, blocklistEnabled }; } + /** + * Reads a raw QQBot config value for plugin runtime host calls. + * @param configKey - Package-owned config key requested by a plugin manifest or worker host call. + * @returns Stored config value, or `undefined` when the key is not configured. + */ + async getConfigValue(configKey: string): Promise { + const record = await this.configRepository.findOne({ + where: { configKey }, + }); + return record?.configValue || undefined; + } + /** * 更新Permission Config。 * @param config - config 输入;使用 `allowlistEnabled`、`blocklistEnabled` 字段生成结果。 diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service.ts new file mode 100644 index 0000000..d27f35d --- /dev/null +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service.ts @@ -0,0 +1,360 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { DictService } from '@/modules/admin/platform-config/dict/dict.service'; +import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; +import { QqbotConfigService } from '@/modules/qqbot/core/application/config/qqbot-config.service'; +import { QqbotSendService } from '@/modules/qqbot/core/application/send/qqbot-send.service'; +import type { QqbotPluginPackageDescriptor } from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package.types'; +import { + QqbotPluginHttpClientService, + type QqbotPluginHttpClientRequest, +} from '../sdk/plugin-http-client.service'; +import type { + QqbotPluginHostCallRequest, + QqbotPluginHostCallResponse, +} from './plugin-host-bridge.types'; + +const HOST_FILE_PATH_ERROR = + 'Plugin host file path must stay inside the package root'; +const MAX_HOST_SLEEP_MS = 60_000; + +@Injectable() +export class QqbotPluginHostBridgeService { + private readonly logger = new Logger(QqbotPluginHostBridgeService.name); + + /** + * Initializes the generic plugin host bridge with platform services that are safe to expose through worker host calls. + * @param configService - QQBot runtime config reader used by manifest-owned config keys. + * @param dictService - Admin dictionary service used by plugin dictionary lookups. + * @param httpClient - Plugin HTTP client used for host-mediated network calls. + * @param accountService - QQBot account service used for event plugin binding calls. + * @param sendService - QQBot send service used for host-mediated outgoing text messages. + */ + constructor( + private readonly configService: QqbotConfigService, + private readonly dictService: DictService, + private readonly httpClient: QqbotPluginHttpClientService, + private readonly accountService: QqbotAccountService, + private readonly sendService: QqbotSendService, + ) {} + + /** + * Handles one plugin worker host call and serializes thrown errors into worker-safe responses. + * @param descriptor - Package descriptor that constrains file-system calls to the plugin package root. + * @param request - Host method name, plugin key, and argument payload sent by the plugin worker. + * @returns Host call success response, or a failure response with a sanitized message. + */ + async handleHostCall( + descriptor: QqbotPluginPackageDescriptor, + request: QqbotPluginHostCallRequest, + ): Promise { + try { + const value = await this.dispatchHostCall(descriptor, request); + return { ok: true, value }; + } catch (error) { + return { + message: error instanceof Error ? error.message : `${error}`, + ok: false, + }; + } + } + + /** + * Dispatches a plugin host method to a generic platform capability without importing concrete plugin packages. + * @param descriptor - Package descriptor used to enforce package-local file operations. + * @param request - Host call request containing method name and untrusted worker arguments. + * @returns Raw platform service result for the requested host method. + */ + private async dispatchHostCall( + descriptor: QqbotPluginPackageDescriptor, + request: QqbotPluginHostCallRequest, + ) { + const args = request.args || {}; + + switch (request.method) { + case 'bindEventPlugin': + return this.accountService.bindEventPlugin( + getRequiredText(args, 'selfId'), + getRequiredText(args, 'pluginKey'), + ); + case 'getBoundEventPluginKeys': + return this.accountService.getBoundEventPluginKeys( + getRequiredText(args, 'selfId'), + ); + case 'getConfig': + return this.configService.getConfigValue(getRequiredText(args, 'key')); + case 'getConfigMany': + return this.getConfigMany(getTextArray(args, 'keys')); + case 'getDictByKey': + return this.dictService.getDictByKey(getDictCode(args)); + case 'getDictItemsByKey': + return this.dictService.getDictItemsByKey(getDictCode(args)); + case 'readAssetFile': + return this.readPackageFile(descriptor, getPathArgument(args)); + case 'readJsonFile': + return this.readJsonFile(descriptor, getPathArgument(args)); + case 'relationTree': + return this.dictService.relationTree( + (args.input || args) as Record, + ); + case 'renameFile': + return this.renamePackageFile( + descriptor, + getRequiredText(args, 'from'), + getRequiredText(args, 'to'), + ); + case 'requestBuffer': + return this.httpClient.requestBuffer(getHttpRequestOptions(args)); + case 'requestJson': + return this.httpClient.requestJson(getHttpRequestOptions(args)); + case 'sendText': + return this.sendService.sendText( + args.input as Parameters[0], + ); + case 'sleep': + return this.sleep(getRequiredNumber(args, 'ms')); + case 'unbindEventPlugin': + return this.accountService.unbindEventPlugin( + getRequiredText(args, 'selfId'), + getRequiredText(args, 'pluginKey'), + ); + case 'warn': + this.logger.warn({ + message: getRequiredText(args, 'message'), + pluginKey: request.pluginKey, + }); + return undefined; + case 'writeJsonFile': + return this.writeJsonFile(descriptor, getPathArgument(args), args.data); + default: + throw new Error(`未知插件 Host 调用:${request.method}`); + } + } + + /** + * Reads several package-owned QQBot config keys into a key/value snapshot for a worker host call. + * @param keys - Manifest-owned config keys requested by a plugin package. + * @returns Record preserving every requested key with stored value or `undefined`. + */ + private async getConfigMany(keys: string[]) { + const entries = await Promise.all( + keys.map(async (key) => [ + key, + await this.configService.getConfigValue(key), + ]), + ); + return Object.fromEntries(entries); + } + + /** + * Reads a package-local file as a buffer for plugin asset access. + * @param descriptor - Package descriptor whose packageRoot bounds the read. + * @param filePath - Relative package path requested by the plugin worker. + * @returns File contents read from inside the descriptor package root. + */ + private async readPackageFile( + descriptor: QqbotPluginPackageDescriptor, + filePath: string, + ) { + return readFile(resolvePackagePath(descriptor, filePath)); + } + + /** + * Reads and parses a package-local JSON file for plugin runtime storage or assets. + * @param descriptor - Package descriptor whose packageRoot bounds the read. + * @param filePath - Relative package path requested by the plugin worker. + * @returns Parsed JSON payload from the package-local file. + */ + private async readJsonFile( + descriptor: QqbotPluginPackageDescriptor, + filePath: string, + ) { + return JSON.parse( + await readFile(resolvePackagePath(descriptor, filePath), 'utf8'), + ); + } + + /** + * Writes pretty JSON to a package-local file and creates parent directories when needed. + * @param descriptor - Package descriptor whose packageRoot bounds the write. + * @param filePath - Relative package path requested by the plugin worker. + * @param data - JSON-serializable payload supplied by the plugin worker. + */ + private async writeJsonFile( + descriptor: QqbotPluginPackageDescriptor, + filePath: string, + data: unknown, + ) { + const targetPath = resolvePackagePath(descriptor, filePath); + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, `${JSON.stringify(data, null, 2)}\n`); + return undefined; + } + + /** + * Renames one package-local file to another package-local path, creating the destination parent directory first. + * @param descriptor - Package descriptor whose packageRoot bounds both source and destination. + * @param from - Relative source path inside the plugin package root. + * @param to - Relative destination path inside the plugin package root. + */ + private async renamePackageFile( + descriptor: QqbotPluginPackageDescriptor, + from: string, + to: string, + ) { + const sourcePath = resolvePackagePath(descriptor, from); + const targetPath = resolvePackagePath(descriptor, to); + await mkdir(dirname(targetPath), { recursive: true }); + await rename(sourcePath, targetPath); + return undefined; + } + + /** + * Waits for a bounded duration requested by plugin runtime IO. + * @param ms - Requested sleep duration in milliseconds; negative and non-finite values are rejected. + */ + private sleep(ms: number) { + if (!Number.isFinite(ms) || ms < 0) { + throw new Error( + 'Plugin host sleep duration must be a non-negative finite number', + ); + } + + return new Promise((resolveSleep) => { + const timer = setTimeout(resolveSleep, Math.min(ms, MAX_HOST_SLEEP_MS)); + timer.unref?.(); + }); + } +} + +/** + * Resolves a worker-supplied package-relative path and rejects absolute paths or traversal outside packageRoot. + * @param descriptor - Package descriptor that owns the host-call file boundary. + * @param filePath - Worker-supplied relative file path inside the package. + * @returns Absolute path guaranteed to stay inside descriptor.packageRoot. + */ +function resolvePackagePath( + descriptor: QqbotPluginPackageDescriptor, + filePath: string, +) { + if (!filePath || isAbsolute(filePath)) throw new Error(HOST_FILE_PATH_ERROR); + + const packageRoot = resolve(descriptor.packageRoot); + const targetPath = resolve(packageRoot, filePath); + const relativePath = relative(packageRoot, targetPath); + if ( + !relativePath || + relativePath === '..' || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + throw new Error(HOST_FILE_PATH_ERROR); + } + return targetPath; +} + +/** + * Reads a required string argument from worker host-call args. + * @param args - Worker-supplied host-call arguments. + * @param key - Argument name whose value must be a non-empty string. + * @returns Trimmed string argument value. + */ +function getRequiredText(args: Record, key: string) { + const value = args[key]; + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`Plugin host argument ${key} must be a non-empty string`); + } + return value.trim(); +} + +/** + * Reads the host-call path argument while accepting both `path` and legacy `filePath` spellings. + * @param args - Worker-supplied host-call arguments. + * @returns Relative package path requested by the plugin worker. + */ +function getPathArgument(args: Record) { + const value = args.path ?? args.filePath; + if (typeof value !== 'string' || !value.trim()) { + throw new Error('Plugin host file path must be a non-empty relative path'); + } + return value.trim(); +} + +/** + * Reads a dictionary code from worker args using the generic `dictCode` or `key` aliases. + * @param args - Worker-supplied host-call arguments. + * @returns Dictionary code to pass to DictService. + */ +function getDictCode(args: Record) { + const value = args.dictCode ?? args.key; + if (typeof value !== 'string' || !value.trim()) { + throw new Error('Plugin host dictCode must be a non-empty string'); + } + return value.trim(); +} + +/** + * Reads a string array argument from worker host-call args. + * @param args - Worker-supplied host-call arguments. + * @param key - Argument name whose value must be an array of strings. + * @returns String values in the same order requested by the plugin worker. + */ +function getTextArray(args: Record, key: string) { + const value = args[key]; + if ( + !Array.isArray(value) || + !value.every((item) => typeof item === 'string') + ) { + throw new Error(`Plugin host argument ${key} must be a string array`); + } + return value as string[]; +} + +/** + * Reads a required numeric argument from worker host-call args. + * @param args - Worker-supplied host-call arguments. + * @param key - Argument name whose value must be numeric. + * @returns Numeric value converted from the worker argument. + */ +function getRequiredNumber(args: Record, key: string) { + const value = Number(args[key]); + if (!Number.isFinite(value)) { + throw new Error(`Plugin host argument ${key} must be a finite number`); + } + return value; +} + +/** + * Normalizes host-call HTTP options from either `{ options }` or raw option arguments. + * @param args - Worker-supplied host-call arguments. + * @returns HTTP client request options safe to pass to QqbotPluginHttpClientService. + */ +function getHttpRequestOptions( + args: Record, +): QqbotPluginHttpClientRequest { + const candidate = args.options || args; + if (!isRecord(candidate)) { + throw new Error('Plugin host HTTP options must be an object'); + } + + const request = { ...candidate } as QqbotPluginHttpClientRequest & { + failureMessageTemplate?: string; + }; + if (typeof request.failureMessageTemplate === 'string') { + const template = request.failureMessageTemplate; + request.failureMessage = (statusCode) => + template.replaceAll('{statusCode}', `${statusCode}`); + delete request.failureMessageTemplate; + } + return request; +} + +/** + * Checks whether an unknown value can be treated as a record of host-call options. + * @param value - Worker-supplied value that may contain named host-call arguments. + * @returns `true` when the value is a non-null object and not an array. + */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.types.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.types.ts new file mode 100644 index 0000000..f5ade63 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.types.ts @@ -0,0 +1,9 @@ +export type QqbotPluginHostCallRequest = { + args: Record; + method: string; + pluginKey: string; +}; + +export type QqbotPluginHostCallResponse = + | { ok: true; value: unknown } + | { message: string; ok: false }; diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.types.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.types.ts index b6ef3df..9f8f333 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.types.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.types.ts @@ -1,3 +1,8 @@ +import type { + QqbotPluginPackageDescriptor, + QqbotPluginRuntimeConfigSnapshot, +} from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package.types'; + export type QqbotPluginWorkerRequestType = | 'activate' | 'deactivate' @@ -20,7 +25,9 @@ export type QqbotPluginSafeInputSummary = { }; export type QqbotPluginWorkerRequest = { + configSnapshot?: QqbotPluginRuntimeConfigSnapshot; correlationId: string; + descriptor?: QqbotPluginPackageDescriptor; event?: unknown; eventKey?: string; installationId?: string; @@ -53,6 +60,7 @@ export type QqbotPluginWorkerRequestQueue = { export type QqbotPluginWorkerRuntimeOptions = { defaultTimeoutMs: number; + descriptor?: QqbotPluginPackageDescriptor; installationId: string; pluginKey: string; }; diff --git a/test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts b/test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts new file mode 100644 index 0000000..4de1c6c --- /dev/null +++ b/test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts @@ -0,0 +1,197 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { QqbotAccountService } from '../../../../src/modules/qqbot/core/application/account/qqbot-account.service'; +import { QqbotConfigService } from '../../../../src/modules/qqbot/core/application/config/qqbot-config.service'; +import { QqbotSendService } from '../../../../src/modules/qqbot/core/application/send/qqbot-send.service'; +import { DictService } from '../../../../src/modules/admin/platform-config/dict/dict.service'; +import { QqbotPluginHostBridgeService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service'; +import { QqbotPluginHttpClientService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service'; +import type { QqbotPluginPackageDescriptor } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package.types'; + +describe('QQBot plugin host bridge', () => { + let tempRoot: string; + let bridge: QqbotPluginHostBridgeService; + let configValues: Record; + let httpClient: { requestBuffer: jest.Mock; requestJson: jest.Mock }; + let accountService: { + bindEventPlugin: jest.Mock; + getBoundEventPluginKeys: jest.Mock; + unbindEventPlugin: jest.Mock; + }; + let sendService: { sendText: jest.Mock }; + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'qqbot-plugin-host-')); + configValues = { + SAMPLE_TOKEN: 'token-1', + SAMPLE_EMPTY: undefined, + }; + const configService = { + /** + * Reads the fixture config value requested by a plugin host call. + * @param configKey - Package-owned config key from the host call arguments. + * @returns Stored fixture value or `undefined` when the key is absent. + */ + getConfigValue: jest.fn( + async (configKey: string) => configValues[configKey], + ), + } as unknown as QqbotConfigService; + const dictService = { + getDictByKey: jest.fn(), + getDictItemsByKey: jest.fn(), + relationTree: jest.fn(), + } as unknown as DictService; + httpClient = { + requestBuffer: jest.fn(), + requestJson: jest.fn().mockResolvedValue({ ok: true }), + }; + accountService = { + bindEventPlugin: jest.fn().mockResolvedValue(true), + getBoundEventPluginKeys: jest.fn(), + unbindEventPlugin: jest.fn(), + }; + sendService = { + sendText: jest.fn().mockResolvedValue({ messageId: 'msg-1' }), + }; + bridge = new QqbotPluginHostBridgeService( + configService, + dictService, + httpClient as unknown as QqbotPluginHttpClientService, + accountService as unknown as QqbotAccountService, + sendService as unknown as QqbotSendService, + ); + }); + + afterEach(() => { + rmSync(tempRoot, { force: true, recursive: true }); + }); + + it('reads many package-owned config keys through the QQBot config service', async () => { + await expect( + bridge.handleHostCall(createDescriptor(), { + args: { keys: ['SAMPLE_TOKEN', 'SAMPLE_EMPTY', 'SAMPLE_MISSING'] }, + method: 'getConfigMany', + pluginKey: 'sample', + }), + ).resolves.toEqual({ + ok: true, + value: { + SAMPLE_EMPTY: undefined, + SAMPLE_MISSING: undefined, + SAMPLE_TOKEN: 'token-1', + }, + }); + }); + + it('rejects JSON reads that try to escape the package root', async () => { + await expect( + bridge.handleHostCall(createDescriptor(), { + args: { path: '../escape.json' }, + method: 'readJsonFile', + pluginKey: 'sample', + }), + ).resolves.toEqual({ + message: 'Plugin host file path must stay inside the package root', + ok: false, + }); + }); + + it('delegates requestJson host calls to the plugin HTTP client', async () => { + const options = { + context: 'sample call', + url: 'https://example.test/data', + }; + + await expect( + bridge.handleHostCall(createDescriptor(), { + args: { options }, + method: 'requestJson', + pluginKey: 'sample', + }), + ).resolves.toEqual({ ok: true, value: { ok: true } }); + expect(httpClient.requestJson).toHaveBeenCalledWith(options); + }); + + it('delegates event binding and text sends to core QQBot services', async () => { + const descriptor = createDescriptor(); + const sendInput = { + message: 'hello', + selfId: '10001', + targetId: '20001', + targetType: 'group', + }; + + await expect( + bridge.handleHostCall(descriptor, { + args: { selfId: '10001', pluginKey: 'sample' }, + method: 'bindEventPlugin', + pluginKey: 'sample', + }), + ).resolves.toEqual({ ok: true, value: true }); + await expect( + bridge.handleHostCall(descriptor, { + args: { input: sendInput }, + method: 'sendText', + pluginKey: 'sample', + }), + ).resolves.toEqual({ ok: true, value: { messageId: 'msg-1' } }); + + expect(accountService.bindEventPlugin).toHaveBeenCalledWith( + '10001', + 'sample', + ); + expect(sendService.sendText).toHaveBeenCalledWith(sendInput); + }); + + it('reads JSON files from descriptor package roots', async () => { + const descriptor = createDescriptor(); + const filePath = join(tempRoot, 'data', 'sample.json'); + mkdirSync(join(tempRoot, 'data'), { recursive: true }); + writeFileSync(filePath, '{"enabled":true}\n'); + + await expect( + bridge.handleHostCall(descriptor, { + args: { path: 'data/sample.json' }, + method: 'readJsonFile', + pluginKey: 'sample', + }), + ).resolves.toEqual({ ok: true, value: { enabled: true } }); + }); + + /** + * Creates a descriptor fixture rooted at this test's temporary package directory. + * @returns Descriptor used to constrain host bridge file-system access. + */ + function createDescriptor(): QqbotPluginPackageDescriptor { + return { + entry: 'src/index.ts', + entryFile: join(tempRoot, 'src', 'index.ts'), + manifest: { + key: 'sample', + pluginKey: 'sample', + name: 'Sample', + version: '1.0.0', + minApiSdkVersion: '1.0.0', + entry: 'src/index.ts', + runtime: { + configKeys: ['SAMPLE_TOKEN'], + maxConcurrency: 1, + memoryMb: 128, + timeoutMs: 5000, + workerType: 'thread', + }, + operations: [], + events: [], + tasks: [], + assets: [], + configSchema: {}, + legacyAliases: [], + migrations: [], + permissions: [], + }, + packageRoot: tempRoot, + pluginKey: 'sample', + }; + } +}); diff --git a/test/modules/qqbot/plugin-platform/worker-runtime.spec.ts b/test/modules/qqbot/plugin-platform/worker-runtime.spec.ts index 2fbcb03..e4a651b 100644 --- a/test/modules/qqbot/plugin-platform/worker-runtime.spec.ts +++ b/test/modules/qqbot/plugin-platform/worker-runtime.spec.ts @@ -1,15 +1,20 @@ import type { ConfigService } from '@nestjs/config'; import { - createQqbotBullmqWorkerQueueOptions, QqbotPluginRuntimeError, + QqbotPluginWorkerRuntime, QqbotPluginWorkerExpiredRequestError, QqbotPluginWorkerResponseError, - QqbotPluginWorkerRuntime, +} from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime'; +import { + createQqbotBullmqWorkerQueueOptions, resolveQqbotPluginQueueConnection, - type QqbotPluginWorkerDriver, - type QqbotPluginWorkerRequest, - type QqbotPluginWorkerRequestQueue, -} from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime'; +} from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/bullmq-plugin-worker-request.queue'; +import type { + QqbotPluginWorkerDriver, + QqbotPluginWorkerRequest, + QqbotPluginWorkerRequestQueue, + QqbotPluginWorkerRuntimeOptions, +} from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.types'; import { createQqbotPluginSdk } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/sdk'; class RecordingDriver implements QqbotPluginWorkerDriver { @@ -209,6 +214,48 @@ const createRuntime = (driver = new RecordingDriver()) => { }; describe('QQBot plugin worker runtime', () => { + it('accepts a package descriptor in worker runtime options', () => { + const options: QqbotPluginWorkerRuntimeOptions = { + defaultTimeoutMs: 5000, + descriptor: { + entry: 'src/index.ts', + entryFile: 'D:/repo/src/modules/qqbot/plugins/sample/src/index.ts', + manifest: { + key: 'sample', + pluginKey: 'sample', + name: 'Sample', + version: '1.0.0', + minApiSdkVersion: '1.0.0', + entry: 'src/index.ts', + runtime: { + configKeys: ['SAMPLE_TOKEN'], + maxConcurrency: 1, + memoryMb: 128, + timeoutMs: 5000, + workerType: 'thread', + }, + operations: [], + events: [], + tasks: [], + assets: [], + configSchema: {}, + legacyAliases: [], + migrations: [], + permissions: [], + }, + packageRoot: 'D:/repo/src/modules/qqbot/plugins/sample', + pluginKey: 'sample', + }, + installationId: 'install-1', + pluginKey: 'sample', + }; + + expect(options.descriptor.manifest.runtime.configKeys).toEqual([ + 'SAMPLE_TOKEN', + ]); + expect(options.descriptor.pluginKey).toBe('sample'); + }); + it('serializes concurrent worker execution requests for one plugin runtime', async () => { const releaseFirst = createDeferred(); const releaseSecond = createDeferred(); @@ -669,7 +716,7 @@ describe('QQBot plugin worker runtime', () => { const driver: QqbotPluginWorkerDriver = { dispose: jest.fn(async () => undefined), request: jest.fn( - () => new Promise((resolve) => setTimeout(resolve, 1000)), + () => new Promise((resolve) => setTimeout(resolve, 100)), ), }; const runtime = new QqbotPluginWorkerRuntime(