refactor: 接入 BangDream Tsugu 执行入口

This commit is contained in:
sunlei 2026-06-06 21:39:37 +08:00
parent 47a48fb311
commit 4eec2d6d05
10 changed files with 407 additions and 38 deletions

View File

@ -389,6 +389,14 @@ export interface TsuguHook {
- 每次命令执行能记录 operation、耗时、图片数、错误。
- 线上日志能定位 BangDream 命令的具体失败阶段。
当前进度:
- 已新增 `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。
- `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`。
## 文件迁移优先级
| 优先级 | 文件/目录 | 原因 |

View File

@ -43,10 +43,13 @@ const fs = require("fs");
const path = require("path");
const { ConfigService } = require("@nestjs/config");
const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8"));
const { ToolsService } = require("./src/common/services/tool.service");
const { QqbotBangDreamRendererService } = require("./src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service");
const { TsuguApplicationService } = require("./src/qqbot/plugins/bangDream/renderer/tsugu-application.service");
(async () => {
const service = new QqbotBangDreamRendererService(new ConfigService({}), undefined);
const renderer = new QqbotBangDreamRendererService(new ConfigService({}), undefined);
const service = new TsuguApplicationService(renderer, new ToolsService());
await service.onApplicationBootstrap();
const result = await service.execute(payload.operationKey, payload.input);
const match = result.replyText.match(/base64:\/\/([A-Za-z0-9+/=]+)/);

View File

@ -5,28 +5,31 @@ import type {
QqbotBangDreamSongSearchInput,
QqbotBangDreamSongSummary,
} from './qqbot-bangdream.types';
import { QqbotBangDreamRendererService } from './renderer/qqbot-bangdream-renderer.service';
import { TsuguApplicationService } from './renderer/tsugu-application.service';
@Injectable()
export class QqbotBangDreamClientService {
constructor(
private readonly rendererService: QqbotBangDreamRendererService,
private readonly tsuguApplicationService: TsuguApplicationService,
) {}
async checkHealth() {
return this.rendererService.checkHealth();
return this.tsuguApplicationService.checkHealth();
}
async execute(
operationKey: QqbotBangDreamOperationKey,
input: QqbotBangDreamCommandInput,
) {
return await this.rendererService.execute(operationKey, input);
return await this.tsuguApplicationService.execute(operationKey, input);
}
async searchSong(
params: QqbotBangDreamSongSearchInput,
): Promise<QqbotBangDreamSongSummary> {
return await this.rendererService.execute('bangdream.song.search', params);
return await this.tsuguApplicationService.execute(
'bangdream.song.search',
params,
);
}
}

View File

@ -1,4 +1,4 @@
import { Injectable, OnApplicationBootstrap, Optional } from '@nestjs/common';
import { Injectable, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DictService } from '@/admin/dict/dict.service';
import { Card } from '../tsugu/models/card';
@ -8,7 +8,10 @@ import { Gacha, getPresentGachaList } from '../tsugu/models/gacha';
import { Server } from '../tsugu/models/server';
import { Song } from '../tsugu/models/song';
import mainAPI, { waitForMainDataReady } from '../tsugu/models/main-data-store';
import { fuzzySearch, type FuzzySearchResult } from '../tsugu/search/fuzzy-search';
import {
fuzzySearch,
type FuzzySearchResult,
} from '../tsugu/search/fuzzy-search';
import { drawCardDetail } from '../tsugu/command-renderers/card-detail';
import { drawCardList } from '../tsugu/command-renderers/card-list';
import { drawCharacterDetail } from '../tsugu/command-renderers/character-detail';
@ -32,7 +35,7 @@ import {
BangDreamDictionaryLoader,
type BangDreamDictionaryItem,
} from '../tsugu/runtime/dictionary-loader';
import { getBangDreamOperationDefinition } from '../tsugu/runtime/operation-registry';
import type { QqbotBangDreamOperationHandlerName } from '../tsugu/runtime/operation-registry';
import {
BANGDREAM_TSUGU_ENV_KEYS,
normalizeBangDreamBoolean,
@ -47,7 +50,7 @@ import type {
const SOURCE_NAME = 'Tsugu BangDream Bot 内置源码';
@Injectable()
export class QqbotBangDreamRendererService implements OnApplicationBootstrap {
export class QqbotBangDreamRendererService {
private readonly dictionaryLoader = new BangDreamDictionaryLoader();
constructor(
@ -56,10 +59,6 @@ export class QqbotBangDreamRendererService implements OnApplicationBootstrap {
private readonly dictService?: DictService,
) {}
async onApplicationBootstrap() {
await this.refreshDictionaryCache();
}
async refreshDictionaryCache() {
await this.dictionaryLoader.refresh((dictCode) =>
this.fetchDictionaryItems(dictCode),
@ -76,18 +75,13 @@ export class QqbotBangDreamRendererService implements OnApplicationBootstrap {
return true;
}
async execute(
operationKey: QqbotBangDreamOperationKey,
async executeOperationHandler(
handlerName: QqbotBangDreamOperationHandlerName,
input: QqbotBangDreamCommandInput,
) {
await waitForMainDataReady();
const operation = getBangDreamOperationDefinition(operationKey);
if (!operation) {
throw new Error(`BangDream 插件能力不存在:${operationKey}`);
}
const handler = this[operation.handlerName];
const handler = this[handlerName];
if (typeof handler !== 'function') {
throw new Error(`BangDream 插件能力未绑定执行器:${operationKey}`);
throw new Error(`BangDream 插件能力未绑定执行器:${handlerName}`);
}
return await (
handler as (
@ -434,9 +428,7 @@ export class QqbotBangDreamRendererService implements OnApplicationBootstrap {
private pickDisplayedServerList(input: QqbotBangDreamCommandInput) {
const source =
input.displayedServerList ||
this.configService.get<string>(
BANGDREAM_TSUGU_ENV_KEYS.displayedServers,
);
this.configService.get<string>(BANGDREAM_TSUGU_ENV_KEYS.displayedServers);
const defaultServers = this.dictionaryLoader.getDefaultDisplayedServers();
if (!source) return defaultServers;
const values = splitBangDreamOptionList(source);

View File

@ -0,0 +1,74 @@
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { ToolsService } from '@/common';
import type {
QqbotBangDreamCommandInput,
QqbotBangDreamCommandOutput,
QqbotBangDreamOperationKey,
} 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 { QqbotBangDreamRendererService } from './qqbot-bangdream-renderer.service';
@Injectable()
export class TsuguApplicationService implements OnApplicationBootstrap {
private readonly hookRegistry = new TsuguHookRegistry([createTsuguLogHook()]);
constructor(
private readonly rendererService: QqbotBangDreamRendererService,
private readonly toolsService: ToolsService,
) {}
async onApplicationBootstrap() {
await this.rendererService.refreshDictionaryCache();
}
async checkHealth() {
return await this.rendererService.checkHealth();
}
async execute(
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);
}
}
}

View File

@ -0,0 +1,148 @@
import type {
QqbotBangDreamCommandInput,
QqbotBangDreamOperationKey,
} from '../../qqbot-bangdream.types';
import { logger } from './logger';
export type TsuguExecutionStage =
| 'handler'
| 'mainData'
| 'operation'
| 'output'
| 'start';
export type TsuguHookContext = {
handlerName?: string;
imageCount?: number;
input: QqbotBangDreamCommandInput;
operationKey: QqbotBangDreamOperationKey;
query?: string;
stage: TsuguExecutionStage;
startedAt: number;
};
export type TsuguHook = {
afterOutput?: (context: TsuguHookContext) => Promise<void> | void;
afterResolve?: (context: TsuguHookContext) => Promise<void> | void;
beforeParse?: (context: TsuguHookContext) => Promise<void> | void;
beforeRender?: (context: TsuguHookContext) => Promise<void> | void;
name: string;
onError?: (context: TsuguHookContext, error: unknown) => Promise<void> | void;
order?: number;
};
type TsuguSimpleHookMethod =
| 'afterOutput'
| 'afterResolve'
| 'beforeParse'
| 'beforeRender';
/**
* Tsugu hook
*/
export class TsuguHookRegistry {
private readonly hooks: TsuguHook[];
constructor(hooks: readonly TsuguHook[] = []) {
this.hooks = [...hooks].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
async beforeParse(context: TsuguHookContext) {
await this.emit('beforeParse', context);
}
async afterResolve(context: TsuguHookContext) {
await this.emit('afterResolve', context);
}
async beforeRender(context: TsuguHookContext) {
await this.emit('beforeRender', context);
}
async afterOutput(context: TsuguHookContext) {
await this.emit('afterOutput', context);
}
async onError(context: TsuguHookContext, error: unknown) {
for (const hook of this.hooks) {
await hook.onError?.(context, error);
}
}
private async emit(method: TsuguSimpleHookMethod, context: TsuguHookContext) {
for (const hook of this.hooks) {
const handler = hook[method];
if (typeof handler === 'function') {
await handler(context);
}
}
}
}
/**
* hook
*/
export function createTsuguHookContext(
operationKey: QqbotBangDreamOperationKey,
input: QqbotBangDreamCommandInput,
): TsuguHookContext {
return {
input,
operationKey,
query: extractTsuguInputText(input),
stage: 'start',
startedAt: Date.now(),
};
}
/**
* hook
*/
export function createTsuguLogHook(): TsuguHook {
return {
afterOutput: (context) => {
logger('operation', formatTsuguHookMessage('success', context));
},
beforeParse: (context) => {
logger('operation', formatTsuguHookMessage('start', context));
},
name: 'TsuguLogHook',
onError: (context, error) => {
logger(
'operation',
`${formatTsuguHookMessage('error', context)} error=${getHookErrorMessage(error)}`,
);
},
};
}
function formatTsuguHookMessage(
status: 'error' | 'start' | 'success',
context: TsuguHookContext,
) {
const durationMs = Date.now() - context.startedAt;
return [
`status=${status}`,
`operation=${context.operationKey}`,
`stage=${context.stage}`,
context.handlerName ? `handler=${context.handlerName}` : '',
context.query ? `query=${context.query}` : '',
context.imageCount === undefined ? '' : `imageCount=${context.imageCount}`,
`durationMs=${durationMs}`,
]
.filter(Boolean)
.join(' ');
}
function extractTsuguInputText(input: QqbotBangDreamCommandInput) {
const direct = `${input.query || input.text || input.raw || ''}`.trim();
if (direct) return direct;
return Array.isArray(input.args) ? input.args.join(' ').trim() : '';
}
function getHookErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
if (error === undefined || error === null) return '';
return `${error}`;
}

View File

@ -41,6 +41,7 @@ import { QqbotPluginRegistryService } from './plugin/qqbot-plugin-registry.servi
import { QqbotBangDreamClientService } from './plugins/bangDream/qqbot-bangdream-client.service';
import { QqbotBangDreamPluginService } from './plugins/bangDream/qqbot-bangdream.plugin';
import { QqbotBangDreamRendererService } from './plugins/bangDream/renderer/qqbot-bangdream-renderer.service';
import { TsuguApplicationService } from './plugins/bangDream/renderer/tsugu-application.service';
import { QqbotFf14ClientService } from './plugins/ff14Market/qqbot-ff14-client.service';
import { QqbotFf14MarketPluginService } from './plugins/ff14Market/qqbot-ff14-market.plugin';
import { QqbotFflogsClientService } from './plugins/fflogs/qqbot-fflogs-client.service';
@ -100,6 +101,7 @@ import { QqbotSendService } from './send/qqbot-send.service';
QqbotBangDreamClientService,
QqbotBangDreamPluginService,
QqbotBangDreamRendererService,
TsuguApplicationService,
QqbotFf14ClientService,
QqbotFf14MarketPluginService,
QqbotFflogsClientService,

View File

@ -1,19 +1,19 @@
jest.mock(
'@/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service',
'@/qqbot/plugins/bangDream/renderer/tsugu-application.service',
() => ({
QqbotBangDreamRendererService: class QqbotBangDreamRendererService {},
TsuguApplicationService: class TsuguApplicationService {},
}),
);
import { QqbotBangDreamClientService } from '@/qqbot/plugins/bangDream/qqbot-bangdream-client.service';
import type { QqbotBangDreamRendererService } from '@/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service';
import type { TsuguApplicationService } from '@/qqbot/plugins/bangDream/renderer/tsugu-application.service';
describe('QqbotBangDreamClientService', () => {
let service: QqbotBangDreamClientService;
let rendererService: jest.Mocked<QqbotBangDreamRendererService>;
let tsuguApplicationService: jest.Mocked<TsuguApplicationService>;
beforeEach(() => {
rendererService = {
tsuguApplicationService = {
checkHealth: jest.fn().mockReturnValue(true),
execute: jest.fn().mockResolvedValue({
imageCount: 1,
@ -23,19 +23,19 @@ describe('QqbotBangDreamClientService', () => {
source: 'Tsugu BangDream Bot 内置源码',
}),
} as any;
service = new QqbotBangDreamClientService(rendererService);
service = new QqbotBangDreamClientService(tsuguApplicationService);
});
it('checks embedded Tsugu renderer health', async () => {
it('checks embedded Tsugu application health', async () => {
await expect(service.checkHealth()).resolves.toBe(true);
expect(rendererService.checkHealth).toHaveBeenCalledTimes(1);
expect(tsuguApplicationService.checkHealth).toHaveBeenCalledTimes(1);
});
it('searches song through embedded Tsugu renderer', async () => {
it('searches song through embedded Tsugu application facade', async () => {
const result = await service.searchSong({ text: '夏祭り' });
expect(rendererService.execute).toHaveBeenCalledWith(
expect(tsuguApplicationService.execute).toHaveBeenCalledWith(
'bangdream.song.search',
{
text: '夏祭り',
@ -52,7 +52,7 @@ describe('QqbotBangDreamClientService', () => {
it('executes other Tsugu open commands through the same plugin facade', async () => {
await service.execute('bangdream.card.search', { text: '1399' });
expect(rendererService.execute).toHaveBeenCalledWith(
expect(tsuguApplicationService.execute).toHaveBeenCalledWith(
'bangdream.card.search',
{
text: '1399',
@ -61,7 +61,7 @@ describe('QqbotBangDreamClientService', () => {
});
it('bubbles embedded Tsugu renderer errors as command errors', async () => {
rendererService.execute.mockRejectedValueOnce(
tsuguApplicationService.execute.mockRejectedValueOnce(
new Error('错误: 没有有效的关键词'),
);

View File

@ -0,0 +1,73 @@
jest.mock(
'@/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service',
() => ({
QqbotBangDreamRendererService: class QqbotBangDreamRendererService {},
}),
);
jest.mock('@/qqbot/plugins/bangDream/tsugu/models/main-data-store', () => ({
__esModule: true,
default: {},
waitForMainDataReady: jest.fn().mockResolvedValue(undefined),
}));
import { ToolsService } from '@/common';
import { TsuguApplicationService } from '@/qqbot/plugins/bangDream/renderer/tsugu-application.service';
import { waitForMainDataReady } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-store';
import type { QqbotBangDreamRendererService } from '@/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service';
describe('TsuguApplicationService', () => {
let rendererService: jest.Mocked<QqbotBangDreamRendererService>;
let service: TsuguApplicationService;
beforeEach(() => {
jest.clearAllMocks();
rendererService = {
checkHealth: jest.fn().mockResolvedValue(true),
executeOperationHandler: jest.fn().mockResolvedValue({
imageCount: 1,
operationKey: 'bangdream.song.search',
query: '夏祭り',
replyText: '[CQ:image,file=base64://base64-song-card]',
source: 'Tsugu BangDream Bot 内置源码',
}),
refreshDictionaryCache: jest.fn().mockResolvedValue(undefined),
} as any;
service = new TsuguApplicationService(rendererService, new ToolsService());
});
it('refreshes dictionary cache during application bootstrap', async () => {
await service.onApplicationBootstrap();
expect(rendererService.refreshDictionaryCache).toHaveBeenCalledTimes(1);
});
it('executes a registry operation through the renderer handler', async () => {
const result = await service.execute('bangdream.song.search', {
text: '夏祭り',
});
expect(waitForMainDataReady).toHaveBeenCalledTimes(1);
expect(rendererService.executeOperationHandler).toHaveBeenCalledWith(
'searchSong',
{
text: '夏祭り',
},
);
expect(result).toMatchObject({
imageCount: 1,
operationKey: 'bangdream.song.search',
query: '夏祭り',
});
});
it('normalizes command errors into string messages', async () => {
rendererService.executeOperationHandler.mockRejectedValueOnce(
'图片渲染失败',
);
await expect(
service.execute('bangdream.event.search', { text: '50' }),
).rejects.toThrow('图片渲染失败');
});
});

View File

@ -0,0 +1,66 @@
import {
createTsuguHookContext,
TsuguHookRegistry,
} from '@/qqbot/plugins/bangDream/tsugu/runtime/hook-registry';
describe('BangDream Tsugu hook registry', () => {
it('emits simple lifecycle hooks by configured order', async () => {
const events: string[] = [];
const registry = new TsuguHookRegistry([
{
afterOutput: () => {
events.push('afterOutput:2');
},
beforeParse: () => {
events.push('beforeParse:2');
},
name: 'second',
order: 2,
},
{
afterOutput: () => {
events.push('afterOutput:1');
},
beforeParse: () => {
events.push('beforeParse:1');
},
name: 'first',
order: 1,
},
]);
const context = createTsuguHookContext('bangdream.song.search', {
text: '夏祭り',
});
await registry.beforeParse(context);
await registry.afterOutput(context);
expect(events).toEqual([
'beforeParse:1',
'beforeParse:2',
'afterOutput:1',
'afterOutput:2',
]);
});
it('passes shared context and error to error hooks', async () => {
const errors: string[] = [];
const registry = new TsuguHookRegistry([
{
name: 'error-recorder',
onError: (context, error) => {
errors.push(`${context.operationKey}:${context.stage}:${error}`);
},
},
]);
const context = createTsuguHookContext('bangdream.event.search', {
args: ['50'],
});
context.stage = 'handler';
await registry.onError(context, '活动渲染失败');
expect(context.query).toBe('50');
expect(errors).toEqual(['bangdream.event.search:handler:活动渲染失败']);
});
});