feat: 增加通用插件host bridge

This commit is contained in:
sunlei 2026-06-18 03:08:40 +08:00
parent 8b50b57f27
commit 9d0935897b
6 changed files with 640 additions and 7 deletions

View File

@ -39,6 +39,18 @@ export class QqbotConfigService {
return { allowlistEnabled, blocklistEnabled };
}
/**
* Reads a raw QQBot config value for plugin runtime host calls.
* @param configKey - Package-owned config key requested by a plugin manifest or worker host call.
* @returns Stored config value, or `undefined` when the key is not configured.
*/
async getConfigValue(configKey: string): Promise<string | undefined> {
const record = await this.configRepository.findOne({
where: { configKey },
});
return record?.configValue || undefined;
}
/**
* Permission Config
* @param config - config 使 `allowlistEnabled``blocklistEnabled`

View File

@ -0,0 +1,360 @@
import { Injectable, Logger } from '@nestjs/common';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
import { DictService } from '@/modules/admin/platform-config/dict/dict.service';
import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service';
import { QqbotConfigService } from '@/modules/qqbot/core/application/config/qqbot-config.service';
import { QqbotSendService } from '@/modules/qqbot/core/application/send/qqbot-send.service';
import type { QqbotPluginPackageDescriptor } from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package.types';
import {
QqbotPluginHttpClientService,
type QqbotPluginHttpClientRequest,
} from '../sdk/plugin-http-client.service';
import type {
QqbotPluginHostCallRequest,
QqbotPluginHostCallResponse,
} from './plugin-host-bridge.types';
const HOST_FILE_PATH_ERROR =
'Plugin host file path must stay inside the package root';
const MAX_HOST_SLEEP_MS = 60_000;
@Injectable()
export class QqbotPluginHostBridgeService {
private readonly logger = new Logger(QqbotPluginHostBridgeService.name);
/**
* Initializes the generic plugin host bridge with platform services that are safe to expose through worker host calls.
* @param configService - QQBot runtime config reader used by manifest-owned config keys.
* @param dictService - Admin dictionary service used by plugin dictionary lookups.
* @param httpClient - Plugin HTTP client used for host-mediated network calls.
* @param accountService - QQBot account service used for event plugin binding calls.
* @param sendService - QQBot send service used for host-mediated outgoing text messages.
*/
constructor(
private readonly configService: QqbotConfigService,
private readonly dictService: DictService,
private readonly httpClient: QqbotPluginHttpClientService,
private readonly accountService: QqbotAccountService,
private readonly sendService: QqbotSendService,
) {}
/**
* Handles one plugin worker host call and serializes thrown errors into worker-safe responses.
* @param descriptor - Package descriptor that constrains file-system calls to the plugin package root.
* @param request - Host method name, plugin key, and argument payload sent by the plugin worker.
* @returns Host call success response, or a failure response with a sanitized message.
*/
async handleHostCall(
descriptor: QqbotPluginPackageDescriptor,
request: QqbotPluginHostCallRequest,
): Promise<QqbotPluginHostCallResponse> {
try {
const value = await this.dispatchHostCall(descriptor, request);
return { ok: true, value };
} catch (error) {
return {
message: error instanceof Error ? error.message : `${error}`,
ok: false,
};
}
}
/**
* Dispatches a plugin host method to a generic platform capability without importing concrete plugin packages.
* @param descriptor - Package descriptor used to enforce package-local file operations.
* @param request - Host call request containing method name and untrusted worker arguments.
* @returns Raw platform service result for the requested host method.
*/
private async dispatchHostCall(
descriptor: QqbotPluginPackageDescriptor,
request: QqbotPluginHostCallRequest,
) {
const args = request.args || {};
switch (request.method) {
case 'bindEventPlugin':
return this.accountService.bindEventPlugin(
getRequiredText(args, 'selfId'),
getRequiredText(args, 'pluginKey'),
);
case 'getBoundEventPluginKeys':
return this.accountService.getBoundEventPluginKeys(
getRequiredText(args, 'selfId'),
);
case 'getConfig':
return this.configService.getConfigValue(getRequiredText(args, 'key'));
case 'getConfigMany':
return this.getConfigMany(getTextArray(args, 'keys'));
case 'getDictByKey':
return this.dictService.getDictByKey(getDictCode(args));
case 'getDictItemsByKey':
return this.dictService.getDictItemsByKey(getDictCode(args));
case 'readAssetFile':
return this.readPackageFile(descriptor, getPathArgument(args));
case 'readJsonFile':
return this.readJsonFile(descriptor, getPathArgument(args));
case 'relationTree':
return this.dictService.relationTree(
(args.input || args) as Record<string, unknown>,
);
case 'renameFile':
return this.renamePackageFile(
descriptor,
getRequiredText(args, 'from'),
getRequiredText(args, 'to'),
);
case 'requestBuffer':
return this.httpClient.requestBuffer(getHttpRequestOptions(args));
case 'requestJson':
return this.httpClient.requestJson(getHttpRequestOptions(args));
case 'sendText':
return this.sendService.sendText(
args.input as Parameters<QqbotSendService['sendText']>[0],
);
case 'sleep':
return this.sleep(getRequiredNumber(args, 'ms'));
case 'unbindEventPlugin':
return this.accountService.unbindEventPlugin(
getRequiredText(args, 'selfId'),
getRequiredText(args, 'pluginKey'),
);
case 'warn':
this.logger.warn({
message: getRequiredText(args, 'message'),
pluginKey: request.pluginKey,
});
return undefined;
case 'writeJsonFile':
return this.writeJsonFile(descriptor, getPathArgument(args), args.data);
default:
throw new Error(`未知插件 Host 调用:${request.method}`);
}
}
/**
* Reads several package-owned QQBot config keys into a key/value snapshot for a worker host call.
* @param keys - Manifest-owned config keys requested by a plugin package.
* @returns Record preserving every requested key with stored value or `undefined`.
*/
private async getConfigMany(keys: string[]) {
const entries = await Promise.all(
keys.map(async (key) => [
key,
await this.configService.getConfigValue(key),
]),
);
return Object.fromEntries(entries);
}
/**
* Reads a package-local file as a buffer for plugin asset access.
* @param descriptor - Package descriptor whose packageRoot bounds the read.
* @param filePath - Relative package path requested by the plugin worker.
* @returns File contents read from inside the descriptor package root.
*/
private async readPackageFile(
descriptor: QqbotPluginPackageDescriptor,
filePath: string,
) {
return readFile(resolvePackagePath(descriptor, filePath));
}
/**
* Reads and parses a package-local JSON file for plugin runtime storage or assets.
* @param descriptor - Package descriptor whose packageRoot bounds the read.
* @param filePath - Relative package path requested by the plugin worker.
* @returns Parsed JSON payload from the package-local file.
*/
private async readJsonFile(
descriptor: QqbotPluginPackageDescriptor,
filePath: string,
) {
return JSON.parse(
await readFile(resolvePackagePath(descriptor, filePath), 'utf8'),
);
}
/**
* Writes pretty JSON to a package-local file and creates parent directories when needed.
* @param descriptor - Package descriptor whose packageRoot bounds the write.
* @param filePath - Relative package path requested by the plugin worker.
* @param data - JSON-serializable payload supplied by the plugin worker.
*/
private async writeJsonFile(
descriptor: QqbotPluginPackageDescriptor,
filePath: string,
data: unknown,
) {
const targetPath = resolvePackagePath(descriptor, filePath);
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, `${JSON.stringify(data, null, 2)}\n`);
return undefined;
}
/**
* Renames one package-local file to another package-local path, creating the destination parent directory first.
* @param descriptor - Package descriptor whose packageRoot bounds both source and destination.
* @param from - Relative source path inside the plugin package root.
* @param to - Relative destination path inside the plugin package root.
*/
private async renamePackageFile(
descriptor: QqbotPluginPackageDescriptor,
from: string,
to: string,
) {
const sourcePath = resolvePackagePath(descriptor, from);
const targetPath = resolvePackagePath(descriptor, to);
await mkdir(dirname(targetPath), { recursive: true });
await rename(sourcePath, targetPath);
return undefined;
}
/**
* Waits for a bounded duration requested by plugin runtime IO.
* @param ms - Requested sleep duration in milliseconds; negative and non-finite values are rejected.
*/
private sleep(ms: number) {
if (!Number.isFinite(ms) || ms < 0) {
throw new Error(
'Plugin host sleep duration must be a non-negative finite number',
);
}
return new Promise<void>((resolveSleep) => {
const timer = setTimeout(resolveSleep, Math.min(ms, MAX_HOST_SLEEP_MS));
timer.unref?.();
});
}
}
/**
* Resolves a worker-supplied package-relative path and rejects absolute paths or traversal outside packageRoot.
* @param descriptor - Package descriptor that owns the host-call file boundary.
* @param filePath - Worker-supplied relative file path inside the package.
* @returns Absolute path guaranteed to stay inside descriptor.packageRoot.
*/
function resolvePackagePath(
descriptor: QqbotPluginPackageDescriptor,
filePath: string,
) {
if (!filePath || isAbsolute(filePath)) throw new Error(HOST_FILE_PATH_ERROR);
const packageRoot = resolve(descriptor.packageRoot);
const targetPath = resolve(packageRoot, filePath);
const relativePath = relative(packageRoot, targetPath);
if (
!relativePath ||
relativePath === '..' ||
relativePath.startsWith(`..${sep}`) ||
isAbsolute(relativePath)
) {
throw new Error(HOST_FILE_PATH_ERROR);
}
return targetPath;
}
/**
* Reads a required string argument from worker host-call args.
* @param args - Worker-supplied host-call arguments.
* @param key - Argument name whose value must be a non-empty string.
* @returns Trimmed string argument value.
*/
function getRequiredText(args: Record<string, unknown>, key: string) {
const value = args[key];
if (typeof value !== 'string' || !value.trim()) {
throw new Error(`Plugin host argument ${key} must be a non-empty string`);
}
return value.trim();
}
/**
* Reads the host-call path argument while accepting both `path` and legacy `filePath` spellings.
* @param args - Worker-supplied host-call arguments.
* @returns Relative package path requested by the plugin worker.
*/
function getPathArgument(args: Record<string, unknown>) {
const value = args.path ?? args.filePath;
if (typeof value !== 'string' || !value.trim()) {
throw new Error('Plugin host file path must be a non-empty relative path');
}
return value.trim();
}
/**
* Reads a dictionary code from worker args using the generic `dictCode` or `key` aliases.
* @param args - Worker-supplied host-call arguments.
* @returns Dictionary code to pass to DictService.
*/
function getDictCode(args: Record<string, unknown>) {
const value = args.dictCode ?? args.key;
if (typeof value !== 'string' || !value.trim()) {
throw new Error('Plugin host dictCode must be a non-empty string');
}
return value.trim();
}
/**
* Reads a string array argument from worker host-call args.
* @param args - Worker-supplied host-call arguments.
* @param key - Argument name whose value must be an array of strings.
* @returns String values in the same order requested by the plugin worker.
*/
function getTextArray(args: Record<string, unknown>, key: string) {
const value = args[key];
if (
!Array.isArray(value) ||
!value.every((item) => typeof item === 'string')
) {
throw new Error(`Plugin host argument ${key} must be a string array`);
}
return value as string[];
}
/**
* Reads a required numeric argument from worker host-call args.
* @param args - Worker-supplied host-call arguments.
* @param key - Argument name whose value must be numeric.
* @returns Numeric value converted from the worker argument.
*/
function getRequiredNumber(args: Record<string, unknown>, key: string) {
const value = Number(args[key]);
if (!Number.isFinite(value)) {
throw new Error(`Plugin host argument ${key} must be a finite number`);
}
return value;
}
/**
* Normalizes host-call HTTP options from either `{ options }` or raw option arguments.
* @param args - Worker-supplied host-call arguments.
* @returns HTTP client request options safe to pass to QqbotPluginHttpClientService.
*/
function getHttpRequestOptions(
args: Record<string, unknown>,
): QqbotPluginHttpClientRequest {
const candidate = args.options || args;
if (!isRecord(candidate)) {
throw new Error('Plugin host HTTP options must be an object');
}
const request = { ...candidate } as QqbotPluginHttpClientRequest & {
failureMessageTemplate?: string;
};
if (typeof request.failureMessageTemplate === 'string') {
const template = request.failureMessageTemplate;
request.failureMessage = (statusCode) =>
template.replaceAll('{statusCode}', `${statusCode}`);
delete request.failureMessageTemplate;
}
return request;
}
/**
* Checks whether an unknown value can be treated as a record of host-call options.
* @param value - Worker-supplied value that may contain named host-call arguments.
* @returns `true` when the value is a non-null object and not an array.
*/
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View File

@ -0,0 +1,9 @@
export type QqbotPluginHostCallRequest = {
args: Record<string, unknown>;
method: string;
pluginKey: string;
};
export type QqbotPluginHostCallResponse =
| { ok: true; value: unknown }
| { message: string; ok: false };

View File

@ -1,3 +1,8 @@
import type {
QqbotPluginPackageDescriptor,
QqbotPluginRuntimeConfigSnapshot,
} from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package.types';
export type QqbotPluginWorkerRequestType =
| 'activate'
| 'deactivate'
@ -20,7 +25,9 @@ export type QqbotPluginSafeInputSummary = {
};
export type QqbotPluginWorkerRequest = {
configSnapshot?: QqbotPluginRuntimeConfigSnapshot;
correlationId: string;
descriptor?: QqbotPluginPackageDescriptor;
event?: unknown;
eventKey?: string;
installationId?: string;
@ -53,6 +60,7 @@ export type QqbotPluginWorkerRequestQueue = {
export type QqbotPluginWorkerRuntimeOptions = {
defaultTimeoutMs: number;
descriptor?: QqbotPluginPackageDescriptor;
installationId: string;
pluginKey: string;
};

View File

@ -0,0 +1,197 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { QqbotAccountService } from '../../../../src/modules/qqbot/core/application/account/qqbot-account.service';
import { QqbotConfigService } from '../../../../src/modules/qqbot/core/application/config/qqbot-config.service';
import { QqbotSendService } from '../../../../src/modules/qqbot/core/application/send/qqbot-send.service';
import { DictService } from '../../../../src/modules/admin/platform-config/dict/dict.service';
import { QqbotPluginHostBridgeService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service';
import { QqbotPluginHttpClientService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service';
import type { QqbotPluginPackageDescriptor } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package.types';
describe('QQBot plugin host bridge', () => {
let tempRoot: string;
let bridge: QqbotPluginHostBridgeService;
let configValues: Record<string, string | undefined>;
let httpClient: { requestBuffer: jest.Mock; requestJson: jest.Mock };
let accountService: {
bindEventPlugin: jest.Mock;
getBoundEventPluginKeys: jest.Mock;
unbindEventPlugin: jest.Mock;
};
let sendService: { sendText: jest.Mock };
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), 'qqbot-plugin-host-'));
configValues = {
SAMPLE_TOKEN: 'token-1',
SAMPLE_EMPTY: undefined,
};
const configService = {
/**
* Reads the fixture config value requested by a plugin host call.
* @param configKey - Package-owned config key from the host call arguments.
* @returns Stored fixture value or `undefined` when the key is absent.
*/
getConfigValue: jest.fn(
async (configKey: string) => configValues[configKey],
),
} as unknown as QqbotConfigService;
const dictService = {
getDictByKey: jest.fn(),
getDictItemsByKey: jest.fn(),
relationTree: jest.fn(),
} as unknown as DictService;
httpClient = {
requestBuffer: jest.fn(),
requestJson: jest.fn().mockResolvedValue({ ok: true }),
};
accountService = {
bindEventPlugin: jest.fn().mockResolvedValue(true),
getBoundEventPluginKeys: jest.fn(),
unbindEventPlugin: jest.fn(),
};
sendService = {
sendText: jest.fn().mockResolvedValue({ messageId: 'msg-1' }),
};
bridge = new QqbotPluginHostBridgeService(
configService,
dictService,
httpClient as unknown as QqbotPluginHttpClientService,
accountService as unknown as QqbotAccountService,
sendService as unknown as QqbotSendService,
);
});
afterEach(() => {
rmSync(tempRoot, { force: true, recursive: true });
});
it('reads many package-owned config keys through the QQBot config service', async () => {
await expect(
bridge.handleHostCall(createDescriptor(), {
args: { keys: ['SAMPLE_TOKEN', 'SAMPLE_EMPTY', 'SAMPLE_MISSING'] },
method: 'getConfigMany',
pluginKey: 'sample',
}),
).resolves.toEqual({
ok: true,
value: {
SAMPLE_EMPTY: undefined,
SAMPLE_MISSING: undefined,
SAMPLE_TOKEN: 'token-1',
},
});
});
it('rejects JSON reads that try to escape the package root', async () => {
await expect(
bridge.handleHostCall(createDescriptor(), {
args: { path: '../escape.json' },
method: 'readJsonFile',
pluginKey: 'sample',
}),
).resolves.toEqual({
message: 'Plugin host file path must stay inside the package root',
ok: false,
});
});
it('delegates requestJson host calls to the plugin HTTP client', async () => {
const options = {
context: 'sample call',
url: 'https://example.test/data',
};
await expect(
bridge.handleHostCall(createDescriptor(), {
args: { options },
method: 'requestJson',
pluginKey: 'sample',
}),
).resolves.toEqual({ ok: true, value: { ok: true } });
expect(httpClient.requestJson).toHaveBeenCalledWith(options);
});
it('delegates event binding and text sends to core QQBot services', async () => {
const descriptor = createDescriptor();
const sendInput = {
message: 'hello',
selfId: '10001',
targetId: '20001',
targetType: 'group',
};
await expect(
bridge.handleHostCall(descriptor, {
args: { selfId: '10001', pluginKey: 'sample' },
method: 'bindEventPlugin',
pluginKey: 'sample',
}),
).resolves.toEqual({ ok: true, value: true });
await expect(
bridge.handleHostCall(descriptor, {
args: { input: sendInput },
method: 'sendText',
pluginKey: 'sample',
}),
).resolves.toEqual({ ok: true, value: { messageId: 'msg-1' } });
expect(accountService.bindEventPlugin).toHaveBeenCalledWith(
'10001',
'sample',
);
expect(sendService.sendText).toHaveBeenCalledWith(sendInput);
});
it('reads JSON files from descriptor package roots', async () => {
const descriptor = createDescriptor();
const filePath = join(tempRoot, 'data', 'sample.json');
mkdirSync(join(tempRoot, 'data'), { recursive: true });
writeFileSync(filePath, '{"enabled":true}\n');
await expect(
bridge.handleHostCall(descriptor, {
args: { path: 'data/sample.json' },
method: 'readJsonFile',
pluginKey: 'sample',
}),
).resolves.toEqual({ ok: true, value: { enabled: true } });
});
/**
* Creates a descriptor fixture rooted at this test's temporary package directory.
* @returns Descriptor used to constrain host bridge file-system access.
*/
function createDescriptor(): QqbotPluginPackageDescriptor {
return {
entry: 'src/index.ts',
entryFile: join(tempRoot, 'src', 'index.ts'),
manifest: {
key: 'sample',
pluginKey: 'sample',
name: 'Sample',
version: '1.0.0',
minApiSdkVersion: '1.0.0',
entry: 'src/index.ts',
runtime: {
configKeys: ['SAMPLE_TOKEN'],
maxConcurrency: 1,
memoryMb: 128,
timeoutMs: 5000,
workerType: 'thread',
},
operations: [],
events: [],
tasks: [],
assets: [],
configSchema: {},
legacyAliases: [],
migrations: [],
permissions: [],
},
packageRoot: tempRoot,
pluginKey: 'sample',
};
}
});

View File

@ -1,15 +1,20 @@
import type { ConfigService } from '@nestjs/config';
import {
createQqbotBullmqWorkerQueueOptions,
QqbotPluginRuntimeError,
QqbotPluginWorkerRuntime,
QqbotPluginWorkerExpiredRequestError,
QqbotPluginWorkerResponseError,
QqbotPluginWorkerRuntime,
} from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime';
import {
createQqbotBullmqWorkerQueueOptions,
resolveQqbotPluginQueueConnection,
type QqbotPluginWorkerDriver,
type QqbotPluginWorkerRequest,
type QqbotPluginWorkerRequestQueue,
} from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime';
} from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/bullmq-plugin-worker-request.queue';
import type {
QqbotPluginWorkerDriver,
QqbotPluginWorkerRequest,
QqbotPluginWorkerRequestQueue,
QqbotPluginWorkerRuntimeOptions,
} from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.types';
import { createQqbotPluginSdk } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/sdk';
class RecordingDriver implements QqbotPluginWorkerDriver {
@ -209,6 +214,48 @@ const createRuntime = (driver = new RecordingDriver()) => {
};
describe('QQBot plugin worker runtime', () => {
it('accepts a package descriptor in worker runtime options', () => {
const options: QqbotPluginWorkerRuntimeOptions = {
defaultTimeoutMs: 5000,
descriptor: {
entry: 'src/index.ts',
entryFile: 'D:/repo/src/modules/qqbot/plugins/sample/src/index.ts',
manifest: {
key: 'sample',
pluginKey: 'sample',
name: 'Sample',
version: '1.0.0',
minApiSdkVersion: '1.0.0',
entry: 'src/index.ts',
runtime: {
configKeys: ['SAMPLE_TOKEN'],
maxConcurrency: 1,
memoryMb: 128,
timeoutMs: 5000,
workerType: 'thread',
},
operations: [],
events: [],
tasks: [],
assets: [],
configSchema: {},
legacyAliases: [],
migrations: [],
permissions: [],
},
packageRoot: 'D:/repo/src/modules/qqbot/plugins/sample',
pluginKey: 'sample',
},
installationId: 'install-1',
pluginKey: 'sample',
};
expect(options.descriptor.manifest.runtime.configKeys).toEqual([
'SAMPLE_TOKEN',
]);
expect(options.descriptor.pluginKey).toBe('sample');
});
it('serializes concurrent worker execution requests for one plugin runtime', async () => {
const releaseFirst = createDeferred<void>();
const releaseSecond = createDeferred<void>();
@ -669,7 +716,7 @@ describe('QQBot plugin worker runtime', () => {
const driver: QqbotPluginWorkerDriver = {
dispose: jest.fn(async () => undefined),
request: jest.fn(
() => new Promise((resolve) => setTimeout(resolve, 1000)),
() => new Promise((resolve) => setTimeout(resolve, 100)),
),
};
const runtime = new QqbotPluginWorkerRuntime(