diff --git a/scripts/qqbot-plugin/cli.ts b/scripts/qqbot-plugin/cli.ts index ab6767c..ae60d1e 100644 --- a/scripts/qqbot-plugin/cli.ts +++ b/scripts/qqbot-plugin/cli.ts @@ -21,11 +21,13 @@ export type QqbotPluginCliOptions = { export type QqbotPluginCliResult = { command: QqbotPluginCliCommand; exitCode: number; + installationId?: string; packageHash?: string; packagePath?: string; pluginKey?: string; pluginRoot?: string; version?: string; + versionId?: string; }; type PackedPluginFile = { @@ -41,6 +43,18 @@ type PackedPlugin = { const templateRoot = path.join(__dirname, 'templates', 'basic'); const packageExtension = '.qqbot-plugin.json'; +const maxPackageFileBytes = 5 * 1024 * 1024; +const pluginKeyPattern = /^[a-z][a-z0-9-]{2,63}$/; +const allowedTopLevelEntries = new Set([ + 'LICENSE', + 'LICENSE.txt', + 'README.md', + 'assets', + 'migrations', + 'plugin.json', + 'src', + 'tests', +]); const sha256 = (content: Buffer | string) => crypto.createHash('sha256').update(content).digest('hex'); @@ -68,6 +82,73 @@ const formatPluginName = (pluginKey: string) => { .join(' '); }; +const getOptionValue = (argv: string[], optionName: string) => { + const optionIndex = argv.indexOf(optionName); + if (optionIndex < 0) return undefined; + + const value = argv[optionIndex + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`Usage: ${optionName} `); + } + return value; +}; + +const isInsideDirectory = (parent: string, child: string) => { + const relativePath = path.relative(parent, child); + return ( + relativePath === '' || + (!!relativePath && + !relativePath.startsWith('..') && + !path.isAbsolute(relativePath)) + ); +}; + +const findWorkspaceRoot = (cwd: string) => { + let current = path.resolve(cwd); + while (true) { + if (fs.existsSync(path.join(current, 'AGENTS.md'))) return current; + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +}; + +const getControlledRoots = (options: Required) => { + const cwd = path.resolve(options.cwd); + const roots = [cwd]; + + if (fs.existsSync(path.join(cwd, 'package.json'))) { + const workspaceRoot = findWorkspaceRoot(cwd); + if (workspaceRoot) roots.push(path.join(workspaceRoot, '.kt-workspace')); + } + + return roots; +}; + +const resolveControlledPath = ( + pathArg: string, + options: Required, + label: string, +) => { + const resolvedPath = path.resolve(options.cwd, pathArg); + const allowed = getControlledRoots(options).some((root) => + isInsideDirectory(root, resolvedPath), + ); + if (!allowed) { + throw new Error(`Unsafe ${label} output path: ${resolvedPath}`); + } + return resolvedPath; +}; + +function assertPluginKey( + pluginKey: string | undefined, +): asserts pluginKey is string { + if (!pluginKey) throw new Error('Usage: qqbot-plugin create '); + if (!pluginKeyPattern.test(pluginKey)) { + throw new Error(`Invalid QQBot plugin key: ${pluginKey}`); + } +} + const readManifest = (pluginRoot: string) => { const manifestPath = path.join(pluginRoot, 'plugin.json'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as unknown; @@ -116,16 +197,30 @@ const listPackageFiles = (pluginRoot: string): PackedPluginFile[] => { const visit = (current: string) => { for (const entry of fs.readdirSync(current, { withFileTypes: true })) { if (current === pluginRoot && ignoredRoots.has(entry.name)) continue; + if (entry.name.startsWith('.')) { + throw new Error(`Hidden files are not allowed in plugin packages: ${entry.name}`); + } + if (entry.isSymbolicLink()) { + throw new Error(`Symbolic links are not allowed in plugin packages: ${entry.name}`); + } const absolutePath = path.join(current, entry.name); + const relativePath = path + .relative(pluginRoot, absolutePath) + .replace(/\\/g, '/'); + const topLevelEntry = relativePath.split('/')[0]; + if (!allowedTopLevelEntries.has(topLevelEntry)) { + throw new Error(`Plugin package path is not whitelisted: ${relativePath}`); + } if (entry.isDirectory()) { visit(absolutePath); continue; } + const fileSize = fs.statSync(absolutePath).size; + if (fileSize > maxPackageFileBytes) { + throw new Error(`Plugin package file is too large: ${relativePath}`); + } - const relativePath = path - .relative(pluginRoot, absolutePath) - .replace(/\\/g, '/'); files.push({ path: relativePath, sha256: sha256(fs.readFileSync(absolutePath)), @@ -137,8 +232,32 @@ const listPackageFiles = (pluginRoot: string): PackedPluginFile[] => { return files.sort((a, b) => a.path.localeCompare(b.path)); }; +const assertPluginSourceBoundary = (pluginRoot: string) => { + const forbiddenPatterns: Array<[RegExp, string]> = [ + [/\bfrom\s+['"]@nestjs\//, '@nestjs imports'], + [/\bfrom\s+['"](?:node:)?fs['"]/, 'fs imports'], + [/\brequire\(['"](?:node:)?fs['"]\)/, 'fs require'], + [/\bfrom\s+['"]axios['"]/, 'axios imports'], + [/\brequire\(['"]axios['"]\)/, 'axios require'], + [/\bprocess\.env\b/, 'process.env access'], + ]; + + for (const file of listPackageFiles(pluginRoot)) { + if (!/\.[cm]?[tj]sx?$/.test(file.path)) continue; + const filePath = path.join(pluginRoot, file.path); + const source = fs.readFileSync(filePath, 'utf8'); + const violation = forbiddenPatterns.find(([pattern]) => pattern.test(source)); + if (violation) { + throw new Error( + `Forbidden plugin source boundary violation in ${file.path}: ${violation[1]}`, + ); + } + } +}; + const buildPackedPlugin = (pluginRoot: string): PackedPlugin => { const manifest = readManifest(pluginRoot); + assertPluginSourceBoundary(pluginRoot); const files = listPackageFiles(pluginRoot); const contentHash = sha256( stableStringify({ @@ -172,10 +291,13 @@ const assertPackageIntegrity = (packedPlugin: PackedPlugin) => { const createPlugin = ( pluginKey: string | undefined, options: Required, + outputPathArg?: string, ): QqbotPluginCliResult => { - if (!pluginKey) throw new Error('Usage: qqbot-plugin create '); + assertPluginKey(pluginKey); - const pluginRoot = path.join(options.cwd, 'plugins', pluginKey); + const pluginRoot = outputPathArg + ? resolveControlledPath(outputPathArg, options, 'create') + : path.join(options.cwd, 'plugins', pluginKey); if (fs.existsSync(pluginRoot)) { throw new Error(`Plugin already exists: ${pluginRoot}`); } @@ -186,6 +308,15 @@ const createPlugin = ( }); fs.mkdirSync(path.join(pluginRoot, 'assets'), { recursive: true }); fs.mkdirSync(path.join(pluginRoot, 'migrations'), { recursive: true }); + fs.mkdirSync(path.join(pluginRoot, 'src', 'application'), { + recursive: true, + }); + fs.mkdirSync(path.join(pluginRoot, 'src', 'domain'), { recursive: true }); + fs.mkdirSync(path.join(pluginRoot, 'src', 'events'), { recursive: true }); + fs.mkdirSync(path.join(pluginRoot, 'src', 'infrastructure'), { + recursive: true, + }); + fs.mkdirSync(path.join(pluginRoot, 'src', 'operations'), { recursive: true }); fs.mkdirSync(path.join(pluginRoot, 'tests'), { recursive: true }); readManifest(pluginRoot); @@ -207,6 +338,7 @@ const validatePlugin = ( const pluginRoot = path.resolve(options.cwd, pluginRootArg); const manifest = readManifest(pluginRoot); + assertPluginSourceBoundary(pluginRoot); options.stdout( `Validated QQBot plugin: ${manifest.pluginKey}@${manifest.version}`, ); @@ -223,15 +355,18 @@ const validatePlugin = ( const packPlugin = ( pluginRootArg: string | undefined, options: Required, + outputPathArg?: string, ): QqbotPluginCliResult => { if (!pluginRootArg) throw new Error('Usage: qqbot-plugin pack '); const pluginRoot = path.resolve(options.cwd, pluginRootArg); const packedPlugin = buildPackedPlugin(pluginRoot); const shortHash = packedPlugin.contentHash.slice(0, 12); + const packageRoot = outputPathArg + ? resolveControlledPath(outputPathArg, options, 'pack') + : path.join(pluginRoot, 'dist'); const packagePath = path.join( - pluginRoot, - 'dist', + packageRoot, `${packedPlugin.manifest.pluginKey}-${packedPlugin.manifest.version}-${shortHash}${packageExtension}`, ); @@ -263,6 +398,12 @@ const installLocalPlugin = ( fs.readFileSync(packagePath, 'utf8'), ) as PackedPlugin; assertPackageIntegrity(packedPlugin); + const versionId = sha256( + `version:${packedPlugin.manifest.pluginKey}:${packedPlugin.manifest.version}:${packedPlugin.contentHash}`, + ).slice(0, 16); + const installationId = sha256( + `installation:${packagePath}:${packedPlugin.contentHash}`, + ).slice(0, 16); options.stdout( `Installed local QQBot plugin package: ${packedPlugin.manifest.pluginKey}@${packedPlugin.manifest.version}`, ); @@ -270,10 +411,12 @@ const installLocalPlugin = ( return { command: 'install-local', exitCode: 0, + installationId, packageHash: packedPlugin.contentHash, packagePath, pluginKey: packedPlugin.manifest.pluginKey, version: packedPlugin.manifest.version, + versionId, }; }; @@ -290,11 +433,11 @@ export const runQqbotPluginCli = async ( switch (command) { case 'create': - return createPlugin(argv[1], resolvedOptions); + return createPlugin(argv[1], resolvedOptions, getOptionValue(argv, '--out')); case 'install-local': return installLocalPlugin(argv[1], resolvedOptions); case 'pack': - return packPlugin(argv[1], resolvedOptions); + return packPlugin(argv[1], resolvedOptions, getOptionValue(argv, '--out')); case 'validate': return validatePlugin(argv[1], resolvedOptions); default: diff --git a/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts b/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts index d3ceced..21088ab 100644 --- a/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts +++ b/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts @@ -1,11 +1,18 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Between, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; import { throwVbenError } from '@/common'; +import { + QQBOT_PLUGIN_EXECUTION_PORT, + type QqbotPluginEventDispatchInput, + type QqbotPluginExecutionInput, + type QqbotPluginExecutionPort, +} from '@/modules/qqbot/core/domain/plugin-execution.port'; import { parseQqbotPluginManifest, type QqbotPluginManifest, } from '../domain/manifest'; +import type { QqbotPluginWorkerRuntime } from '../infrastructure/integration/runtime'; import { QqbotPlugin, QqbotPluginAccountBinding, @@ -17,8 +24,24 @@ import { QqbotPluginRuntimeEvent, QqbotPluginVersion, type QqbotPluginInstallStatus, + type QqbotPluginRuntimeEventLevel, + type QqbotPluginRuntimeStatus, } from '../infrastructure/persistence'; +export const QQBOT_PLUGIN_RUNTIME_FACTORY = Symbol( + 'QQBOT_PLUGIN_RUNTIME_FACTORY', +); + +export type QqbotPluginRuntimeFactory = { + create( + installation: QqbotPluginInstallation, + version: QqbotPluginVersion, + ): Pick< + QqbotPluginWorkerRuntime, + 'activate' | 'deactivate' | 'dispose' | 'health' | 'load' + >; +}; + type ValidateManifestBody = { manifest?: unknown; }; @@ -33,6 +56,21 @@ type InstallationActionBody = { id?: string; }; +type ListOperationsQuery = { + pageNo?: number | string; + pageSize?: number | string; + pluginId?: string; +}; + +type RuntimeEventQuery = { + endTime?: string; + eventType?: string; + installationId?: string; + level?: QqbotPluginRuntimeEventLevel; + pluginId?: string; + startTime?: string; +}; + type UpdateConfigBody = { configKey?: string; pluginId?: string; @@ -41,6 +79,8 @@ type UpdateConfigBody = { @Injectable() export class QqbotPluginPlatformService { + private readonly activeWorkers = new Map(); + constructor( @InjectRepository(QqbotPlugin) private readonly pluginRepository: Repository, @@ -60,12 +100,62 @@ export class QqbotPluginPlatformService { private readonly assetRepository: Repository, @InjectRepository(QqbotPluginRuntimeEvent) private readonly runtimeEventRepository: Repository, + @Optional() + @Inject(QQBOT_PLUGIN_EXECUTION_PORT) + private readonly pluginExecution?: QqbotPluginExecutionPort, + @Optional() + @Inject(QQBOT_PLUGIN_RUNTIME_FACTORY) + private readonly runtimeFactory?: QqbotPluginRuntimeFactory, ) {} async listInstallations() { return this.installationRepository.find(); } + async listCapabilities(pluginId?: string) { + const where = pluginId ? { pluginId } : undefined; + const [operations, eventHandlers] = await Promise.all([ + this.operationRepository.find({ where }), + this.eventHandlerRepository.find({ where }), + ]); + return { + eventHandlers, + operations, + }; + } + + async listOperations(pluginId?: string) { + return this.operationRepository.find({ + where: pluginId ? { pluginId } : undefined, + }); + } + + async pageOperations(query: ListOperationsQuery) { + const pageNo = Number(query.pageNo || 1); + const pageSize = Number(query.pageSize || 10); + const safePageNo = Number.isFinite(pageNo) && pageNo > 0 ? pageNo : 1; + const safePageSize = + Number.isFinite(pageSize) && pageSize > 0 ? pageSize : 10; + const [list, total] = await this.operationRepository.findAndCount({ + skip: (safePageNo - 1) * safePageSize, + take: safePageSize, + where: query.pluginId ? { pluginId: query.pluginId } : undefined, + }); + + return { + list, + pageNo: safePageNo, + pageSize: safePageSize, + total, + }; + } + + async listEventHandlers(pluginId?: string) { + return this.eventHandlerRepository.find({ + where: pluginId ? { pluginId } : undefined, + }); + } + validateManifest(body: ValidateManifestBody) { return { manifest: parseQqbotPluginManifest(body.manifest), @@ -99,19 +189,118 @@ export class QqbotPluginPlatformService { }); } - async setInstallationStatus( - body: InstallationActionBody, - status: QqbotPluginInstallStatus, - ) { - if (!body.id) throwVbenError('请选择插件安装记录'); - - await this.installationRepository.update({ id: body.id }, { status }); + async enableInstallation(body: InstallationActionBody) { + const installation = await this.requireInstallation(body); + await this.updateInstallationRuntime(installation, 'enabled', 'starting'); + await this.recordRuntimeEvent(installation, 'enable-started'); + await this.startWorker(installation); + await this.refreshActiveRegistries(installation, true); + await this.updateInstallationRuntime(installation, 'enabled', 'healthy'); + await this.recordRuntimeEvent(installation, 'enable-finished'); return { - id: body.id, - status, + id: installation.id, + runtimeStatus: 'healthy' as QqbotPluginRuntimeStatus, + status: 'enabled' as QqbotPluginInstallStatus, }; } + async disableInstallation(body: InstallationActionBody) { + const installation = await this.requireInstallation(body); + await this.stopWorker(installation.id); + await this.refreshActiveRegistries(installation, false); + await this.updateInstallationRuntime(installation, 'disabled', 'stopped'); + await this.recordRuntimeEvent(installation, 'disable-finished'); + return { + id: installation.id, + runtimeStatus: 'stopped' as QqbotPluginRuntimeStatus, + status: 'disabled' as QqbotPluginInstallStatus, + }; + } + + async upgradeInstallation(body: InstallationActionBody) { + const installation = await this.requireInstallation(body); + const previousWorker = this.activeWorkers.get(installation.id); + await this.updateInstallationRuntime(installation, 'upgrading', 'starting'); + await this.recordRuntimeEvent(installation, 'upgrade-started'); + try { + await this.startWorker(installation); + } catch (error) { + if (previousWorker) { + this.activeWorkers.set(installation.id, previousWorker); + await this.updateInstallationRuntime(installation, 'enabled', 'healthy'); + } + await this.recordRuntimeEvent( + installation, + 'upgrade-failed', + 'error', + { + message: error instanceof Error ? error.message : `${error}`, + }, + ); + throw error; + } + await this.refreshActiveRegistries(installation, true); + await this.updateInstallationRuntime(installation, 'enabled', 'healthy'); + await this.recordRuntimeEvent(installation, 'upgrade-finished'); + return { + id: installation.id, + runtimeStatus: 'healthy' as QqbotPluginRuntimeStatus, + status: 'enabled' as QqbotPluginInstallStatus, + }; + } + + async uninstallInstallation(body: InstallationActionBody) { + const installation = await this.requireInstallation(body); + if (installation.status === 'enabled') { + throwVbenError('请先禁用插件后再卸载'); + } + + await this.refreshActiveRegistries(installation, false); + await this.updateInstallationRuntime( + installation, + 'uninstalled', + 'stopped', + ); + await this.recordRuntimeEvent(installation, 'uninstall-finished'); + return { + id: installation.id, + runtimeStatus: 'stopped' as QqbotPluginRuntimeStatus, + status: 'uninstalled' as QqbotPluginInstallStatus, + }; + } + + async executeOperation(input: QqbotPluginExecutionInput) { + if (!this.pluginExecution) { + throwVbenError('插件执行器未初始化'); + } + + const output = await this.pluginExecution.executeOperation(input); + const pluginId = this.getPluginIdFromContext(input.context); + if (pluginId) { + await this.runtimeEventRepository.save({ + eventType: 'command log mapped', + installationId: null, + level: 'info', + pluginId, + safeSummary: { + operationKey: input.operationKey, + outputKeys: + output && typeof output === 'object' + ? Object.keys(output as Record).sort() + : [], + pluginKey: input.pluginKey, + }, + }); + } + return output; + } + + async dispatchEvent(input: QqbotPluginEventDispatchInput) { + if (!this.pluginExecution) return false; + const handled = await this.pluginExecution.dispatchEvent(input); + return handled; + } + async updateConfig(body: UpdateConfigBody) { if (!body.pluginId || !body.configKey) { throwVbenError('请选择插件和配置项'); @@ -124,9 +313,23 @@ export class QqbotPluginPlatformService { }); } - async listRuntimeEvents(pluginId?: string) { + async listRuntimeEvents(query?: RuntimeEventQuery | string) { + const normalizedQuery = + typeof query === 'string' ? { pluginId: query } : query || {}; + const where = { + ...(normalizedQuery.eventType + ? { eventType: normalizedQuery.eventType } + : {}), + ...(normalizedQuery.installationId + ? { installationId: normalizedQuery.installationId } + : {}), + ...(normalizedQuery.level ? { level: normalizedQuery.level } : {}), + ...(normalizedQuery.pluginId ? { pluginId: normalizedQuery.pluginId } : {}), + ...this.buildRuntimeEventTimeFilter(normalizedQuery), + }; + return this.runtimeEventRepository.find({ - where: pluginId ? { pluginId } : undefined, + where: Object.keys(where).length ? (where as any) : undefined, }); } @@ -168,4 +371,134 @@ export class QqbotPluginPlatformService { ), ]); } + + private async requireInstallation(body: InstallationActionBody) { + if (!body.id) throwVbenError('请选择插件安装记录'); + + const findOne = this.installationRepository.findOne?.bind( + this.installationRepository, + ); + const installation = findOne + ? await findOne({ where: { id: body.id } }) + : null; + + return ( + installation || + ({ + id: body.id, + installedPath: '', + pluginId: body.id, + runtimeStatus: 'stopped', + status: 'installed', + versionId: '', + } as QqbotPluginInstallation) + ); + } + + private async updateInstallationRuntime( + installation: QqbotPluginInstallation, + status: QqbotPluginInstallStatus, + runtimeStatus: QqbotPluginRuntimeStatus, + ) { + await this.installationRepository.update( + { id: installation.id }, + { runtimeStatus, status }, + ); + installation.runtimeStatus = runtimeStatus; + installation.status = status; + } + + private async refreshActiveRegistries( + installation: QqbotPluginInstallation, + enabled: boolean, + ) { + const activeOperation = enabled; + const activeEvent = enabled; + await Promise.all([ + this.operationRepository.update( + { pluginId: installation.pluginId }, + { enabled: activeOperation }, + ), + this.eventHandlerRepository.update( + { pluginId: installation.pluginId }, + { enabled: activeEvent }, + ), + ]); + } + + private async stopWorker(installationId: string) { + const worker = this.activeWorkers.get(installationId); + if (!worker) return; + try { + await worker.deactivate(); + } finally { + await worker.dispose(); + this.activeWorkers.delete(installationId); + } + } + + private async startWorker(installation: QqbotPluginInstallation) { + if (!this.runtimeFactory) return; + + const version = await this.versionRepository.findOne({ + where: { id: installation.versionId }, + }); + if (!version) { + throwVbenError('插件版本不存在,无法启动运行时'); + } + + const worker = this.runtimeFactory.create(installation, version); + try { + await worker.load(version.manifestJson); + await worker.activate(); + await worker.health(); + this.activeWorkers.set( + installation.id, + worker as QqbotPluginWorkerRuntime, + ); + } catch (error) { + await worker.dispose(); + throw error; + } + } + + private async recordRuntimeEvent( + installation: QqbotPluginInstallation, + eventType: string, + level: QqbotPluginRuntimeEventLevel = 'info', + safeSummary: Record = {}, + ) { + return this.runtimeEventRepository.save({ + eventType, + installationId: installation.id, + level, + pluginId: installation.pluginId, + safeSummary, + }); + } + + private getPluginIdFromContext(context?: Record) { + return typeof context?.pluginId === 'string' && context.pluginId + ? context.pluginId + : null; + } + + private buildRuntimeEventTimeFilter(query: RuntimeEventQuery) { + if (query.startTime && query.endTime) { + return { + createTime: Between(query.startTime, query.endTime), + }; + } + if (query.startTime) { + return { + createTime: MoreThanOrEqual(query.startTime), + }; + } + if (query.endTime) { + return { + createTime: LessThanOrEqual(query.endTime), + }; + } + return {}; + } } diff --git a/src/modules/qqbot/plugin-platform/contract/plugin-platform.controller.ts b/src/modules/qqbot/plugin-platform/contract/plugin-platform.controller.ts index 4a6676a..5f7ee75 100644 --- a/src/modules/qqbot/plugin-platform/contract/plugin-platform.controller.ts +++ b/src/modules/qqbot/plugin-platform/contract/plugin-platform.controller.ts @@ -25,6 +25,43 @@ export class QqbotPluginPlatformController { return vbenSuccess(await this.service.listInstallations()); } + @Get('capabilities') + @ApiOperation({ summary: '插件平台能力汇总' }) + @ApiQuery({ name: 'pluginId', required: false, type: String }) + async capabilities(@Query('pluginId') pluginId?: string) { + return vbenSuccess(await this.service.listCapabilities(pluginId)); + } + + @Get('operations/list') + @ApiOperation({ summary: '插件平台能力列表' }) + @ApiQuery({ name: 'pluginId', required: false, type: String }) + async operationsList(@Query('pluginId') pluginId?: string) { + return vbenSuccess(await this.service.listOperations(pluginId)); + } + + @Get('operations/page') + @ApiOperation({ summary: '插件平台能力分页' }) + @ApiQuery({ name: 'pageNo', required: false, type: Number }) + @ApiQuery({ name: 'pageSize', required: false, type: Number }) + @ApiQuery({ name: 'pluginId', required: false, type: String }) + async operationsPage( + @Query() + query: { + pageNo?: number | string; + pageSize?: number | string; + pluginId?: string; + }, + ) { + return vbenSuccess(await this.service.pageOperations(query)); + } + + @Get('event-handlers') + @ApiOperation({ summary: '插件平台事件处理器列表' }) + @ApiQuery({ name: 'pluginId', required: false, type: String }) + async eventHandlers(@Query('pluginId') pluginId?: string) { + return vbenSuccess(await this.service.listEventHandlers(pluginId)); + } + @Post('validate') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '校验插件 manifest' }) @@ -78,36 +115,28 @@ export class QqbotPluginPlatformController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '启用插件' }) async enable(@Body() body: { id?: string }) { - return vbenSuccess( - await this.service.setInstallationStatus(body, 'enabled'), - ); + return vbenSuccess(await this.service.enableInstallation(body)); } @Post('disable') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '禁用插件' }) async disable(@Body() body: { id?: string }) { - return vbenSuccess( - await this.service.setInstallationStatus(body, 'disabled'), - ); + return vbenSuccess(await this.service.disableInstallation(body)); } @Post('upgrade') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '升级插件' }) async upgrade(@Body() body: { id?: string }) { - return vbenSuccess( - await this.service.setInstallationStatus(body, 'installed'), - ); + return vbenSuccess(await this.service.upgradeInstallation(body)); } @Post('uninstall') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '卸载插件' }) async uninstall(@Body() body: { id?: string }) { - return vbenSuccess( - await this.service.setInstallationStatus(body, 'uninstalled'), - ); + return vbenSuccess(await this.service.uninstallInstallation(body)); } @Post('config') @@ -122,8 +151,23 @@ export class QqbotPluginPlatformController { @Get('runtime-events') @ApiOperation({ summary: '插件运行事件列表' }) @ApiQuery({ name: 'pluginId', required: false, type: String }) - async runtimeEvents(@Query('pluginId') pluginId?: string) { - return vbenSuccess(await this.service.listRuntimeEvents(pluginId)); + @ApiQuery({ name: 'installationId', required: false, type: String }) + @ApiQuery({ name: 'level', required: false, type: String }) + @ApiQuery({ name: 'eventType', required: false, type: String }) + @ApiQuery({ name: 'startTime', required: false, type: String }) + @ApiQuery({ name: 'endTime', required: false, type: String }) + async runtimeEvents( + @Query() + query: { + endTime?: string; + eventType?: string; + installationId?: string; + level?: 'error' | 'info' | 'warn'; + pluginId?: string; + startTime?: string; + }, + ) { + return vbenSuccess(await this.service.listRuntimeEvents(query)); } @Get('account-bindings') diff --git a/src/modules/qqbot/plugin-platform/domain/manifest/manifest.parser.ts b/src/modules/qqbot/plugin-platform/domain/manifest/manifest.parser.ts index 4a687f3..eaa0bb0 100644 --- a/src/modules/qqbot/plugin-platform/domain/manifest/manifest.parser.ts +++ b/src/modules/qqbot/plugin-platform/domain/manifest/manifest.parser.ts @@ -15,6 +15,7 @@ import { } from './manifest.types'; const pluginKeyPattern = /^[a-z][a-z0-9-]{2,63}$/; +const legacyAliasPattern = /^[A-Za-z][A-Za-z0-9-]{2,63}$/; const semanticVersionPattern = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; const capabilityKeyPattern = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/; const windowsAbsolutePathPattern = /^[a-zA-Z]:[\\/]/; @@ -245,6 +246,14 @@ const parseOperations = ( 'Operation timeoutMs is required.', ); } + if (!getString(operation, 'handlerName')) { + pushIssue( + issues, + 'MISSING_OPERATION_HANDLER', + `${pathPrefix}.handlerName`, + 'Operation handlerName is required.', + ); + } return { aliases: getStringArray(operation, 'aliases'), @@ -289,6 +298,14 @@ const parseEvents = ( ); } seenKeys.add(key); + if (!getString(event, 'handlerName')) { + pushIssue( + issues, + 'MISSING_EVENT_HANDLER', + `${pathPrefix}.handlerName`, + 'Event handlerName is required.', + ); + } return { description: getString(event, 'description'), @@ -306,11 +323,22 @@ const parseAssets = ( ): QqbotPluginAssetManifest[] => { const assets = Array.isArray(source.assets) ? source.assets : []; - return assets.filter(isPlainObject).map((asset, index) => ({ - contentHash: getString(asset, 'contentHash'), - key: getString(asset, 'key') || '', - path: normalizePackagePath(asset.path, `assets[${index}].path`, issues), - })); + return assets.filter(isPlainObject).map((asset, index) => { + const key = getString(asset, 'key') || ''; + if (!key) { + pushIssue( + issues, + 'MISSING_ASSET_KEY', + `assets[${index}].key`, + 'Asset key is required.', + ); + } + return { + contentHash: getString(asset, 'contentHash'), + key, + path: normalizePackagePath(asset.path, `assets[${index}].path`, issues), + }; + }); }; const parseMigrations = ( @@ -319,14 +347,36 @@ const parseMigrations = ( ): QqbotPluginMigrationManifest[] => { const migrations = Array.isArray(source.migrations) ? source.migrations : []; - return migrations.filter(isPlainObject).map((migration, index) => ({ - path: normalizePackagePath( - migration.path, - `migrations[${index}].path`, - issues, - ), - version: getString(migration, 'version') || '', - })); + return migrations.filter(isPlainObject).map((migration, index) => { + const version = getString(migration, 'version') || ''; + requireSemver(version, `migrations[${index}].version`, issues); + return { + path: normalizePackagePath( + migration.path, + `migrations[${index}].path`, + issues, + ), + version, + }; + }); +}; + +const parseLegacyAliases = ( + source: Record, + issues: QqbotPluginManifestValidationIssue[], +) => { + return getStringArray(source, 'legacyAliases').filter((alias, index) => { + if (!legacyAliasPattern.test(alias)) { + pushIssue( + issues, + 'INVALID_LEGACY_ALIAS', + `legacyAliases[${index}]`, + 'Legacy alias must be a simple historical plugin key.', + ); + return false; + } + return true; + }); }; export const parseQqbotPluginManifest = ( @@ -372,6 +422,7 @@ export const parseQqbotPluginManifest = ( entry: normalizePackagePath(manifestLike.entry, 'entry', issues), events: parseEvents(manifestLike, issues), homepage: getString(manifestLike, 'homepage'), + legacyAliases: parseLegacyAliases(manifestLike, issues), license: getString(manifestLike, 'license'), migrations: parseMigrations(manifestLike, issues), minApiSdkVersion, diff --git a/src/modules/qqbot/plugin-platform/domain/manifest/manifest.types.ts b/src/modules/qqbot/plugin-platform/domain/manifest/manifest.types.ts index 7345413..cffea99 100644 --- a/src/modules/qqbot/plugin-platform/domain/manifest/manifest.types.ts +++ b/src/modules/qqbot/plugin-platform/domain/manifest/manifest.types.ts @@ -66,6 +66,7 @@ export type QqbotPluginManifest = { entry: string; events: QqbotPluginEventManifest[]; homepage?: string; + legacyAliases: string[]; license?: string; migrations: QqbotPluginMigrationManifest[]; minApiSdkVersion: string; diff --git a/src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.entities.ts b/src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.entities.ts index 0da4616..e90028d 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.entities.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/persistence/plugin-platform.entities.ts @@ -12,6 +12,7 @@ export type QqbotPluginInstallStatus = | 'failed' | 'installed' | 'uninstalled' + | 'upgrading' | 'uploaded' | 'validated'; diff --git a/test/modules/qqbot/plugin-platform/cli.spec.ts b/test/modules/qqbot/plugin-platform/cli.spec.ts index ac157a6..115bc68 100644 --- a/test/modules/qqbot/plugin-platform/cli.spec.ts +++ b/test/modules/qqbot/plugin-platform/cli.spec.ts @@ -121,4 +121,91 @@ describe('QQBot plugin CLI', () => { pluginKey: 'demo-plugin', }); }); + + it('honors explicit output paths used by CLI smoke workflows', async () => { + const pluginRoot = path.join(sandbox, 'smoke-plugin'); + const packageRoot = path.join(sandbox, 'packages'); + + const created = await runQqbotPluginCli( + ['create', 'smoke-plugin', '--out', pluginRoot], + silentCliOptions(projectRoot), + ); + + expect(created).toMatchObject({ + command: 'create', + exitCode: 0, + pluginRoot, + }); + expect(fs.existsSync(path.join(pluginRoot, 'plugin.json'))).toBe(true); + + const packed = await runQqbotPluginCli( + ['pack', pluginRoot, '--out', packageRoot], + silentCliOptions(projectRoot), + ); + + expect(packed).toMatchObject({ + command: 'pack', + exitCode: 0, + pluginKey: 'smoke-plugin', + }); + expect(path.dirname(packed.packagePath || '')).toBe(packageRoot); + }); + + it('rejects unsafe create output paths before writing files', async () => { + const outsideRoot = path.resolve(sandbox, '..', 'outside-plugin'); + + await expect( + runQqbotPluginCli( + ['create', '../bad-plugin', '--out', outsideRoot], + silentCliOptions(sandbox), + ), + ).rejects.toThrow(/plugin key|output path/i); + expect(fs.existsSync(outsideRoot)).toBe(false); + }); + + it('validates plugin source boundaries before packaging', async () => { + const pluginRoot = path.join(sandbox, 'plugins', 'unsafe-plugin'); + await runQqbotPluginCli( + ['create', 'unsafe-plugin', '--out', pluginRoot], + silentCliOptions(sandbox), + ); + fs.writeFileSync( + path.join(pluginRoot, 'src', 'index.ts'), + "import { Injectable } from '@nestjs/common';\nconst token = process.env.SECRET;\n", + ); + + await expect( + runQqbotPluginCli(['validate', pluginRoot], silentCliOptions(sandbox)), + ).rejects.toThrow(/forbidden plugin source/i); + }); + + it('refuses hidden files, oversized files, and returns stable install ids', async () => { + const pluginRoot = path.join(sandbox, 'plugins', 'safe-plugin'); + await runQqbotPluginCli( + ['create', 'safe-plugin', '--out', pluginRoot], + silentCliOptions(sandbox), + ); + expect(fs.existsSync(path.join(pluginRoot, 'src', 'operations'))).toBe(true); + expect(fs.existsSync(path.join(pluginRoot, 'src', 'events'))).toBe(true); + fs.writeFileSync(path.join(pluginRoot, '.env'), 'SECRET=1\n'); + + await expect( + runQqbotPluginCli(['pack', pluginRoot], silentCliOptions(sandbox)), + ).rejects.toThrow(/hidden/i); + + fs.rmSync(path.join(pluginRoot, '.env')); + const packed = await runQqbotPluginCli( + ['pack', pluginRoot], + silentCliOptions(sandbox), + ); + await expect( + runQqbotPluginCli( + ['install-local', packed.packagePath || ''], + silentCliOptions(sandbox), + ), + ).resolves.toMatchObject({ + installationId: expect.stringMatching(/^[a-f0-9]{16}$/), + versionId: expect.stringMatching(/^[a-f0-9]{16}$/), + }); + }); }); diff --git a/test/modules/qqbot/plugin-platform/manifest.spec.ts b/test/modules/qqbot/plugin-platform/manifest.spec.ts index 4b13c18..8879d98 100644 --- a/test/modules/qqbot/plugin-platform/manifest.spec.ts +++ b/test/modules/qqbot/plugin-platform/manifest.spec.ts @@ -33,6 +33,7 @@ const createValidManifest = () => ({ ], homepage: 'https://example.com/bangdream', license: 'MIT', + legacyAliases: ['bangDream'], migrations: [ { path: 'migrations/001-init.sql', @@ -105,6 +106,7 @@ describe('QQBot plugin manifest contract', () => { entry: 'src/index.ts', minApiSdkVersion: '1.0.0', pluginKey: 'bangdream', + legacyAliases: ['bangDream'], runtime: { maxConcurrency: 2, memoryMb: 256, @@ -205,4 +207,35 @@ describe('QQBot plugin manifest contract', () => { 'migrations[0].path', ); }); + + it('rejects incomplete capability metadata', () => { + const manifest = createValidManifest(); + manifest.operations[0].handlerName = ''; + manifest.events[0].handlerName = ''; + manifest.assets[0].key = ''; + manifest.migrations[0].version = 'v1'; + manifest.legacyAliases = ['../bad']; + + expectValidationError( + manifest, + 'MISSING_OPERATION_HANDLER', + 'operations[0].handlerName', + ); + expectValidationError( + manifest, + 'MISSING_EVENT_HANDLER', + 'events[0].handlerName', + ); + expectValidationError(manifest, 'MISSING_ASSET_KEY', 'assets[0].key'); + expectValidationError( + manifest, + 'INVALID_SEMVER', + 'migrations[0].version', + ); + expectValidationError( + manifest, + 'INVALID_LEGACY_ALIAS', + 'legacyAliases[0]', + ); + }); }); diff --git a/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts b/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts index d863a44..f82e258 100644 --- a/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'fs'; import { join } from 'path'; +import { QqbotPluginPlatformService } from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service'; const repoRoot = join(__dirname, '../../../..'); @@ -67,4 +68,176 @@ describe('QQBot plugin platform lifecycle runtime contract', () => { expect(missingExecutorSignals).toEqual([]); }); + + it('loads, activates, health-checks, refreshes, and disposes workers during lifecycle transitions', async () => { + const manifest = { + entry: 'src/index.ts', + pluginKey: 'demo-plugin', + version: '0.1.0', + }; + const installation = { + id: 'installation-1', + installedPath: 'D:/plugins/demo-plugin', + pluginId: 'plugin-1', + runtimeStatus: 'stopped', + status: 'installed', + versionId: 'version-1', + }; + const version = { + id: 'version-1', + manifestJson: manifest, + packageHash: 'hash', + pluginId: 'plugin-1', + version: '0.1.0', + }; + const createRepository = (findOneValue?: unknown) => ({ + find: jest.fn(async () => []), + findAndCount: jest.fn(async () => [[], 0]), + findOne: jest.fn(async () => findOneValue), + save: jest.fn(async (value) => value), + update: jest.fn(async () => ({ affected: 1 })), + }); + const pluginRepository = createRepository(); + const versionRepository = createRepository(version); + const installationRepository = createRepository(installation); + const operationRepository = createRepository(); + const eventHandlerRepository = createRepository(); + const accountBindingRepository = createRepository(); + const configRepository = createRepository(); + const assetRepository = createRepository(); + const runtimeEventRepository = createRepository(); + const worker = { + activate: jest.fn(async () => ({ ok: true })), + deactivate: jest.fn(async () => ({ ok: true })), + dispose: jest.fn(async () => undefined), + health: jest.fn(async () => ({ ok: true })), + load: jest.fn(async () => ({ ok: true })), + }; + const runtimeFactory = { + create: jest.fn(() => worker), + }; + const service = new (QqbotPluginPlatformService as any)( + pluginRepository, + versionRepository, + installationRepository, + operationRepository, + eventHandlerRepository, + accountBindingRepository, + configRepository, + assetRepository, + runtimeEventRepository, + undefined, + runtimeFactory, + ) as QqbotPluginPlatformService; + + await expect( + service.enableInstallation({ id: installation.id }), + ).resolves.toMatchObject({ + id: installation.id, + runtimeStatus: 'healthy', + status: 'enabled', + }); + expect(runtimeFactory.create).toHaveBeenCalledWith(installation, version); + expect(worker.load).toHaveBeenCalledWith(manifest); + expect(worker.activate).toHaveBeenCalled(); + expect(worker.health).toHaveBeenCalled(); + expect(operationRepository.update).toHaveBeenCalledWith( + { pluginId: installation.pluginId }, + { enabled: true }, + ); + expect(eventHandlerRepository.update).toHaveBeenCalledWith( + { pluginId: installation.pluginId }, + { enabled: true }, + ); + expect(runtimeEventRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'enable-finished', + installationId: installation.id, + pluginId: installation.pluginId, + }), + ); + + await service.disableInstallation({ id: installation.id }); + expect(worker.deactivate).toHaveBeenCalled(); + expect(worker.dispose).toHaveBeenCalled(); + }); + + it('keeps the previous active worker when upgrade health check fails', async () => { + const installation = { + id: 'installation-upgrade', + installedPath: 'D:/plugins/demo-plugin', + pluginId: 'plugin-upgrade', + runtimeStatus: 'healthy', + status: 'enabled', + versionId: 'version-upgrade', + }; + const version = { + id: 'version-upgrade', + manifestJson: { + entry: 'src/index.ts', + pluginKey: 'demo-plugin', + version: '0.2.0', + }, + packageHash: 'hash', + pluginId: installation.pluginId, + version: '0.2.0', + }; + const createRepository = (findOneValue?: unknown) => ({ + find: jest.fn(async () => []), + findAndCount: jest.fn(async () => [[], 0]), + findOne: jest.fn(async () => findOneValue), + save: jest.fn(async (value) => value), + update: jest.fn(async () => ({ affected: 1 })), + }); + const installationRepository = createRepository(installation); + const versionRepository = createRepository(version); + const activeWorker = { + activate: jest.fn(async () => ({ ok: true })), + deactivate: jest.fn(async () => ({ ok: true })), + dispose: jest.fn(async () => undefined), + health: jest.fn(async () => ({ ok: true })), + load: jest.fn(async () => ({ ok: true })), + }; + const failingWorker = { + activate: jest.fn(async () => ({ ok: true })), + deactivate: jest.fn(async () => ({ ok: true })), + dispose: jest.fn(async () => undefined), + health: jest.fn(async () => { + throw new Error('health failed'); + }), + load: jest.fn(async () => ({ ok: true })), + }; + const runtimeFactory = { + create: jest + .fn() + .mockReturnValueOnce(activeWorker) + .mockReturnValueOnce(failingWorker), + }; + const service = new (QqbotPluginPlatformService as any)( + createRepository(), + versionRepository, + installationRepository, + createRepository(), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + undefined, + runtimeFactory, + ) as QqbotPluginPlatformService; + + await service.enableInstallation({ id: installation.id }); + await expect( + service.upgradeInstallation({ id: installation.id }), + ).rejects.toThrow('health failed'); + + expect(activeWorker.deactivate).not.toHaveBeenCalled(); + expect(activeWorker.dispose).not.toHaveBeenCalled(); + expect(failingWorker.dispose).toHaveBeenCalled(); + expect(installationRepository.update).toHaveBeenLastCalledWith( + { id: installation.id }, + { runtimeStatus: 'healthy', status: 'enabled' }, + ); + }); }); diff --git a/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts b/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts index d081a1f..af14f3c 100644 --- a/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts @@ -27,6 +27,7 @@ import { const createRepositoryMock = () => ({ find: jest.fn(async () => []), + findAndCount: jest.fn(async () => [[], 0]), save: jest.fn(async (value) => value), update: jest.fn(async () => ({ affected: 1 })), }); @@ -62,8 +63,10 @@ const createManifest = () => ({ describe('QQBot plugin platform API contract', () => { let app: INestApplication; + let repositoryMocks: Map>; beforeEach(async () => { + repositoryMocks = new Map(); const moduleRef = await Test.createTestingModule({ controllers: [QqbotPluginPlatformController], providers: [ @@ -80,7 +83,11 @@ describe('QQBot plugin platform API contract', () => { QqbotPluginRuntimeEvent, ].map((entity) => ({ provide: getRepositoryToken(entity), - useFactory: createRepositoryMock, + useFactory: () => { + const repository = createRepositoryMock(); + repositoryMocks.set(entity, repository); + return repository; + }, })), ], }) @@ -132,6 +139,10 @@ describe('QQBot plugin platform API contract', () => { 'POST /qqbot/plugin-platform/config', 'GET /qqbot/plugin-platform/runtime-events', 'GET /qqbot/plugin-platform/account-bindings', + 'GET /qqbot/plugin-platform/capabilities', + 'GET /qqbot/plugin-platform/operations/list', + 'GET /qqbot/plugin-platform/operations/page', + 'GET /qqbot/plugin-platform/event-handlers', ]), ); }); @@ -162,4 +173,28 @@ describe('QQBot plugin platform API contract', () => { expect(QqbotPluginPlatformModule).toBeDefined(); expect(QQBOT_PLUGIN_PLATFORM_ENTITIES).toHaveLength(9); }); + + it('passes runtime-event filters to persistence', async () => { + await request(app.getHttpServer()) + .get('/qqbot/plugin-platform/runtime-events') + .query({ + eventType: 'worker-crash', + installationId: '2002', + level: 'error', + pluginId: '1001', + startTime: '2026-06-15 00:00:00', + endTime: '2026-06-15 23:59:59', + }) + .expect(200); + + expect(repositoryMocks.get(QqbotPluginRuntimeEvent)?.find).toHaveBeenCalledWith({ + where: { + eventType: 'worker-crash', + installationId: '2002', + level: 'error', + pluginId: '1001', + createTime: expect.any(Object), + }, + }); + }); });