refactor: 收口 BangDream Tsugu 执行流水线

This commit is contained in:
sunlei 2026-06-06 21:48:13 +08:00
parent 4eec2d6d05
commit 5d97b8822a
4 changed files with 207 additions and 38 deletions

View File

@ -394,8 +394,9 @@ export interface TsuguHook {
- 已新增 `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 触发和错误字符串化。
- `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 改造后验证脚本回退到旧链路。
- 已新增 `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`。
## 文件迁移优先级

View File

@ -7,21 +7,33 @@ import type {
} from '../qqbot-bangdream.types';
import { waitForMainDataReady } from '../tsugu/models/main-data-store';
import {
createTsuguHookContext,
createTsuguLogHook,
TsuguHookRegistry,
} from '../tsugu/runtime/hook-registry';
import { getBangDreamOperationDefinition } from '../tsugu/runtime/operation-registry';
import { TsuguOperationPipeline } from '../tsugu/runtime/operation-pipeline';
import { QqbotBangDreamRendererService } from './qqbot-bangdream-renderer.service';
@Injectable()
export class TsuguApplicationService implements OnApplicationBootstrap {
private readonly hookRegistry = new TsuguHookRegistry([createTsuguLogHook()]);
private readonly operationPipeline: TsuguOperationPipeline;
constructor(
private readonly rendererService: QqbotBangDreamRendererService,
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() {
await this.rendererService.refreshDictionaryCache();
@ -35,40 +47,6 @@ export class TsuguApplicationService implements OnApplicationBootstrap {
operationKey: QqbotBangDreamOperationKey,
input: QqbotBangDreamCommandInput,
): Promise<QqbotBangDreamCommandOutput> {
const context = createTsuguHookContext(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);
}
return await this.operationPipeline.run(operationKey, input);
}
}

View File

@ -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);
}
}
}

View 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:图片渲染失败']);
});
});