feat: 增加通用插件包发现服务

This commit is contained in:
sunlei 2026-06-18 02:15:53 +08:00
parent 374c1ba0de
commit 9314b303fc
4 changed files with 369 additions and 0 deletions

View File

@ -0,0 +1,86 @@
import { Injectable } from '@nestjs/common';
import { existsSync, statSync } from 'node:fs';
import { isAbsolute, relative, resolve, sep } from 'node:path';
const DEFAULT_BUILTIN_PACKAGE_ROOT = resolve(
process.cwd(),
'src/modules/qqbot/plugins',
);
/**
* Restricts QQBot plugin package discovery and entry resolution to controlled package roots.
*/
@Injectable()
export class QqbotPluginPackagePathPolicyService {
private readonly controlledRoots: string[];
/**
* Creates a path policy for package roots that the plugin platform may scan.
* @param controlledRoots - Directory roots that may contain one-level QQBot plugin package folders.
*/
constructor(controlledRoots = [DEFAULT_BUILTIN_PACKAGE_ROOT]) {
this.controlledRoots = controlledRoots.map((root) => resolve(root));
}
/**
* Lists controlled roots that currently exist as directories on disk.
* @returns Absolute root directories that may be scanned for package manifests.
*/
listExistingRoots(): string[] {
return this.controlledRoots.filter(
(root) => existsSync(root) && statSync(root).isDirectory(),
);
}
/**
* Resolves a manifest entry to an absolute file path inside its owning package root.
* @param packageRoot - Package directory that owns the `plugin.json` declaring the entry.
* @param entry - Manifest entry path that must be relative to the package root.
* @returns Absolute entry file path safe for later worker import.
*/
resolveEntryFile(packageRoot: string, entry: string): string {
const normalizedPackageRoot = resolve(packageRoot);
const entryFile = resolve(normalizedPackageRoot, entry);
if (isAbsolute(entry) || this.isOutside(normalizedPackageRoot, entryFile)) {
throw new Error('Plugin entry must stay inside the package root');
}
return entryFile;
}
/**
* Ensures a package directory belongs to a configured controlled root.
* @param packageRoot - Candidate QQBot plugin package directory discovered from a root scan.
* @returns Normalized absolute package directory when it is inside a controlled root.
*/
assertControlledPackageRoot(packageRoot: string): string {
const normalizedPackageRoot = resolve(packageRoot);
const isControlled = this.controlledRoots.some(
(root) =>
normalizedPackageRoot === root ||
!this.isOutside(root, normalizedPackageRoot),
);
if (!isControlled) {
throw new Error('Plugin package root is outside controlled roots');
}
return normalizedPackageRoot;
}
/**
* Checks whether a candidate package path escapes a controlled root.
* @param root - Absolute controlled root that bounds package discovery.
* @param candidate - Absolute package directory or entry file being validated.
* @returns Whether the candidate is outside the root.
*/
private isOutside(root: string, candidate: string): boolean {
const relation = relative(root, candidate);
return (
relation === '..' ||
relation.startsWith(`..${sep}`) ||
isAbsolute(relation)
);
}
}

View File

@ -0,0 +1,179 @@
import { Injectable } from '@nestjs/common';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
parseQqbotPluginManifest,
type QqbotPluginManifest,
} from '@/modules/qqbot/plugin-platform/domain/manifest';
import { QqbotPluginPackagePathPolicyService } from './plugin-package-path-policy.service';
import type { QqbotPluginPackageDescriptor } from './plugin-package.types';
/**
* Discovers QQBot plugin packages by reading package manifests without importing package code.
*/
@Injectable()
export class QqbotPluginPackageSourceService {
/**
* Creates a manifest-only package descriptor source.
* @param pathPolicy - Root and entry policy that keeps package discovery inside controlled directories.
*/
constructor(private readonly pathPolicy: QqbotPluginPackagePathPolicyService) {}
/**
* Discovers package descriptors from one-level package directories under controlled roots.
* @returns Parsed descriptors sorted by plugin key for deterministic plugin-platform startup.
*/
async discoverPackages(): Promise<QqbotPluginPackageDescriptor[]> {
const descriptors: QqbotPluginPackageDescriptor[] = [];
for (const root of this.pathPolicy.listExistingRoots()) {
for (const packageRoot of this.listPackageRoots(root)) {
const descriptor = this.readDescriptor(packageRoot);
if (descriptor) {
descriptors.push(descriptor);
}
}
}
return descriptors.sort((left, right) =>
left.pluginKey.localeCompare(right.pluginKey),
);
}
/**
* Reads one package descriptor from `plugin.json` without loading the package entry module.
* @param packageRoot - Candidate QQBot plugin package directory that may contain `plugin.json`.
* @returns Descriptor when a manifest exists, otherwise `null`.
*/
readDescriptor(packageRoot: string): QqbotPluginPackageDescriptor | null {
const controlledPackageRoot =
this.pathPolicy.assertControlledPackageRoot(packageRoot);
const manifestFile = join(controlledPackageRoot, 'plugin.json');
if (!existsSync(manifestFile)) {
return null;
}
const manifestLike = JSON.parse(readFileSync(manifestFile, 'utf8'));
const entry = this.readManifestEntry(manifestLike);
const entryFile = this.pathPolicy.resolveEntryFile(
controlledPackageRoot,
entry,
);
const manifest = parseQqbotPluginManifest(
this.normalizeManifestForCurrentParser(manifestLike),
{ pluginRoot: controlledPackageRoot },
);
const pluginKey = manifest.pluginKey;
return {
entry: manifest.entry,
entryFile,
manifest: this.attachPackageKeyAlias(manifest, pluginKey),
packageRoot: controlledPackageRoot,
pluginKey,
};
}
/**
* Lists first-level child directories that may represent QQBot plugin packages.
* @param root - Existing controlled root that contains package directories.
* @returns Absolute candidate package directories under the root.
*/
private listPackageRoots(root: string): string[] {
return readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => join(root, entry.name));
}
/**
* Reads the raw entry string before parser normalization so path policy errors stay explicit.
* @param manifestLike - Parsed `plugin.json` content whose entry path belongs to the package.
* @returns Raw entry path or an empty value that the manifest parser will reject.
*/
private readManifestEntry(manifestLike: unknown): string {
if (!this.isRecord(manifestLike)) return '';
return typeof manifestLike.entry === 'string' ? manifestLike.entry : '';
}
/**
* Adapts planned package manifest aliases to the parser contract currently used by the repo.
* @param manifestLike - Parsed `plugin.json` content from a QQBot plugin package.
* @returns Parser input with the package key and worker type normalized for this codebase.
*/
private normalizeManifestForCurrentParser(manifestLike: unknown): unknown {
if (!this.isRecord(manifestLike)) return manifestLike;
const normalized: Record<string, unknown> = {
minApiSdkVersion: '1.0.0',
...manifestLike,
};
const packageKey = this.readPackageKey(manifestLike);
if (!this.readString(normalized.pluginKey) && packageKey) {
normalized.pluginKey = packageKey;
}
if (this.isRecord(manifestLike.runtime)) {
normalized.runtime = {
...manifestLike.runtime,
workerType:
manifestLike.runtime.workerType === 'thread'
? 'node-worker'
: manifestLike.runtime.workerType,
};
}
return normalized;
}
/**
* Adds the planned `manifest.key` alias while preserving the current manifest parser type.
* @param manifest - Parsed manifest whose `pluginKey` identifies the package.
* @param pluginKey - Package key exposed to descriptor consumers and future generic workers.
* @returns Manifest object with a runtime `key` alias for descriptor compatibility.
*/
private attachPackageKeyAlias(
manifest: QqbotPluginManifest,
pluginKey: string,
): QqbotPluginManifest {
return {
...manifest,
key: pluginKey,
} as QqbotPluginManifest;
}
/**
* Reads the package key from either the planned `key` field or the current `pluginKey` field.
* @param manifestLike - Parsed `plugin.json` object that declares the package identity.
* @returns Non-empty package key when declared.
*/
private readPackageKey(manifestLike: Record<string, unknown>): string {
return (
this.readString(manifestLike.key) ||
this.readString(manifestLike.pluginKey) ||
''
);
}
/**
* Narrows JSON values to object records before reading manifest fields.
* @param value - Parsed JSON value from a QQBot plugin package manifest.
* @returns Whether the value is a non-array object record.
*/
private isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
/**
* Normalizes string manifest fields used by package discovery.
* @param value - Raw JSON field value read from `plugin.json`.
* @returns Trimmed string when the field is non-empty.
*/
private readString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim()
? value.trim()
: undefined;
}
}

View File

@ -0,0 +1,14 @@
import type { QqbotPluginManifest } from '@/modules/qqbot/plugin-platform/domain/manifest';
export type QqbotPluginPackageDescriptor = {
entry: string;
entryFile: string;
manifest: QqbotPluginManifest;
packageRoot: string;
pluginKey: string;
};
export type QqbotPluginRuntimeConfigSnapshot = Record<
string,
string | undefined
>;

View File

@ -0,0 +1,90 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, sep } from 'node:path';
import { QqbotPluginPackagePathPolicyService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-path-policy.service';
import { QqbotPluginPackageSourceService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-source.service';
describe('QqbotPluginPackageSourceService', () => {
let tempRoot: string;
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), 'qqbot-plugin-source-'));
});
afterEach(() => {
rmSync(tempRoot, { force: true, recursive: true });
});
it('discovers packages from plugin.json without importing package code', async () => {
const packageRoot = join(tempRoot, 'sample');
mkdirSync(join(packageRoot, 'src'), { recursive: true });
writeFileSync(
join(packageRoot, 'plugin.json'),
JSON.stringify({
key: 'sample',
name: 'Sample',
version: '1.0.0',
entry: 'src/index.ts',
runtime: {
workerType: 'thread',
timeoutMs: 5000,
memoryMb: 128,
maxConcurrency: 1,
configKeys: ['SAMPLE_TOKEN'],
},
operations: [],
}),
'utf8',
);
writeFileSync(
join(packageRoot, 'src', 'index.ts'),
'throw new Error("entry must not be imported during discovery");',
'utf8',
);
const source = new QqbotPluginPackageSourceService(
new QqbotPluginPackagePathPolicyService([tempRoot]),
);
await expect(source.discoverPackages()).resolves.toEqual([
expect.objectContaining({
entry: 'src/index.ts',
entryFile: join(packageRoot, 'src', 'index.ts'),
manifest: expect.objectContaining({ key: 'sample' }),
packageRoot,
pluginKey: 'sample',
}),
]);
});
it('rejects package entries that escape controlled roots', async () => {
const packageRoot = join(tempRoot, 'escape');
mkdirSync(packageRoot, { recursive: true });
writeFileSync(
join(packageRoot, 'plugin.json'),
JSON.stringify({
key: 'escape',
name: 'Escape',
version: '1.0.0',
entry: `..${sep}outside.ts`,
runtime: {
workerType: 'thread',
timeoutMs: 5000,
memoryMb: 128,
maxConcurrency: 1,
},
operations: [],
}),
'utf8',
);
const source = new QqbotPluginPackageSourceService(
new QqbotPluginPackagePathPolicyService([tempRoot]),
);
await expect(source.discoverPackages()).rejects.toThrow(
'Plugin entry must stay inside the package root',
);
});
});