feat: 增加通用插件host bridge
This commit is contained in:
parent
8b50b57f27
commit
9d0935897b
@ -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` 字段生成结果。
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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 };
|
||||
@ -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;
|
||||
};
|
||||
|
||||
197
test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts
Normal file
197
test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
});
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user