feat: 重建QQBot插件平台运行时
This commit is contained in:
parent
379e5fb608
commit
bb69427921
@ -21,11 +21,13 @@ export type QqbotPluginCliOptions = {
|
|||||||
export type QqbotPluginCliResult = {
|
export type QqbotPluginCliResult = {
|
||||||
command: QqbotPluginCliCommand;
|
command: QqbotPluginCliCommand;
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
|
installationId?: string;
|
||||||
packageHash?: string;
|
packageHash?: string;
|
||||||
packagePath?: string;
|
packagePath?: string;
|
||||||
pluginKey?: string;
|
pluginKey?: string;
|
||||||
pluginRoot?: string;
|
pluginRoot?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
versionId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PackedPluginFile = {
|
type PackedPluginFile = {
|
||||||
@ -41,6 +43,18 @@ type PackedPlugin = {
|
|||||||
|
|
||||||
const templateRoot = path.join(__dirname, 'templates', 'basic');
|
const templateRoot = path.join(__dirname, 'templates', 'basic');
|
||||||
const packageExtension = '.qqbot-plugin.json';
|
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) =>
|
const sha256 = (content: Buffer | string) =>
|
||||||
crypto.createHash('sha256').update(content).digest('hex');
|
crypto.createHash('sha256').update(content).digest('hex');
|
||||||
@ -68,6 +82,73 @@ const formatPluginName = (pluginKey: string) => {
|
|||||||
.join(' ');
|
.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 readManifest = (pluginRoot: string) => {
|
||||||
const manifestPath = path.join(pluginRoot, 'plugin.json');
|
const manifestPath = path.join(pluginRoot, 'plugin.json');
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as unknown;
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as unknown;
|
||||||
@ -116,16 +197,30 @@ const listPackageFiles = (pluginRoot: string): PackedPluginFile[] => {
|
|||||||
const visit = (current: string) => {
|
const visit = (current: string) => {
|
||||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
if (current === pluginRoot && ignoredRoots.has(entry.name)) continue;
|
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 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()) {
|
if (entry.isDirectory()) {
|
||||||
visit(absolutePath);
|
visit(absolutePath);
|
||||||
continue;
|
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({
|
files.push({
|
||||||
path: relativePath,
|
path: relativePath,
|
||||||
sha256: sha256(fs.readFileSync(absolutePath)),
|
sha256: sha256(fs.readFileSync(absolutePath)),
|
||||||
@ -137,8 +232,32 @@ const listPackageFiles = (pluginRoot: string): PackedPluginFile[] => {
|
|||||||
return files.sort((a, b) => a.path.localeCompare(b.path));
|
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 buildPackedPlugin = (pluginRoot: string): PackedPlugin => {
|
||||||
const manifest = readManifest(pluginRoot);
|
const manifest = readManifest(pluginRoot);
|
||||||
|
assertPluginSourceBoundary(pluginRoot);
|
||||||
const files = listPackageFiles(pluginRoot);
|
const files = listPackageFiles(pluginRoot);
|
||||||
const contentHash = sha256(
|
const contentHash = sha256(
|
||||||
stableStringify({
|
stableStringify({
|
||||||
@ -172,10 +291,13 @@ const assertPackageIntegrity = (packedPlugin: PackedPlugin) => {
|
|||||||
const createPlugin = (
|
const createPlugin = (
|
||||||
pluginKey: string | undefined,
|
pluginKey: string | undefined,
|
||||||
options: Required<QqbotPluginCliOptions>,
|
options: Required<QqbotPluginCliOptions>,
|
||||||
|
outputPathArg?: string,
|
||||||
): QqbotPluginCliResult => {
|
): 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)) {
|
if (fs.existsSync(pluginRoot)) {
|
||||||
throw new Error(`Plugin already exists: ${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, 'assets'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(pluginRoot, 'migrations'), { 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 });
|
fs.mkdirSync(path.join(pluginRoot, 'tests'), { recursive: true });
|
||||||
readManifest(pluginRoot);
|
readManifest(pluginRoot);
|
||||||
|
|
||||||
@ -207,6 +338,7 @@ const validatePlugin = (
|
|||||||
|
|
||||||
const pluginRoot = path.resolve(options.cwd, pluginRootArg);
|
const pluginRoot = path.resolve(options.cwd, pluginRootArg);
|
||||||
const manifest = readManifest(pluginRoot);
|
const manifest = readManifest(pluginRoot);
|
||||||
|
assertPluginSourceBoundary(pluginRoot);
|
||||||
options.stdout(
|
options.stdout(
|
||||||
`Validated QQBot plugin: ${manifest.pluginKey}@${manifest.version}`,
|
`Validated QQBot plugin: ${manifest.pluginKey}@${manifest.version}`,
|
||||||
);
|
);
|
||||||
@ -223,15 +355,18 @@ const validatePlugin = (
|
|||||||
const packPlugin = (
|
const packPlugin = (
|
||||||
pluginRootArg: string | undefined,
|
pluginRootArg: string | undefined,
|
||||||
options: Required<QqbotPluginCliOptions>,
|
options: Required<QqbotPluginCliOptions>,
|
||||||
|
outputPathArg?: string,
|
||||||
): QqbotPluginCliResult => {
|
): QqbotPluginCliResult => {
|
||||||
if (!pluginRootArg) throw new Error('Usage: qqbot-plugin pack <path>');
|
if (!pluginRootArg) throw new Error('Usage: qqbot-plugin pack <path>');
|
||||||
|
|
||||||
const pluginRoot = path.resolve(options.cwd, pluginRootArg);
|
const pluginRoot = path.resolve(options.cwd, pluginRootArg);
|
||||||
const packedPlugin = buildPackedPlugin(pluginRoot);
|
const packedPlugin = buildPackedPlugin(pluginRoot);
|
||||||
const shortHash = packedPlugin.contentHash.slice(0, 12);
|
const shortHash = packedPlugin.contentHash.slice(0, 12);
|
||||||
|
const packageRoot = outputPathArg
|
||||||
|
? resolveControlledPath(outputPathArg, options, 'pack')
|
||||||
|
: path.join(pluginRoot, 'dist');
|
||||||
const packagePath = path.join(
|
const packagePath = path.join(
|
||||||
pluginRoot,
|
packageRoot,
|
||||||
'dist',
|
|
||||||
`${packedPlugin.manifest.pluginKey}-${packedPlugin.manifest.version}-${shortHash}${packageExtension}`,
|
`${packedPlugin.manifest.pluginKey}-${packedPlugin.manifest.version}-${shortHash}${packageExtension}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -263,6 +398,12 @@ const installLocalPlugin = (
|
|||||||
fs.readFileSync(packagePath, 'utf8'),
|
fs.readFileSync(packagePath, 'utf8'),
|
||||||
) as PackedPlugin;
|
) as PackedPlugin;
|
||||||
assertPackageIntegrity(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(
|
options.stdout(
|
||||||
`Installed local QQBot plugin package: ${packedPlugin.manifest.pluginKey}@${packedPlugin.manifest.version}`,
|
`Installed local QQBot plugin package: ${packedPlugin.manifest.pluginKey}@${packedPlugin.manifest.version}`,
|
||||||
);
|
);
|
||||||
@ -270,10 +411,12 @@ const installLocalPlugin = (
|
|||||||
return {
|
return {
|
||||||
command: 'install-local',
|
command: 'install-local',
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
|
installationId,
|
||||||
packageHash: packedPlugin.contentHash,
|
packageHash: packedPlugin.contentHash,
|
||||||
packagePath,
|
packagePath,
|
||||||
pluginKey: packedPlugin.manifest.pluginKey,
|
pluginKey: packedPlugin.manifest.pluginKey,
|
||||||
version: packedPlugin.manifest.version,
|
version: packedPlugin.manifest.version,
|
||||||
|
versionId,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -290,11 +433,11 @@ export const runQqbotPluginCli = async (
|
|||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'create':
|
case 'create':
|
||||||
return createPlugin(argv[1], resolvedOptions);
|
return createPlugin(argv[1], resolvedOptions, getOptionValue(argv, '--out'));
|
||||||
case 'install-local':
|
case 'install-local':
|
||||||
return installLocalPlugin(argv[1], resolvedOptions);
|
return installLocalPlugin(argv[1], resolvedOptions);
|
||||||
case 'pack':
|
case 'pack':
|
||||||
return packPlugin(argv[1], resolvedOptions);
|
return packPlugin(argv[1], resolvedOptions, getOptionValue(argv, '--out'));
|
||||||
case 'validate':
|
case 'validate':
|
||||||
return validatePlugin(argv[1], resolvedOptions);
|
return validatePlugin(argv[1], resolvedOptions);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -1,11 +1,18 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Between, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||||
import { throwVbenError } from '@/common';
|
import { throwVbenError } from '@/common';
|
||||||
|
import {
|
||||||
|
QQBOT_PLUGIN_EXECUTION_PORT,
|
||||||
|
type QqbotPluginEventDispatchInput,
|
||||||
|
type QqbotPluginExecutionInput,
|
||||||
|
type QqbotPluginExecutionPort,
|
||||||
|
} from '@/modules/qqbot/core/domain/plugin-execution.port';
|
||||||
import {
|
import {
|
||||||
parseQqbotPluginManifest,
|
parseQqbotPluginManifest,
|
||||||
type QqbotPluginManifest,
|
type QqbotPluginManifest,
|
||||||
} from '../domain/manifest';
|
} from '../domain/manifest';
|
||||||
|
import type { QqbotPluginWorkerRuntime } from '../infrastructure/integration/runtime';
|
||||||
import {
|
import {
|
||||||
QqbotPlugin,
|
QqbotPlugin,
|
||||||
QqbotPluginAccountBinding,
|
QqbotPluginAccountBinding,
|
||||||
@ -17,8 +24,24 @@ import {
|
|||||||
QqbotPluginRuntimeEvent,
|
QqbotPluginRuntimeEvent,
|
||||||
QqbotPluginVersion,
|
QqbotPluginVersion,
|
||||||
type QqbotPluginInstallStatus,
|
type QqbotPluginInstallStatus,
|
||||||
|
type QqbotPluginRuntimeEventLevel,
|
||||||
|
type QqbotPluginRuntimeStatus,
|
||||||
} from '../infrastructure/persistence';
|
} 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 = {
|
type ValidateManifestBody = {
|
||||||
manifest?: unknown;
|
manifest?: unknown;
|
||||||
};
|
};
|
||||||
@ -33,6 +56,21 @@ type InstallationActionBody = {
|
|||||||
id?: string;
|
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 = {
|
type UpdateConfigBody = {
|
||||||
configKey?: string;
|
configKey?: string;
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
@ -41,6 +79,8 @@ type UpdateConfigBody = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotPluginPlatformService {
|
export class QqbotPluginPlatformService {
|
||||||
|
private readonly activeWorkers = new Map<string, QqbotPluginWorkerRuntime>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(QqbotPlugin)
|
@InjectRepository(QqbotPlugin)
|
||||||
private readonly pluginRepository: Repository<QqbotPlugin>,
|
private readonly pluginRepository: Repository<QqbotPlugin>,
|
||||||
@ -60,12 +100,62 @@ export class QqbotPluginPlatformService {
|
|||||||
private readonly assetRepository: Repository<QqbotPluginAsset>,
|
private readonly assetRepository: Repository<QqbotPluginAsset>,
|
||||||
@InjectRepository(QqbotPluginRuntimeEvent)
|
@InjectRepository(QqbotPluginRuntimeEvent)
|
||||||
private readonly runtimeEventRepository: Repository<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() {
|
async listInstallations() {
|
||||||
return this.installationRepository.find();
|
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) {
|
validateManifest(body: ValidateManifestBody) {
|
||||||
return {
|
return {
|
||||||
manifest: parseQqbotPluginManifest(body.manifest),
|
manifest: parseQqbotPluginManifest(body.manifest),
|
||||||
@ -99,19 +189,118 @@ export class QqbotPluginPlatformService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setInstallationStatus(
|
async enableInstallation(body: InstallationActionBody) {
|
||||||
body: InstallationActionBody,
|
const installation = await this.requireInstallation(body);
|
||||||
status: QqbotPluginInstallStatus,
|
await this.updateInstallationRuntime(installation, 'enabled', 'starting');
|
||||||
) {
|
await this.recordRuntimeEvent(installation, 'enable-started');
|
||||||
if (!body.id) throwVbenError('请选择插件安装记录');
|
await this.startWorker(installation);
|
||||||
|
await this.refreshActiveRegistries(installation, true);
|
||||||
await this.installationRepository.update({ id: body.id }, { status });
|
await this.updateInstallationRuntime(installation, 'enabled', 'healthy');
|
||||||
|
await this.recordRuntimeEvent(installation, 'enable-finished');
|
||||||
return {
|
return {
|
||||||
id: body.id,
|
id: installation.id,
|
||||||
status,
|
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) {
|
async updateConfig(body: UpdateConfigBody) {
|
||||||
if (!body.pluginId || !body.configKey) {
|
if (!body.pluginId || !body.configKey) {
|
||||||
throwVbenError('请选择插件和配置项');
|
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({
|
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());
|
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')
|
@Post('validate')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '校验插件 manifest' })
|
@ApiOperation({ summary: '校验插件 manifest' })
|
||||||
@ -78,36 +115,28 @@ export class QqbotPluginPlatformController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '启用插件' })
|
@ApiOperation({ summary: '启用插件' })
|
||||||
async enable(@Body() body: { id?: string }) {
|
async enable(@Body() body: { id?: string }) {
|
||||||
return vbenSuccess(
|
return vbenSuccess(await this.service.enableInstallation(body));
|
||||||
await this.service.setInstallationStatus(body, 'enabled'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('disable')
|
@Post('disable')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '禁用插件' })
|
@ApiOperation({ summary: '禁用插件' })
|
||||||
async disable(@Body() body: { id?: string }) {
|
async disable(@Body() body: { id?: string }) {
|
||||||
return vbenSuccess(
|
return vbenSuccess(await this.service.disableInstallation(body));
|
||||||
await this.service.setInstallationStatus(body, 'disabled'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('upgrade')
|
@Post('upgrade')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '升级插件' })
|
@ApiOperation({ summary: '升级插件' })
|
||||||
async upgrade(@Body() body: { id?: string }) {
|
async upgrade(@Body() body: { id?: string }) {
|
||||||
return vbenSuccess(
|
return vbenSuccess(await this.service.upgradeInstallation(body));
|
||||||
await this.service.setInstallationStatus(body, 'installed'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('uninstall')
|
@Post('uninstall')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '卸载插件' })
|
@ApiOperation({ summary: '卸载插件' })
|
||||||
async uninstall(@Body() body: { id?: string }) {
|
async uninstall(@Body() body: { id?: string }) {
|
||||||
return vbenSuccess(
|
return vbenSuccess(await this.service.uninstallInstallation(body));
|
||||||
await this.service.setInstallationStatus(body, 'uninstalled'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('config')
|
@Post('config')
|
||||||
@ -122,8 +151,23 @@ export class QqbotPluginPlatformController {
|
|||||||
@Get('runtime-events')
|
@Get('runtime-events')
|
||||||
@ApiOperation({ summary: '插件运行事件列表' })
|
@ApiOperation({ summary: '插件运行事件列表' })
|
||||||
@ApiQuery({ name: 'pluginId', required: false, type: String })
|
@ApiQuery({ name: 'pluginId', required: false, type: String })
|
||||||
async runtimeEvents(@Query('pluginId') pluginId?: string) {
|
@ApiQuery({ name: 'installationId', required: false, type: String })
|
||||||
return vbenSuccess(await this.service.listRuntimeEvents(pluginId));
|
@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')
|
@Get('account-bindings')
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
} from './manifest.types';
|
} from './manifest.types';
|
||||||
|
|
||||||
const pluginKeyPattern = /^[a-z][a-z0-9-]{2,63}$/;
|
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 semanticVersionPattern = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
||||||
const capabilityKeyPattern = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
|
const capabilityKeyPattern = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
|
||||||
const windowsAbsolutePathPattern = /^[a-zA-Z]:[\\/]/;
|
const windowsAbsolutePathPattern = /^[a-zA-Z]:[\\/]/;
|
||||||
@ -245,6 +246,14 @@ const parseOperations = (
|
|||||||
'Operation timeoutMs is required.',
|
'Operation timeoutMs is required.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!getString(operation, 'handlerName')) {
|
||||||
|
pushIssue(
|
||||||
|
issues,
|
||||||
|
'MISSING_OPERATION_HANDLER',
|
||||||
|
`${pathPrefix}.handlerName`,
|
||||||
|
'Operation handlerName is required.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
aliases: getStringArray(operation, 'aliases'),
|
aliases: getStringArray(operation, 'aliases'),
|
||||||
@ -289,6 +298,14 @@ const parseEvents = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
seenKeys.add(key);
|
seenKeys.add(key);
|
||||||
|
if (!getString(event, 'handlerName')) {
|
||||||
|
pushIssue(
|
||||||
|
issues,
|
||||||
|
'MISSING_EVENT_HANDLER',
|
||||||
|
`${pathPrefix}.handlerName`,
|
||||||
|
'Event handlerName is required.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: getString(event, 'description'),
|
description: getString(event, 'description'),
|
||||||
@ -306,11 +323,22 @@ const parseAssets = (
|
|||||||
): QqbotPluginAssetManifest[] => {
|
): QqbotPluginAssetManifest[] => {
|
||||||
const assets = Array.isArray(source.assets) ? source.assets : [];
|
const assets = Array.isArray(source.assets) ? source.assets : [];
|
||||||
|
|
||||||
return assets.filter(isPlainObject).map((asset, index) => ({
|
return assets.filter(isPlainObject).map((asset, index) => {
|
||||||
contentHash: getString(asset, 'contentHash'),
|
const key = getString(asset, 'key') || '';
|
||||||
key: getString(asset, 'key') || '',
|
if (!key) {
|
||||||
path: normalizePackagePath(asset.path, `assets[${index}].path`, issues),
|
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 = (
|
const parseMigrations = (
|
||||||
@ -319,14 +347,36 @@ const parseMigrations = (
|
|||||||
): QqbotPluginMigrationManifest[] => {
|
): QqbotPluginMigrationManifest[] => {
|
||||||
const migrations = Array.isArray(source.migrations) ? source.migrations : [];
|
const migrations = Array.isArray(source.migrations) ? source.migrations : [];
|
||||||
|
|
||||||
return migrations.filter(isPlainObject).map((migration, index) => ({
|
return migrations.filter(isPlainObject).map((migration, index) => {
|
||||||
path: normalizePackagePath(
|
const version = getString(migration, 'version') || '';
|
||||||
migration.path,
|
requireSemver(version, `migrations[${index}].version`, issues);
|
||||||
`migrations[${index}].path`,
|
return {
|
||||||
issues,
|
path: normalizePackagePath(
|
||||||
),
|
migration.path,
|
||||||
version: getString(migration, 'version') || '',
|
`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 = (
|
export const parseQqbotPluginManifest = (
|
||||||
@ -372,6 +422,7 @@ export const parseQqbotPluginManifest = (
|
|||||||
entry: normalizePackagePath(manifestLike.entry, 'entry', issues),
|
entry: normalizePackagePath(manifestLike.entry, 'entry', issues),
|
||||||
events: parseEvents(manifestLike, issues),
|
events: parseEvents(manifestLike, issues),
|
||||||
homepage: getString(manifestLike, 'homepage'),
|
homepage: getString(manifestLike, 'homepage'),
|
||||||
|
legacyAliases: parseLegacyAliases(manifestLike, issues),
|
||||||
license: getString(manifestLike, 'license'),
|
license: getString(manifestLike, 'license'),
|
||||||
migrations: parseMigrations(manifestLike, issues),
|
migrations: parseMigrations(manifestLike, issues),
|
||||||
minApiSdkVersion,
|
minApiSdkVersion,
|
||||||
|
|||||||
@ -66,6 +66,7 @@ export type QqbotPluginManifest = {
|
|||||||
entry: string;
|
entry: string;
|
||||||
events: QqbotPluginEventManifest[];
|
events: QqbotPluginEventManifest[];
|
||||||
homepage?: string;
|
homepage?: string;
|
||||||
|
legacyAliases: string[];
|
||||||
license?: string;
|
license?: string;
|
||||||
migrations: QqbotPluginMigrationManifest[];
|
migrations: QqbotPluginMigrationManifest[];
|
||||||
minApiSdkVersion: string;
|
minApiSdkVersion: string;
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export type QqbotPluginInstallStatus =
|
|||||||
| 'failed'
|
| 'failed'
|
||||||
| 'installed'
|
| 'installed'
|
||||||
| 'uninstalled'
|
| 'uninstalled'
|
||||||
|
| 'upgrading'
|
||||||
| 'uploaded'
|
| 'uploaded'
|
||||||
| 'validated';
|
| 'validated';
|
||||||
|
|
||||||
|
|||||||
@ -121,4 +121,91 @@ describe('QQBot plugin CLI', () => {
|
|||||||
pluginKey: 'demo-plugin',
|
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',
|
homepage: 'https://example.com/bangdream',
|
||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
|
legacyAliases: ['bangDream'],
|
||||||
migrations: [
|
migrations: [
|
||||||
{
|
{
|
||||||
path: 'migrations/001-init.sql',
|
path: 'migrations/001-init.sql',
|
||||||
@ -105,6 +106,7 @@ describe('QQBot plugin manifest contract', () => {
|
|||||||
entry: 'src/index.ts',
|
entry: 'src/index.ts',
|
||||||
minApiSdkVersion: '1.0.0',
|
minApiSdkVersion: '1.0.0',
|
||||||
pluginKey: 'bangdream',
|
pluginKey: 'bangdream',
|
||||||
|
legacyAliases: ['bangDream'],
|
||||||
runtime: {
|
runtime: {
|
||||||
maxConcurrency: 2,
|
maxConcurrency: 2,
|
||||||
memoryMb: 256,
|
memoryMb: 256,
|
||||||
@ -205,4 +207,35 @@ describe('QQBot plugin manifest contract', () => {
|
|||||||
'migrations[0].path',
|
'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 { readFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { QqbotPluginPlatformService } from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service';
|
||||||
|
|
||||||
const repoRoot = join(__dirname, '../../../..');
|
const repoRoot = join(__dirname, '../../../..');
|
||||||
|
|
||||||
@ -67,4 +68,176 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
|
|||||||
|
|
||||||
expect(missingExecutorSignals).toEqual([]);
|
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 = () => ({
|
const createRepositoryMock = () => ({
|
||||||
find: jest.fn(async () => []),
|
find: jest.fn(async () => []),
|
||||||
|
findAndCount: jest.fn(async () => [[], 0]),
|
||||||
save: jest.fn(async (value) => value),
|
save: jest.fn(async (value) => value),
|
||||||
update: jest.fn(async () => ({ affected: 1 })),
|
update: jest.fn(async () => ({ affected: 1 })),
|
||||||
});
|
});
|
||||||
@ -62,8 +63,10 @@ const createManifest = () => ({
|
|||||||
|
|
||||||
describe('QQBot plugin platform API contract', () => {
|
describe('QQBot plugin platform API contract', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
let repositoryMocks: Map<unknown, ReturnType<typeof createRepositoryMock>>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
repositoryMocks = new Map();
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
controllers: [QqbotPluginPlatformController],
|
controllers: [QqbotPluginPlatformController],
|
||||||
providers: [
|
providers: [
|
||||||
@ -80,7 +83,11 @@ describe('QQBot plugin platform API contract', () => {
|
|||||||
QqbotPluginRuntimeEvent,
|
QqbotPluginRuntimeEvent,
|
||||||
].map((entity) => ({
|
].map((entity) => ({
|
||||||
provide: getRepositoryToken(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',
|
'POST /qqbot/plugin-platform/config',
|
||||||
'GET /qqbot/plugin-platform/runtime-events',
|
'GET /qqbot/plugin-platform/runtime-events',
|
||||||
'GET /qqbot/plugin-platform/account-bindings',
|
'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(QqbotPluginPlatformModule).toBeDefined();
|
||||||
expect(QQBOT_PLUGIN_PLATFORM_ENTITIES).toHaveLength(9);
|
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