feat: 重建QQBot插件平台运行时

This commit is contained in:
sunlei 2026-06-15 16:20:53 +08:00
parent 379e5fb608
commit bb69427921
10 changed files with 951 additions and 50 deletions

View File

@ -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} <path>`);
}
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<QqbotPluginCliOptions>) => {
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<QqbotPluginCliOptions>,
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 <pluginKey>');
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<QqbotPluginCliOptions>,
outputPathArg?: string,
): QqbotPluginCliResult => {
if (!pluginKey) throw new Error('Usage: qqbot-plugin create <pluginKey>');
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<QqbotPluginCliOptions>,
outputPathArg?: string,
): QqbotPluginCliResult => {
if (!pluginRootArg) throw new Error('Usage: qqbot-plugin pack <path>');
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:

View File

@ -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<string, QqbotPluginWorkerRuntime>();
constructor(
@InjectRepository(QqbotPlugin)
private readonly pluginRepository: Repository<QqbotPlugin>,
@ -60,12 +100,62 @@ export class QqbotPluginPlatformService {
private readonly assetRepository: Repository<QqbotPluginAsset>,
@InjectRepository(QqbotPluginRuntimeEvent)
private readonly runtimeEventRepository: Repository<QqbotPluginRuntimeEvent>,
@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<string, unknown>).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<string, unknown> = {},
) {
return this.runtimeEventRepository.save({
eventType,
installationId: installation.id,
level,
pluginId: installation.pluginId,
safeSummary,
});
}
private getPluginIdFromContext(context?: Record<string, any>) {
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 {};
}
}

View File

@ -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')

View File

@ -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<string, unknown>,
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,

View File

@ -66,6 +66,7 @@ export type QqbotPluginManifest = {
entry: string;
events: QqbotPluginEventManifest[];
homepage?: string;
legacyAliases: string[];
license?: string;
migrations: QqbotPluginMigrationManifest[];
minApiSdkVersion: string;

View File

@ -12,6 +12,7 @@ export type QqbotPluginInstallStatus =
| 'failed'
| 'installed'
| 'uninstalled'
| 'upgrading'
| 'uploaded'
| 'validated';

View File

@ -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}$/),
});
});
});

View File

@ -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]',
);
});
});

View File

@ -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' },
);
});
});

View File

@ -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<unknown, ReturnType<typeof createRepositoryMock>>;
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),
},
});
});
});