feat: 增加QQBot插件定时任务manifest契约

This commit is contained in:
sunlei 2026-06-17 09:09:13 +08:00
parent 24b63c7332
commit 250bb654f0
6 changed files with 249 additions and 0 deletions

View File

@ -0,0 +1,2 @@
export * from './qqbot-plugin-task-cron.validator';
export * from './qqbot-plugin-task.types';

View File

@ -0,0 +1,28 @@
import { throwVbenError } from '@/common';
const fieldPattern = /^[\d*/,\-]+$/;
export function normalizeQqbotPluginTaskCron(input: unknown): string {
const value = `${input || ''}`.trim().replace(/\s+/g, ' ');
const fields = value.split(' ').filter(Boolean);
if (fields.length !== 5) {
throw new Error('定时任务 cron 必须是 5 段表达式');
}
if (!fields.every((field) => fieldPattern.test(field))) {
throw new Error('定时任务 cron 只能包含数字、星号、斜杠、逗号和横线');
}
if (fields[0] === '*') {
throw new Error('定时任务 cron 不允许每分钟执行');
}
return fields.join(' ');
}
export function requireQqbotPluginTaskCron(input: unknown): string {
try {
return normalizeQqbotPluginTaskCron(input);
} catch (error) {
throwVbenError(
error instanceof Error ? error.message : '定时任务 cron 不合法',
);
}
}

View File

@ -0,0 +1,33 @@
export type QqbotPluginTaskRuntimeStatus =
| 'disabled'
| 'failed'
| 'idle'
| 'running'
| 'scheduled';
export type QqbotPluginTaskRunStatus =
| 'failed'
| 'running'
| 'skipped'
| 'success';
export type QqbotPluginTaskTriggerType = 'bootstrap' | 'manual' | 'schedule';
export type QqbotPluginTaskPageQuery = {
enabled?: boolean | string;
pageNo?: number | string;
pageSize?: number | string;
pluginId?: string;
pluginKey?: string;
status?: QqbotPluginTaskRuntimeStatus;
taskKey?: string;
};
export type QqbotPluginTaskRunPageQuery = {
endTime?: string;
pageNo?: number | string;
pageSize?: number | string;
startTime?: string;
status?: QqbotPluginTaskRunStatus;
triggerType?: QqbotPluginTaskTriggerType;
};

View File

@ -1,4 +1,5 @@
import * as path from 'path';
import { normalizeQqbotPluginTaskCron } from '../../application/task';
import {
QQBOT_PLUGIN_ALLOWED_PERMISSIONS,
QQBOT_PLUGIN_WORKER_TYPES,
@ -11,6 +12,7 @@ import {
type QqbotPluginOperationManifest,
type QqbotPluginPermission,
type QqbotPluginRuntimeManifest,
type QqbotPluginTaskManifest,
type QqbotPluginWorkerType,
} from './manifest.types';
@ -317,6 +319,74 @@ const parseEvents = (
});
};
const parseTasks = (
source: Record<string, unknown>,
issues: QqbotPluginManifestValidationIssue[],
): QqbotPluginTaskManifest[] => {
const tasks = Array.isArray(source.tasks) ? source.tasks : [];
const seenKeys = new Set<string>();
return tasks.filter(isPlainObject).map((task, index) => {
const pathPrefix = `tasks[${index}]`;
const key = getString(task, 'key') || '';
const timeoutMs = getNumber(task, 'timeoutMs');
let defaultCron = getString(task, 'defaultCron') || '';
requireKey(key, `${pathPrefix}.key`, issues);
if (seenKeys.has(key)) {
pushIssue(
issues,
'DUPLICATE_TASK_KEY',
pathPrefix,
`Duplicate task key: ${key}.`,
);
}
seenKeys.add(key);
if (!getString(task, 'handlerName')) {
pushIssue(
issues,
'MISSING_TASK_HANDLER',
`${pathPrefix}.handlerName`,
'Task handlerName is required.',
);
}
if (!timeoutMs) {
pushIssue(
issues,
'MISSING_TASK_TIMEOUT',
`${pathPrefix}.timeoutMs`,
'Task timeoutMs is required.',
);
}
try {
defaultCron = normalizeQqbotPluginTaskCron(defaultCron);
} catch (error) {
pushIssue(
issues,
'INVALID_TASK_CRON',
`${pathPrefix}.defaultCron`,
error instanceof Error ? error.message : 'Task cron is invalid.',
);
}
return {
defaultCron,
description: getString(task, 'description'),
enabled: task.enabled !== false,
handlerName: getString(task, 'handlerName') || '',
key,
name: getString(task, 'name') || key,
permissions: normalizePermissions(
task.permissions,
`${pathPrefix}.permissions`,
issues,
),
timeoutMs: timeoutMs || 1000,
};
});
};
const parseAssets = (
source: Record<string, unknown>,
issues: QqbotPluginManifestValidationIssue[],
@ -435,6 +505,7 @@ export const parseQqbotPluginManifest = (
),
pluginKey,
runtime: parseRuntime(manifestLike, issues),
tasks: parseTasks(manifestLike, issues),
version,
};

View File

@ -47,6 +47,17 @@ export type QqbotPluginEventManifest = {
name: string;
};
export type QqbotPluginTaskManifest = {
defaultCron: string;
description?: string;
enabled: boolean;
handlerName: string;
key: string;
name: string;
permissions: QqbotPluginPermission[];
timeoutMs: number;
};
export type QqbotPluginAssetManifest = {
contentHash?: string;
key: string;
@ -75,6 +86,7 @@ export type QqbotPluginManifest = {
permissions: QqbotPluginPermission[];
pluginKey: string;
runtime: QqbotPluginRuntimeManifest;
tasks: QqbotPluginTaskManifest[];
version: string;
};

View File

@ -0,0 +1,103 @@
import {
parseQqbotPluginManifest,
QqbotPluginManifestValidationError,
} from '../../../../src/modules/qqbot/plugin-platform/domain/manifest';
import { normalizeQqbotPluginTaskCron } from '../../../../src/modules/qqbot/plugin-platform/application/task';
const createManifestWithTask = () => ({
assets: [],
configSchema: { type: 'object' },
entry: 'src/index.ts',
events: [],
legacyAliases: [],
migrations: [],
minApiSdkVersion: '1.0.0',
name: 'BangDream',
operations: [],
permissions: ['runtime.http', 'plugin.storage.read', 'plugin.storage.write'],
pluginKey: 'bangdream',
runtime: {
maxConcurrency: 1,
memoryMb: 512,
timeoutMs: 30000,
workerType: 'node-worker',
},
tasks: [
{
defaultCron: '0 */6 * * *',
description: '同步 BangDream 主数据',
enabled: true,
handlerName: 'syncBestdoriMainData',
key: 'bangdream.bestdori.sync-main-data',
name: '同步 Bestdori 主数据',
permissions: [
'runtime.http',
'plugin.storage.read',
'plugin.storage.write',
],
timeoutMs: 120000,
},
],
version: '2.0.0',
});
describe('QQBot plugin task manifest contract', () => {
it('parses manifest tasks and normalizes cron whitespace', () => {
const manifest = createManifestWithTask();
manifest.tasks[0].defaultCron = ' 0 */6 * * * ';
const parsed = parseQqbotPluginManifest(manifest);
expect(parsed.tasks).toEqual([
expect.objectContaining({
defaultCron: '0 */6 * * *',
enabled: true,
handlerName: 'syncBestdoriMainData',
key: 'bangdream.bestdori.sync-main-data',
timeoutMs: 120000,
}),
]);
expect(normalizeQqbotPluginTaskCron('0 */6 * * *')).toBe('0 */6 * * *');
});
it('rejects invalid task metadata', () => {
const manifest = createManifestWithTask();
manifest.tasks.push({
...manifest.tasks[0],
handlerName: 'syncBestdoriMainDataAgain',
});
manifest.tasks.push({
...manifest.tasks[0],
handlerName: '',
key: 'BangDream.Bad',
permissions: ['host.fs.read'],
timeoutMs: undefined,
} as any);
expect(() => parseQqbotPluginManifest(manifest)).toThrow(
QqbotPluginManifestValidationError,
);
try {
parseQqbotPluginManifest(manifest);
} catch (error) {
expect((error as QqbotPluginManifestValidationError).issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'DUPLICATE_TASK_KEY' }),
expect.objectContaining({ code: 'INVALID_CAPABILITY_KEY' }),
expect.objectContaining({ code: 'MISSING_TASK_HANDLER' }),
expect.objectContaining({ code: 'MISSING_TASK_TIMEOUT' }),
expect.objectContaining({ code: 'UNKNOWN_PERMISSION' }),
]),
);
}
});
it('rejects six-field cron and too-frequent task cron', () => {
expect(() => normalizeQqbotPluginTaskCron('* * * * * *')).toThrow(
'定时任务 cron 必须是 5 段表达式',
);
expect(() => normalizeQqbotPluginTaskCron('* * * * *')).toThrow(
'定时任务 cron 不允许每分钟执行',
);
});
});