feat: 重建QQBot插件平台运行时
This commit is contained in:
parent
379e5fb608
commit
bb69427921
@ -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:
|
||||
|
||||
@ -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 {};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -66,6 +66,7 @@ export type QqbotPluginManifest = {
|
||||
entry: string;
|
||||
events: QqbotPluginEventManifest[];
|
||||
homepage?: string;
|
||||
legacyAliases: string[];
|
||||
license?: string;
|
||||
migrations: QqbotPluginMigrationManifest[];
|
||||
minApiSdkVersion: string;
|
||||
|
||||
@ -12,6 +12,7 @@ export type QqbotPluginInstallStatus =
|
||||
| 'failed'
|
||||
| 'installed'
|
||||
| 'uninstalled'
|
||||
| 'upgrading'
|
||||
| 'uploaded'
|
||||
| 'validated';
|
||||
|
||||
|
||||
@ -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}$/),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user