refactor: 收口 BangDream Tsugu 执行流水线
This commit is contained in:
parent
4eec2d6d05
commit
5d97b8822a
@ -394,8 +394,9 @@ export interface TsuguHook {
|
|||||||
- 已新增 `tsugu/runtime/hook-registry.ts`,定义 `TsuguHookContext`、`TsuguHook`、`TsuguHookRegistry` 和默认 `TsuguLogHook`,命令执行日志包含 `operation`、`stage`、`handler`、`query`、`imageCount`、`durationMs` 和错误字符串。
|
- 已新增 `tsugu/runtime/hook-registry.ts`,定义 `TsuguHookContext`、`TsuguHook`、`TsuguHookRegistry` 和默认 `TsuguLogHook`,命令执行日志包含 `operation`、`stage`、`handler`、`query`、`imageCount`、`durationMs` 和错误字符串。
|
||||||
- 已新增 `renderer/tsugu-application.service.ts` 作为 BangDream Tsugu 应用入口,统一执行主数据 ready 等待、operation registry 查找、handler 调度、hook 触发和错误字符串化。
|
- 已新增 `renderer/tsugu-application.service.ts` 作为 BangDream Tsugu 应用入口,统一执行主数据 ready 等待、operation registry 查找、handler 调度、hook 触发和错误字符串化。
|
||||||
- `QqbotBangDreamClientService` 已改为注入 `TsuguApplicationService`,不再直接调用 renderer;`QqbotBangDreamRendererService` 移除 operation key 调度,只保留字典刷新、健康检查、渲染选项解析和 15 个具体 handler。
|
- `QqbotBangDreamClientService` 已改为注入 `TsuguApplicationService`,不再直接调用 renderer;`QqbotBangDreamRendererService` 移除 operation key 调度,只保留字典刷新、健康检查、渲染选项解析和 15 个具体 handler。
|
||||||
|
- 已新增 `tsugu/runtime/operation-pipeline.ts`,把主数据 ready、operation resolve、handler render、output hook 和 error hook 固定为 `TsuguOperationPipeline.run`,`TsuguApplicationService.execute` 只负责调用 pipeline。
|
||||||
- `scripts/bangdream-render-smoke.ps1` 已切到 `TsuguApplicationService`,本地 smoke 继续走真实应用入口,避免 facade 改造后验证脚本回退到旧链路。
|
- `scripts/bangdream-render-smoke.ps1` 已切到 `TsuguApplicationService`,本地 smoke 继续走真实应用入口,避免 facade 改造后验证脚本回退到旧链路。
|
||||||
- 已新增 `hook-registry.spec.ts` 和 `tsugu-application.service.spec.ts`,覆盖 hook 顺序、错误 hook、应用入口执行、字典刷新和错误字符串化;本地 Phase 7 smoke 已生成 `phase7-hook-event-50.jpg`、`phase7-hook-cutoff-detail-100-50-cn.jpg`、`phase7-hook-cutoff-recent-100-50-cn.jpg`。
|
- 已新增 `hook-registry.spec.ts`、`operation-pipeline.spec.ts` 和 `tsugu-application.service.spec.ts`,覆盖 hook 顺序、错误 hook、pipeline 成功/未知 operation/handler 错误、应用入口执行、字典刷新和错误字符串化;本地 Phase 7 smoke 已生成 `phase7-hook-event-50.jpg`、`phase7-hook-cutoff-detail-100-50-cn.jpg`、`phase7-hook-cutoff-recent-100-50-cn.jpg`、`phase7-pipeline-event-50.jpg`、`phase7-pipeline-cutoff-detail-100-50-cn.jpg`、`phase7-pipeline-cutoff-recent-100-50-cn.jpg`。
|
||||||
|
|
||||||
## 文件迁移优先级
|
## 文件迁移优先级
|
||||||
|
|
||||||
|
|||||||
@ -7,21 +7,33 @@ import type {
|
|||||||
} from '../qqbot-bangdream.types';
|
} from '../qqbot-bangdream.types';
|
||||||
import { waitForMainDataReady } from '../tsugu/models/main-data-store';
|
import { waitForMainDataReady } from '../tsugu/models/main-data-store';
|
||||||
import {
|
import {
|
||||||
createTsuguHookContext,
|
|
||||||
createTsuguLogHook,
|
createTsuguLogHook,
|
||||||
TsuguHookRegistry,
|
TsuguHookRegistry,
|
||||||
} from '../tsugu/runtime/hook-registry';
|
} from '../tsugu/runtime/hook-registry';
|
||||||
import { getBangDreamOperationDefinition } from '../tsugu/runtime/operation-registry';
|
import { getBangDreamOperationDefinition } from '../tsugu/runtime/operation-registry';
|
||||||
|
import { TsuguOperationPipeline } from '../tsugu/runtime/operation-pipeline';
|
||||||
import { QqbotBangDreamRendererService } from './qqbot-bangdream-renderer.service';
|
import { QqbotBangDreamRendererService } from './qqbot-bangdream-renderer.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TsuguApplicationService implements OnApplicationBootstrap {
|
export class TsuguApplicationService implements OnApplicationBootstrap {
|
||||||
private readonly hookRegistry = new TsuguHookRegistry([createTsuguLogHook()]);
|
private readonly hookRegistry = new TsuguHookRegistry([createTsuguLogHook()]);
|
||||||
|
private readonly operationPipeline: TsuguOperationPipeline;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly rendererService: QqbotBangDreamRendererService,
|
private readonly rendererService: QqbotBangDreamRendererService,
|
||||||
private readonly toolsService: ToolsService,
|
private readonly toolsService: ToolsService,
|
||||||
) {}
|
) {
|
||||||
|
this.operationPipeline = new TsuguOperationPipeline({
|
||||||
|
executeHandler: async (handlerName, input) =>
|
||||||
|
await this.rendererService.executeOperationHandler(handlerName, input),
|
||||||
|
hookRegistry: this.hookRegistry,
|
||||||
|
normalizeError: (error) =>
|
||||||
|
this.toolsService.getErrorMessage(error, 'BangDream 命令执行失败'),
|
||||||
|
resolveOperation: (operationKey) =>
|
||||||
|
getBangDreamOperationDefinition(operationKey),
|
||||||
|
waitForReady: async () => await waitForMainDataReady(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
await this.rendererService.refreshDictionaryCache();
|
await this.rendererService.refreshDictionaryCache();
|
||||||
@ -35,40 +47,6 @@ export class TsuguApplicationService implements OnApplicationBootstrap {
|
|||||||
operationKey: QqbotBangDreamOperationKey,
|
operationKey: QqbotBangDreamOperationKey,
|
||||||
input: QqbotBangDreamCommandInput,
|
input: QqbotBangDreamCommandInput,
|
||||||
): Promise<QqbotBangDreamCommandOutput> {
|
): Promise<QqbotBangDreamCommandOutput> {
|
||||||
const context = createTsuguHookContext(operationKey, input);
|
return await this.operationPipeline.run(operationKey, input);
|
||||||
await this.hookRegistry.beforeParse(context);
|
|
||||||
|
|
||||||
try {
|
|
||||||
context.stage = 'mainData';
|
|
||||||
await waitForMainDataReady();
|
|
||||||
|
|
||||||
context.stage = 'operation';
|
|
||||||
const operation = getBangDreamOperationDefinition(operationKey);
|
|
||||||
if (!operation) {
|
|
||||||
throw new Error(`BangDream 插件能力不存在:${operationKey}`);
|
|
||||||
}
|
|
||||||
context.handlerName = operation.handlerName;
|
|
||||||
await this.hookRegistry.afterResolve(context);
|
|
||||||
|
|
||||||
context.stage = 'handler';
|
|
||||||
await this.hookRegistry.beforeRender(context);
|
|
||||||
const output = await this.rendererService.executeOperationHandler(
|
|
||||||
operation.handlerName,
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
|
|
||||||
context.stage = 'output';
|
|
||||||
context.imageCount = output.imageCount;
|
|
||||||
context.query = output.query || context.query;
|
|
||||||
await this.hookRegistry.afterOutput(context);
|
|
||||||
return output;
|
|
||||||
} catch (err) {
|
|
||||||
const message = this.toolsService.getErrorMessage(
|
|
||||||
err,
|
|
||||||
'BangDream 命令执行失败',
|
|
||||||
);
|
|
||||||
await this.hookRegistry.onError(context, message);
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,71 @@
|
|||||||
|
import type {
|
||||||
|
QqbotBangDreamCommandInput,
|
||||||
|
QqbotBangDreamCommandOutput,
|
||||||
|
QqbotBangDreamOperationKey,
|
||||||
|
} from '../../qqbot-bangdream.types';
|
||||||
|
import type {
|
||||||
|
QqbotBangDreamOperationHandlerName,
|
||||||
|
TsuguOperationDefinition,
|
||||||
|
} from './operation-registry';
|
||||||
|
import {
|
||||||
|
createTsuguHookContext,
|
||||||
|
type TsuguHookRegistry,
|
||||||
|
} from './hook-registry';
|
||||||
|
|
||||||
|
export type TsuguOperationPipelineOptions = {
|
||||||
|
executeHandler: (
|
||||||
|
handlerName: QqbotBangDreamOperationHandlerName,
|
||||||
|
input: QqbotBangDreamCommandInput,
|
||||||
|
) => Promise<QqbotBangDreamCommandOutput>;
|
||||||
|
hookRegistry: TsuguHookRegistry;
|
||||||
|
normalizeError: (error: unknown) => string;
|
||||||
|
resolveOperation: (
|
||||||
|
operationKey: QqbotBangDreamOperationKey,
|
||||||
|
) => TsuguOperationDefinition | undefined;
|
||||||
|
waitForReady: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 串联 Tsugu 命令执行阶段:准备主数据、解析 operation、调用渲染 handler、输出 hook。
|
||||||
|
*/
|
||||||
|
export class TsuguOperationPipeline {
|
||||||
|
constructor(private readonly options: TsuguOperationPipelineOptions) {}
|
||||||
|
|
||||||
|
async run(
|
||||||
|
operationKey: QqbotBangDreamOperationKey,
|
||||||
|
input: QqbotBangDreamCommandInput,
|
||||||
|
) {
|
||||||
|
const context = createTsuguHookContext(operationKey, input);
|
||||||
|
await this.options.hookRegistry.beforeParse(context);
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.stage = 'mainData';
|
||||||
|
await this.options.waitForReady();
|
||||||
|
|
||||||
|
context.stage = 'operation';
|
||||||
|
const operation = this.options.resolveOperation(operationKey);
|
||||||
|
if (!operation) {
|
||||||
|
throw new Error(`BangDream 插件能力不存在:${operationKey}`);
|
||||||
|
}
|
||||||
|
context.handlerName = operation.handlerName;
|
||||||
|
await this.options.hookRegistry.afterResolve(context);
|
||||||
|
|
||||||
|
context.stage = 'handler';
|
||||||
|
await this.options.hookRegistry.beforeRender(context);
|
||||||
|
const output = await this.options.executeHandler(
|
||||||
|
operation.handlerName,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
|
||||||
|
context.stage = 'output';
|
||||||
|
context.imageCount = output.imageCount;
|
||||||
|
context.query = output.query || context.query;
|
||||||
|
await this.options.hookRegistry.afterOutput(context);
|
||||||
|
return output;
|
||||||
|
} catch (error) {
|
||||||
|
const message = this.options.normalizeError(error);
|
||||||
|
await this.options.hookRegistry.onError(context, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
test/qqbot/plugins/bangDream/tsugu/operation-pipeline.spec.ts
Normal file
119
test/qqbot/plugins/bangDream/tsugu/operation-pipeline.spec.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { TsuguHookRegistry } from '@/qqbot/plugins/bangDream/tsugu/runtime/hook-registry';
|
||||||
|
import { TsuguOperationPipeline } from '@/qqbot/plugins/bangDream/tsugu/runtime/operation-pipeline';
|
||||||
|
import type { TsuguOperationDefinition } from '@/qqbot/plugins/bangDream/tsugu/runtime/operation-registry';
|
||||||
|
|
||||||
|
const songSearchOperation: TsuguOperationDefinition = {
|
||||||
|
description: '查询歌曲',
|
||||||
|
handlerName: 'searchSong',
|
||||||
|
key: 'bangdream.song.search',
|
||||||
|
name: '查曲',
|
||||||
|
onlineCommand: {
|
||||||
|
aliases: ['查曲'],
|
||||||
|
cooldownMs: 1500,
|
||||||
|
remark: '查询歌曲',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TsuguOperationPipeline', () => {
|
||||||
|
it('runs ready check, operation resolve, handler, and output hooks', async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const hookRegistry = new TsuguHookRegistry([
|
||||||
|
{
|
||||||
|
afterOutput: (context) => {
|
||||||
|
events.push(`afterOutput:${context.stage}:${context.imageCount}`);
|
||||||
|
},
|
||||||
|
afterResolve: (context) => {
|
||||||
|
events.push(`afterResolve:${context.stage}:${context.handlerName}`);
|
||||||
|
},
|
||||||
|
beforeParse: (context) => {
|
||||||
|
events.push(`beforeParse:${context.stage}:${context.query}`);
|
||||||
|
},
|
||||||
|
beforeRender: (context) => {
|
||||||
|
events.push(`beforeRender:${context.stage}:${context.handlerName}`);
|
||||||
|
},
|
||||||
|
name: 'recorder',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const pipeline = new TsuguOperationPipeline({
|
||||||
|
executeHandler: async (handlerName, input) => {
|
||||||
|
events.push(`handler:${handlerName}:${input.text}`);
|
||||||
|
return {
|
||||||
|
imageCount: 1,
|
||||||
|
operationKey: 'bangdream.song.search',
|
||||||
|
query: '夏祭り',
|
||||||
|
replyText: '[CQ:image,file=base64://base64-song-card]',
|
||||||
|
source: 'Tsugu BangDream Bot 内置源码',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
hookRegistry,
|
||||||
|
normalizeError: (error) => `${error}`,
|
||||||
|
resolveOperation: () => songSearchOperation,
|
||||||
|
waitForReady: async () => {
|
||||||
|
events.push('ready');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = await pipeline.run('bangdream.song.search', {
|
||||||
|
text: '夏祭り',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(output.imageCount).toBe(1);
|
||||||
|
expect(events).toEqual([
|
||||||
|
'beforeParse:start:夏祭り',
|
||||||
|
'ready',
|
||||||
|
'afterResolve:operation:searchSong',
|
||||||
|
'beforeRender:handler:searchSong',
|
||||||
|
'handler:searchSong:夏祭り',
|
||||||
|
'afterOutput:output:1',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes unknown operation errors and emits error hook', async () => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const pipeline = new TsuguOperationPipeline({
|
||||||
|
executeHandler: jest.fn(),
|
||||||
|
hookRegistry: new TsuguHookRegistry([
|
||||||
|
{
|
||||||
|
name: 'error-recorder',
|
||||||
|
onError: (context, error) => {
|
||||||
|
errors.push(`${context.stage}:${error}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
normalizeError: (error) =>
|
||||||
|
error instanceof Error ? error.message : `${error}`,
|
||||||
|
resolveOperation: () => undefined,
|
||||||
|
waitForReady: jest.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
pipeline.run('bangdream.song.search', { text: '夏祭り' }),
|
||||||
|
).rejects.toThrow('BangDream 插件能力不存在:bangdream.song.search');
|
||||||
|
expect(errors).toEqual([
|
||||||
|
'operation:BangDream 插件能力不存在:bangdream.song.search',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes handler errors at handler stage', async () => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const pipeline = new TsuguOperationPipeline({
|
||||||
|
executeHandler: jest.fn().mockRejectedValue('图片渲染失败'),
|
||||||
|
hookRegistry: new TsuguHookRegistry([
|
||||||
|
{
|
||||||
|
name: 'error-recorder',
|
||||||
|
onError: (context, error) => {
|
||||||
|
errors.push(`${context.stage}:${context.handlerName}:${error}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
normalizeError: (error) => `${error}`,
|
||||||
|
resolveOperation: () => songSearchOperation,
|
||||||
|
waitForReady: jest.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
pipeline.run('bangdream.song.search', { text: '夏祭り' }),
|
||||||
|
).rejects.toThrow('图片渲染失败');
|
||||||
|
expect(errors).toEqual(['handler:searchSong:图片渲染失败']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user