diff --git a/src/modules/qqbot/plugins/bangdream/plugin.json b/src/modules/qqbot/plugins/bangdream/plugin.json index ef964f5..34dc17e 100644 --- a/src/modules/qqbot/plugins/bangdream/plugin.json +++ b/src/modules/qqbot/plugins/bangdream/plugin.json @@ -17,10 +17,22 @@ "plugin.storage.write" ], "runtime": { - "workerType": "node-worker", + "workerType": "thread", "timeoutMs": 30000, - "memoryMb": 3072, - "maxConcurrency": 2 + "memoryMb": 512, + "maxConcurrency": 1, + "configKeys": [ + "BANGDREAM_TSUGU_BESTDORI_BASE_URL", + "BANGDREAM_TSUGU_CACHE_ROOT", + "BANGDREAM_TSUGU_COMPRESS", + "BANGDREAM_TSUGU_DISPLAYED_SERVERS", + "BANGDREAM_TSUGU_HHWX_BASE_URL", + "BANGDREAM_TSUGU_MAIN_DATA_READY_TIMEOUT_MS", + "BANGDREAM_TSUGU_MAIN_SERVER", + "BANGDREAM_TSUGU_REQUEST_TIMEOUT_MS", + "BANGDREAM_TSUGU_RETRY_COUNT", + "BANGDREAM_TSUGU_USE_EASY_BG" + ] }, "configSchema": { "type": "object" diff --git a/src/modules/qqbot/plugins/bangdream/src/index.ts b/src/modules/qqbot/plugins/bangdream/src/index.ts index 9defb6a..483f30e 100644 --- a/src/modules/qqbot/plugins/bangdream/src/index.ts +++ b/src/modules/qqbot/plugins/bangdream/src/index.ts @@ -1,6 +1,11 @@ +import * as path from 'path'; +import * as XLSX from 'xlsx'; + import { BangDreamCommandContext, type BangDreamCommandContextOptions, + type BangDreamConfigReader, + type BangDreamDictionaryReader, } from './application/bangdream-command-context'; import { createBangDreamOperationLifecycleContext, @@ -24,6 +29,10 @@ import type { } from './domain/common/bangdream.types'; import { waitForBangDreamCatalogReady } from './application/catalog/bangdream-catalog-cache'; import { preloadBangDreamRenderAssets } from './application/render-assets'; +import { + fuzzySearchPath, + projectRoot as bangDreamProjectRoot, +} from './config/runtime-config'; type BangDreamPluginRuntimeOptions = BangDreamCommandContextOptions & { description?: string; @@ -52,11 +61,69 @@ type BangDreamResolvedOperation = BangDreamManifestOperation & { execute: BangDreamOperationModule['execute']; }; +type BangDreamGenericManifest = { + description?: string; + entry?: string; + events?: unknown[]; + key?: string; + legacyAliases?: string[]; + name?: string; + operations?: BangDreamManifestOperation[]; + pluginKey?: string; + runtime?: Record; + tasks?: unknown[]; + version?: string; +}; + +export type QqbotGenericPluginCreateOptions = { + host: Record; + manifest: BangDreamGenericManifest; + normalizeError: (error: unknown, fallback?: string) => string | Error; + now: () => Date; + runtime: { + configSnapshot: Record; + installationId: string; + }; +}; + +type BangDreamPluginCreateOptions = + | BangDreamPluginRuntimeOptions + | QqbotGenericPluginCreateOptions; + +type BangDreamCommandPlugin = ReturnType< + typeof createBangDreamPluginFromRuntimeOptions +>; + +type BangDreamGenericPathMapper = (filePath: string) => string; + /** - * 创建 BangDream 插件对象或配置。 - * @param options - BangDream列表;使用 `io`、`operations`、`normalizeError`、`description` 字段生成结果。 + * Creates the BangDream plugin entry for either the legacy package loader or the generic worker runtime. + * @param options - Legacy BangDream runtime options or generic worker create context with manifest and config snapshot. + * @returns BangDream command plugin instance exposing operations, health checks, and scheduled tasks. */ -export function createPlugin(options: BangDreamPluginRuntimeOptions) { +export function createPlugin( + options: BangDreamPluginRuntimeOptions, +): BangDreamCommandPlugin; +export function createPlugin( + options: QqbotGenericPluginCreateOptions, +): Promise; +export function createPlugin( + options: BangDreamPluginCreateOptions, +): BangDreamCommandPlugin | Promise { + if (isBangDreamGenericPluginCreateOptions(options)) { + return createBangDreamPluginFromGenericOptions(options); + } + return createBangDreamPluginFromRuntimeOptions(options); +} + +/** + * Creates the BangDream plugin through the historical package-local option shape. + * @param options - Package-local options carrying synchronous config, optional runtime IO, and manifest operations. + * @returns BangDream command plugin used by legacy loaders and tests. + */ +function createBangDreamPluginFromRuntimeOptions( + options: BangDreamPluginRuntimeOptions, +) { if (options.io) configureBangDreamRuntimeIo(options.io); const context = new BangDreamCommandContext(options); const lifecycle = new BangDreamOperationLifecycle([ @@ -155,6 +222,394 @@ export function createPlugin(options: BangDreamPluginRuntimeOptions) { }; } +/** + * Creates the BangDream plugin from the generic worker contract while keeping adapters inside the package. + * @param options - Generic worker context; config is read from the snapshot and IO calls delegate to host RPC methods. + * @returns BangDream command plugin compatible with the generic worker runtime. + */ +async function createBangDreamPluginFromGenericOptions( + options: QqbotGenericPluginCreateOptions, +): Promise { + const manifest = options.manifest; + const pathMapper = createBangDreamGenericPathMapper( + options.runtime.installationId, + ); + const syncJsonCache = await preloadBangDreamGenericSyncJson( + options.host, + pathMapper, + ); + return createBangDreamPluginFromRuntimeOptions({ + configReader: createBangDreamGenericConfigReader( + options.runtime.configSnapshot, + ), + description: manifest.description, + dictionaryReader: createBangDreamGenericDictionaryReader(options.host), + io: createBangDreamGenericRuntimeIo(options, pathMapper, syncJsonCache), + legacyAliases: manifest.legacyAliases, + name: manifest.name, + normalizeError: (error) => + normalizeBangDreamGenericError(options.normalizeError, error), + operations: manifest.operations || [], + pluginKey: manifest.pluginKey || manifest.key || 'bangdream', + version: manifest.version, + }); +} + +/** + * Checks whether a create call is using the generic worker runtime shape. + * @param options - Unknown BangDream create options supplied by a loader. + * @returns `true` when the options include a runtime config snapshot. + */ +function isBangDreamGenericPluginCreateOptions( + options: BangDreamPluginCreateOptions, +): options is QqbotGenericPluginCreateOptions { + return ( + !!(options as QqbotGenericPluginCreateOptions).runtime?.configSnapshot && + !!(options as QqbotGenericPluginCreateOptions).manifest + ); +} + +/** + * Creates a synchronous BangDream config reader over the worker startup snapshot. + * @param snapshot - Manifest-owned config key snapshot captured before the worker call. + * @returns Config reader that never performs async host RPC during BangDream command execution. + */ +function createBangDreamGenericConfigReader( + snapshot: Record, +): BangDreamConfigReader { + return { + /** + * Reads one BangDream config value from the immutable worker snapshot. + * @param key - Runtime config key declared by the BangDream package manifest. + * @returns Snapshot value cast to the requested BangDream config type. + */ + get: (key: string) => snapshot[key] as T | undefined, + }; +} + +/** + * Creates a dictionary reader that delegates BangDream dictionary lookups to the generic host. + * @param host - Generic worker host facade with dictionary RPC methods. + * @returns Dictionary reader used by BangDream command context cache refresh. + */ +function createBangDreamGenericDictionaryReader( + host: Record, +): BangDreamDictionaryReader { + return { + /** + * Reads dictionary items through the worker host bridge. + * @param dictCode - Admin dictionary code requested by BangDream alias/config lookup. + * @returns Dictionary items normalized to label/value pairs by the command context. + */ + getDictItemsByKey: async (dictCode) => + await callBangDreamGenericHost(host, 'getDictItemsByKey', dictCode), + }; +} + +/** + * Builds BangDream runtime IO adapters over generic worker host methods. + * @param options - Generic worker context containing host RPC methods and config snapshot. + * @param pathMapper - Converts BangDream absolute package paths into host-safe package-relative paths. + * @param syncJsonCache - Preloaded JSON payloads available to synchronous BangDream readers. + * @returns Runtime IO implementation consumed by BangDream package infrastructure. + */ +function createBangDreamGenericRuntimeIo( + options: QqbotGenericPluginCreateOptions, + pathMapper: BangDreamGenericPathMapper, + syncJsonCache: Map, +): BangDreamRuntimeIo { + const { host, runtime } = options; + return { + /** + * Reads a BangDream config value from the startup snapshot. + * @param key - Runtime config key declared by the BangDream package manifest. + * @returns Snapshot value or `undefined` when the key is not configured. + */ + getConfig: (key) => runtime.configSnapshot[key], + /** + * Reads a package asset file through the generic host bridge. + * @param filePath - Package-relative asset path requested by BangDream rendering or catalog code. + * @returns Asset bytes from the package root. + */ + readAssetFile: async (filePath) => + normalizeBangDreamHostBuffer( + await callBangDreamGenericHost( + host, + 'readAssetFile', + pathMapper(filePath), + ), + ), + /** + * Reads Excel rows from a package-local workbook buffer through the generic host bridge. + * @param filePath - Absolute or relative BangDream static workbook path requested by package code. + * @returns First-sheet rows parsed from host-provided XLSX bytes. + */ + readExcelRows: async >( + filePath: string, + ) => + parseBangDreamExcelRows( + normalizeBangDreamHostBuffer( + await callBangDreamGenericHost( + host, + 'readAssetFile', + pathMapper(filePath), + ), + ), + ), + /** + * Reads a package-local JSON file through the generic host bridge. + * @param filePath - Package-relative JSON path requested by BangDream storage or static data code. + * @returns Parsed JSON payload returned by the host bridge. + */ + readJsonFile: async (filePath: string) => + await callBangDreamGenericHost( + host, + 'readJsonFile', + pathMapper(filePath), + ), + /** + * Reads preloaded package-local JSON synchronously for BangDream search/config modules. + * @param filePath - Absolute or relative BangDream JSON path requested by synchronous package code. + * @returns Cached JSON payload populated during generic plugin creation. + */ + readJsonFileSync: (filePath: string) => { + const relativePath = pathMapper(filePath); + if (!syncJsonCache.has(relativePath)) { + throw new Error( + `BangDream generic runtime JSON 未预加载:${relativePath}`, + ); + } + return syncJsonCache.get(relativePath) as T; + }, + /** + * Renames a package-local file through the generic host bridge. + * @param from - Package-relative temporary path created by BangDream storage code. + * @param to - Package-relative final path for the atomic write target. + */ + renameFile: async (from, to) => { + await callBangDreamGenericHost( + host, + 'renameFile', + pathMapper(from), + pathMapper(to), + ); + }, + /** + * Requests binary HTTP content through the generic host bridge. + * @param url - Absolute HTTP URL requested by BangDream external integrations. + * @param requestOptions - Optional headers and timeout used for the host-mediated request. + * @returns Buffer body wrapped in the BangDream runtime IO response shape. + */ + requestArrayBuffer: async (url, requestOptions) => ({ + body: normalizeBangDreamHostBuffer( + await callBangDreamGenericHost(host, 'requestBuffer', { + context: 'BangDream 资源下载', + failureMessageTemplate: 'BangDream 资源下载失败:{statusCode}', + headers: requestOptions?.headers, + timeoutMessage: 'BangDream 资源下载超时', + timeoutMs: requestOptions?.timeoutMs, + url, + }), + ), + }), + /** + * Requests JSON HTTP content through the generic host bridge. + * @param url - Absolute HTTP URL requested by BangDream external integrations. + * @param requestOptions - Optional headers and timeout used for the host-mediated request. + * @returns JSON body wrapped in the BangDream runtime IO response shape. + */ + requestJson: async (url: string, requestOptions) => ({ + body: await callBangDreamGenericHost(host, 'requestJson', { + context: 'BangDream 数据接口', + failureMessageTemplate: 'BangDream 数据接口失败:{statusCode}', + headers: requestOptions?.headers, + invalidJsonMessage: 'BangDream 数据接口返回不是合法 JSON', + timeoutMessage: 'BangDream 数据接口请求超时', + timeoutMs: requestOptions?.timeoutMs, + url, + }), + }), + /** + * Sleeps through the generic host bridge so worker delays remain bounded by platform policy. + * @param ms - Delay duration in milliseconds requested by BangDream retry logic. + */ + sleep: async (ms) => { + await callBangDreamGenericHost(host, 'sleep', ms); + }, + /** + * Writes a package-local JSON file through the generic host bridge. + * @param filePath - Package-relative storage path requested by BangDream cache code. + * @param data - JSON-serializable payload to persist in package storage. + */ + writeJsonFile: async (filePath, data) => { + await callBangDreamGenericHost( + host, + 'writeJsonFile', + pathMapper(filePath), + data, + ); + }, + }; +} + +/** + * Creates a BangDream package path mapper for generic host file calls. + * @param installationId - Runtime installation id used to namespace package-external cache paths. + * @returns Function converting absolute package paths to package-relative host paths with forward slashes. + */ +function createBangDreamGenericPathMapper( + installationId: string, +): BangDreamGenericPathMapper { + const packageRoot = path.resolve(bangDreamProjectRoot, '..'); + const runtimePrefix = `runtime/${normalizeBangDreamPathSegment( + installationId || 'default', + )}`; + + /** + * Converts a BangDream runtime file path into a host-safe package-relative path. + * @param filePath - Absolute package path, package-relative path, or package-external cache path from BangDream code. + * @returns Forward-slash relative path accepted by the generic host bridge. + */ + return (filePath: string) => { + if (!path.isAbsolute(filePath)) { + return normalizeBangDreamHostPath(filePath); + } + + const absolutePath = path.resolve(filePath); + const relativePath = path.relative(packageRoot, absolutePath); + if ( + relativePath && + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ) { + return normalizeBangDreamHostPath(relativePath); + } + + return `${runtimePrefix}/${normalizeBangDreamExternalPath(absolutePath)}`; + }; +} + +/** + * Preloads synchronous BangDream JSON files through async generic host methods before the plugin is returned. + * @param host - Generic worker host facade used to read package JSON files. + * @param pathMapper - Converts BangDream absolute paths to host-safe package-relative paths. + * @returns Map keyed by normalized package-relative JSON paths for sync readers. + */ +async function preloadBangDreamGenericSyncJson( + host: Record, + pathMapper: BangDreamGenericPathMapper, +) { + const cache = new Map(); + if (typeof host.readJsonFile !== 'function') return cache; + + const fuzzyPath = pathMapper(fuzzySearchPath); + cache.set( + fuzzyPath, + await callBangDreamGenericHost(host, 'readJsonFile', fuzzyPath), + ); + return cache; +} + +/** + * Parses rows from the first worksheet of a host-provided XLSX buffer. + * @param buffer - Workbook bytes read through the generic host bridge. + * @returns JSON rows from the first workbook sheet. + */ +function parseBangDreamExcelRows>( + buffer: Buffer, +): T[] { + const workbook = XLSX.read(buffer, { type: 'buffer' }); + const sheetName = workbook.SheetNames[0]; + if (!sheetName) return []; + return XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]); +} + +/** + * Normalizes a host path to forward-slash package-relative form. + * @param filePath - Package-relative path with either Windows or POSIX separators. + * @returns Forward-slash path without leading current-directory markers. + */ +function normalizeBangDreamHostPath(filePath: string) { + return filePath + .replace(/\\/g, '/') + .replace(/^\.\/+/, '') + .split('/') + .filter(Boolean) + .join('/'); +} + +/** + * Converts an absolute package-external path into a stable runtime storage key. + * @param filePath - Absolute cache or storage path outside the BangDream package root. + * @returns Forward-slash runtime storage suffix that is safe for package-local host APIs. + */ +function normalizeBangDreamExternalPath(filePath: string) { + return normalizeBangDreamHostPath( + filePath.replace(/^[A-Za-z]:/, (drive) => drive.slice(0, 1)), + ); +} + +/** + * Sanitizes one path segment used by generic runtime storage prefixes. + * @param value - Installation id or other untrusted segment value. + * @returns Path-safe segment with separators and punctuation collapsed. + */ +function normalizeBangDreamPathSegment(value: string) { + return ( + value.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'default' + ); +} + +/** + * Calls one generic BangDream host capability and fails with a package-owned error when absent. + * @param host - Generic worker host facade supplied to `createPlugin`. + * @param method - Host capability name required by the BangDream adapter. + * @param args - Positional arguments accepted by the host facade method. + * @returns Host method result cast to the requested package-local type. + */ +async function callBangDreamGenericHost( + host: Record, + method: string, + ...args: unknown[] +): Promise { + const fn = host[method]; + if (typeof fn !== 'function') { + throw new Error(`BangDream generic host 缺少 ${method}`); + } + return (await fn(...args)) as TResult; +} + +/** + * Converts a generic host binary response into a Node Buffer for BangDream render code. + * @param value - Buffer, Uint8Array, or object containing a `body` field returned by the host bridge. + * @returns Buffer instance safe for BangDream runtime IO consumers. + */ +function normalizeBangDreamHostBuffer(value: unknown): Buffer { + const body = + value && typeof value === 'object' && 'body' in value + ? (value as { body?: unknown }).body + : value; + if (Buffer.isBuffer(body)) return body; + if (body instanceof Uint8Array) return Buffer.from(body); + if (Array.isArray(body)) return Buffer.from(body); + return Buffer.from([]); +} + +/** + * Normalizes generic worker errors to the legacy BangDream string error contract. + * @param normalizeError - Generic worker error normalizer supplied by the platform runtime. + * @param error - Error or arbitrary thrown value from BangDream package code. + * @returns Error message consumed by BangDream lifecycle logging and thrown operation errors. + */ +function normalizeBangDreamGenericError( + normalizeError: QqbotGenericPluginCreateOptions['normalizeError'], + error: unknown, +) { + const normalized = normalizeError(error, 'BangDream 命令执行失败'); + return normalized instanceof Error ? normalized.message : `${normalized}`; +} + /** * 解析Bang Dream Operations。 * @param operations - BangDream列表;转换 BangDream列表项。 diff --git a/src/modules/qqbot/plugins/ff14-market/plugin.json b/src/modules/qqbot/plugins/ff14-market/plugin.json index 29b515e..04eaf44 100644 --- a/src/modules/qqbot/plugins/ff14-market/plugin.json +++ b/src/modules/qqbot/plugins/ff14-market/plugin.json @@ -9,10 +9,16 @@ "legacyAliases": ["ff14Market"], "permissions": ["runtime.http", "plugin.config.read"], "runtime": { - "workerType": "node-worker", - "timeoutMs": 15000, + "workerType": "thread", + "timeoutMs": 30000, "memoryMb": 256, - "maxConcurrency": 4 + "maxConcurrency": 1, + "configKeys": [ + "FF14_DEFAULT_WORLD", + "FF14_UNIVERSALIS_BASE_URL", + "FF14_XIVAPI_BASE_URL", + "FF14_XIVAPI_CHS_BASE_URL" + ] }, "configSchema": { "type": "object", diff --git a/src/modules/qqbot/plugins/ff14-market/src/index.ts b/src/modules/qqbot/plugins/ff14-market/src/index.ts index 4689af1..12180b5 100644 --- a/src/modules/qqbot/plugins/ff14-market/src/index.ts +++ b/src/modules/qqbot/plugins/ff14-market/src/index.ts @@ -1,5 +1,8 @@ import { Ff14MarketApplication } from './application/ff14-market-application'; -import { Ff14MarketClient } from './infrastructure/integration/ff14-market-client'; +import { + Ff14MarketClient, + type Ff14MarketPluginHost, +} from './infrastructure/integration/ff14-market-client'; import { buildFf14MarketOperations, type Ff14MarketManifest, @@ -12,11 +15,49 @@ type Ff14MarketPluginOptions = { now?: () => Date; }; +export type QqbotGenericPluginCreateOptions = { + host: Record; + manifest: Ff14MarketManifest & { key?: string }; + normalizeError: (error: unknown, fallback?: string) => string | Error; + now: () => Date; + runtime: { + configSnapshot: Record; + installationId: string; + }; +}; + +type Ff14MarketPluginCreateOptions = + | Ff14MarketPluginOptions + | QqbotGenericPluginCreateOptions; + /** - * 创建 FF14 市场插件对象或配置。 - * @param options - FF14 市场列表;使用 `host`、`manifest`、`now`、`normalizeError` 字段生成结果。 + * Creates the FF14 Market plugin entry for the legacy loader or generic worker runtime. + * @param options - Legacy package-local options or generic worker options with config snapshot and host RPC facade. + * @returns FF14 Market command plugin instance. */ -export function createPlugin(options: Ff14MarketPluginOptions) { +export function createPlugin(options: Ff14MarketPluginCreateOptions) { + if (isFf14GenericPluginCreateOptions(options)) { + return createFf14MarketPluginFromOptions({ + host: createFf14MarketGenericHostAdapter(options), + manifest: normalizeFf14MarketManifest(options.manifest), + normalizeError: (error, fallback) => + normalizeFf14MarketGenericError( + options.normalizeError, + error, + fallback, + ), + now: options.now, + }); + } + return createFf14MarketPluginFromOptions(options); +} + +/** + * Creates the FF14 Market plugin from the package-local host contract. + * @param options - Package-local host, manifest, clock, and error normalizer. + * @returns FF14 Market command plugin used by current tests and legacy loaders. + */ +function createFf14MarketPluginFromOptions(options: Ff14MarketPluginOptions) { const application = new Ff14MarketApplication( new Ff14MarketClient(options.host), ); @@ -55,6 +96,138 @@ export function createPlugin(options: Ff14MarketPluginOptions) { }; } +/** + * Checks whether FF14 Market create options came from the generic worker runtime. + * @param options - Candidate create options supplied by the plugin loader. + * @returns `true` when a runtime config snapshot is present. + */ +function isFf14GenericPluginCreateOptions( + options: Ff14MarketPluginCreateOptions, +): options is QqbotGenericPluginCreateOptions { + return ( + !!(options as QqbotGenericPluginCreateOptions).runtime?.configSnapshot && + !!(options as QqbotGenericPluginCreateOptions).manifest + ); +} + +/** + * Normalizes generic manifest aliases so FF14 Market keeps a package-local plugin key. + * @param manifest - Manifest supplied by the generic worker descriptor. + * @returns Manifest with `pluginKey` filled from `key` when needed. + */ +function normalizeFf14MarketManifest( + manifest: QqbotGenericPluginCreateOptions['manifest'], +): Ff14MarketManifest { + return { + ...manifest, + pluginKey: manifest.pluginKey || manifest.key || 'ff14-market', + }; +} + +/** + * Builds the FF14 Market client host over generic worker host methods. + * @param options - Generic worker context containing host RPC methods and config snapshot. + * @returns Package-local host expected by `Ff14MarketClient`. + */ +function createFf14MarketGenericHostAdapter( + options: QqbotGenericPluginCreateOptions, +): Ff14MarketPluginHost { + const { host, runtime } = options; + return { + /** + * Reads FF14 Market config synchronously from the worker startup snapshot. + * @param key - Runtime config key declared by the FF14 Market package manifest. + * @returns Snapshot value cast to the requested config type. + */ + getConfig: (key: string) => + runtime.configSnapshot[key] as T | undefined, + /** + * Reads dictionary items through the generic host bridge. + * @param dictCode - FF14 dictionary code used for region, data center, or world lookup. + * @returns Dictionary items returned by the host dictionary service. + */ + getDictItemsByKey: async (dictCode) => + await callFf14GenericHost(host, 'getDictItemsByKey', dictCode), + /** + * Reads the FF14 dictionary relation tree through the generic host bridge. + * @param input - Relation tree root dictionary code requested by the FF14 catalog builder. + * @returns Relation tree nodes returned by the host dictionary service. + */ + relationTree: async (input) => + await callFf14GenericHost(host, 'relationTree', input), + /** + * Performs FF14 Market HTTP JSON requests through the generic host bridge. + * @param request - Package-local HTTP request options from `Ff14MarketClient`. + * @returns Parsed JSON payload returned by the host HTTP client. + */ + requestJson: async (request) => + await callFf14GenericHost( + host, + 'requestJson', + serializeFf14GenericHttpRequest(request), + ), + }; +} + +/** + * Calls one FF14 Market generic host method and fails with a package-owned error when absent. + * @param host - Generic worker host facade supplied by the platform runtime. + * @param method - Host capability required by the FF14 Market adapter. + * @param args - Positional arguments accepted by the host facade method. + * @returns Host method result cast to the requested package-local type. + */ +async function callFf14GenericHost( + host: Record, + method: string, + ...args: unknown[] +): Promise { + const fn = host[method]; + if (typeof fn !== 'function') { + throw new Error(`FF14 Market generic host 缺少 ${method}`); + } + return (await fn(...args)) as TResult; +} + +/** + * Converts package-local HTTP options to worker-safe generic host request data. + * @param request - FF14 Market HTTP request containing URL and function-based failure message. + * @returns Serializable request data accepted by the generic host bridge. + */ +function serializeFf14GenericHttpRequest( + request: Parameters[0], +) { + const statusPlaceholder = 599; + return { + body: undefined, + context: request.context, + failureMessageTemplate: request + .failureMessage(statusPlaceholder) + .replaceAll(`${statusPlaceholder}`, '{statusCode}'), + headers: undefined, + invalidJsonMessage: request.invalidJsonMessage, + method: request.method, + timeoutMessage: request.timeoutMessage, + timeoutMs: request.timeoutMs, + url: request.url.toString(), + }; +} + +/** + * Normalizes generic worker errors to the legacy FF14 Market string error contract. + * @param normalizeError - Generic worker error normalizer supplied by the platform runtime. + * @param error - Error or arbitrary thrown value from package code. + * @param fallback - FF14 Market fallback message used by health checks. + * @returns String message consumed by legacy plugin health output. + */ +function normalizeFf14MarketGenericError( + normalizeError: QqbotGenericPluginCreateOptions['normalizeError'], + error: unknown, + fallback: string, +) { + const normalized = normalizeError(error, fallback); + return normalized instanceof Error ? normalized.message : `${normalized}`; +} + /** * 转换 FF14 市场插件输入。 * @param date - date 输入;执行 `date.getFullYear()`、`date.getMonth()`、`date.getDate()`、`date.getHours()` 对应的 FF14 市场步骤。 diff --git a/src/modules/qqbot/plugins/fflogs/plugin.json b/src/modules/qqbot/plugins/fflogs/plugin.json index 19b7ca6..deba02e 100644 --- a/src/modules/qqbot/plugins/fflogs/plugin.json +++ b/src/modules/qqbot/plugins/fflogs/plugin.json @@ -8,10 +8,21 @@ "entry": "src/index.ts", "permissions": ["runtime.http", "plugin.config.read"], "runtime": { - "workerType": "node-worker", - "timeoutMs": 20000, + "workerType": "thread", + "timeoutMs": 30000, "memoryMb": 256, - "maxConcurrency": 2 + "maxConcurrency": 1, + "configKeys": [ + "FFLOGS_WEB_BASE_URL", + "FFLOGS_BASE_URL", + "FFLOGS_CLIENT_ID", + "FFLOGS_CLIENT_SECRET", + "FFLOGS_GRAPHQL_URL", + "FFLOGS_TOKEN_URL", + "FFLOGS_DEFAULT_SERVER", + "FFLOGS_DEFAULT_SERVER_REGION", + "FFLOGS_REQUEST_TIMEOUT_MS" + ] }, "configSchema": { "type": "object", diff --git a/src/modules/qqbot/plugins/fflogs/src/index.ts b/src/modules/qqbot/plugins/fflogs/src/index.ts index 19f79c0..c6eccc1 100644 --- a/src/modules/qqbot/plugins/fflogs/src/index.ts +++ b/src/modules/qqbot/plugins/fflogs/src/index.ts @@ -1,5 +1,8 @@ import { FflogsApplication } from './application/fflogs-application'; -import { FflogsClient } from './infrastructure/integration/fflogs-client'; +import { + FflogsClient, + type FflogsPluginHost, +} from './infrastructure/integration/fflogs-client'; import { buildFflogsOperations, type FflogsManifest } from './operations'; type FflogsPluginOptions = { @@ -9,11 +12,45 @@ type FflogsPluginOptions = { now?: () => Date; }; +export type QqbotGenericPluginCreateOptions = { + host: Record; + manifest: FflogsManifest & { key?: string }; + normalizeError: (error: unknown, fallback?: string) => string | Error; + now: () => Date; + runtime: { + configSnapshot: Record; + installationId: string; + }; +}; + +type FflogsPluginCreateOptions = + | FflogsPluginOptions + | QqbotGenericPluginCreateOptions; + /** - * 创建 FFLogs 插件对象或配置。 - * @param options - FFLogs列表;使用 `host`、`manifest`、`now`、`normalizeError` 字段生成结果。 + * Creates the FFLogs plugin entry for the legacy loader or generic worker runtime. + * @param options - Legacy package-local options or generic worker options with config snapshot and host RPC facade. + * @returns FFLogs command plugin instance. */ -export function createPlugin(options: FflogsPluginOptions) { +export function createPlugin(options: FflogsPluginCreateOptions) { + if (isFflogsGenericPluginCreateOptions(options)) { + return createFflogsPluginFromOptions({ + host: createFflogsGenericHostAdapter(options), + manifest: normalizeFflogsManifest(options.manifest), + normalizeError: (error, fallback) => + normalizeFflogsGenericError(options.normalizeError, error, fallback), + now: options.now, + }); + } + return createFflogsPluginFromOptions(options); +} + +/** + * Creates the FFLogs plugin from the package-local host contract. + * @param options - Package-local host, manifest, clock, and error normalizer. + * @returns FFLogs command plugin used by current tests and legacy loaders. + */ +function createFflogsPluginFromOptions(options: FflogsPluginOptions) { const application = new FflogsApplication(new FflogsClient(options.host)); return { description: options.manifest.description, @@ -46,6 +83,162 @@ export function createPlugin(options: FflogsPluginOptions) { }; } +/** + * Checks whether FFLogs create options came from the generic worker runtime. + * @param options - Candidate create options supplied by the plugin loader. + * @returns `true` when a runtime config snapshot is present. + */ +function isFflogsGenericPluginCreateOptions( + options: FflogsPluginCreateOptions, +): options is QqbotGenericPluginCreateOptions { + return ( + !!(options as QqbotGenericPluginCreateOptions).runtime?.configSnapshot && + !!(options as QqbotGenericPluginCreateOptions).manifest + ); +} + +/** + * Normalizes generic manifest aliases so FFLogs keeps a package-local plugin key. + * @param manifest - Manifest supplied by the generic worker descriptor. + * @returns Manifest with `pluginKey` filled from `key` when needed. + */ +function normalizeFflogsManifest( + manifest: QqbotGenericPluginCreateOptions['manifest'], +): FflogsManifest { + return { + ...manifest, + pluginKey: manifest.pluginKey || manifest.key || 'fflogs', + }; +} + +/** + * Builds the FFLogs client host over generic worker host methods. + * @param options - Generic worker context containing host RPC methods and config snapshot. + * @returns Package-local host expected by `FflogsClient`. + */ +function createFflogsGenericHostAdapter( + options: QqbotGenericPluginCreateOptions, +): FflogsPluginHost { + const { host, runtime } = options; + return { + /** + * Reads FFLogs config synchronously from the worker startup snapshot. + * @param key - Runtime config key declared by the FFLogs package manifest. + * @returns Snapshot value cast to the requested config type. + */ + getConfig: (key: string) => + runtime.configSnapshot[key] as T | undefined, + /** + * Reads dictionary items through the legacy dictionary method name when FFLogs localization asks for it. + * @param dictCode - Dictionary code used by FFLogs localization or FF14 world lookup. + * @returns Dictionary items returned by the host dictionary service. + */ + getDictByKey: async (dictCode) => + await callFflogsGenericDictHost(host, dictCode), + /** + * Reads dictionary items through the generic dictionary method name for package-owned FF14 world lookup. + * @param dictCode - Dictionary code used by FFLogs known-world resolution. + * @returns Dictionary items returned by the host dictionary service. + */ + getDictItemsByKey: async (dictCode) => + await callFflogsGenericDictHost(host, dictCode), + /** + * Reads the FF14 dictionary relation tree through the generic host bridge. + * @param input - Relation tree root dictionary code requested by FFLogs known-world resolution. + * @returns Relation tree nodes returned by the host dictionary service. + */ + relationTree: async (input) => + await callFflogsGenericHost(host, 'relationTree', input), + /** + * Performs FFLogs HTTP JSON requests through the generic host bridge. + * @param request - Package-local HTTP request options from `FflogsClient`. + * @returns Parsed JSON payload returned by the host HTTP client. + */ + requestJson: async (request) => + await callFflogsGenericHost( + host, + 'requestJson', + serializeFflogsGenericHttpRequest(request), + ), + }; +} + +/** + * Reads dictionary items through the preferred generic host method with a legacy name fallback. + * @param host - Generic worker host facade supplied by the platform runtime. + * @param dictCode - Dictionary code requested by FFLogs package code. + * @returns Dictionary items returned by the host bridge. + */ +async function callFflogsGenericDictHost( + host: Record, + dictCode: string, +) { + const method = + typeof host.getDictItemsByKey === 'function' + ? 'getDictItemsByKey' + : 'getDictByKey'; + return await callFflogsGenericHost(host, method, dictCode); +} + +/** + * Calls one FFLogs generic host method and fails with a package-owned error when absent. + * @param host - Generic worker host facade supplied by the platform runtime. + * @param method - Host capability required by the FFLogs adapter. + * @param args - Positional arguments accepted by the host facade method. + * @returns Host method result cast to the requested package-local type. + */ +async function callFflogsGenericHost( + host: Record, + method: string, + ...args: unknown[] +): Promise { + const fn = host[method]; + if (typeof fn !== 'function') { + throw new Error(`FFLogs generic host 缺少 ${method}`); + } + return (await fn(...args)) as TResult; +} + +/** + * Converts package-local HTTP options to worker-safe generic host request data. + * @param request - FFLogs HTTP request containing URL and function-based failure message. + * @returns Serializable request data accepted by the generic host bridge. + */ +function serializeFflogsGenericHttpRequest( + request: Parameters[0], +) { + const statusPlaceholder = 599; + return { + body: request.body, + context: request.context, + failureMessageTemplate: request + .failureMessage(statusPlaceholder) + .replaceAll(`${statusPlaceholder}`, '{statusCode}'), + headers: request.headers, + invalidJsonMessage: request.invalidJsonMessage, + method: request.method, + timeoutMessage: request.timeoutMessage, + timeoutMs: request.timeoutMs, + url: request.url.toString(), + }; +} + +/** + * Normalizes generic worker errors to the legacy FFLogs string error contract. + * @param normalizeError - Generic worker error normalizer supplied by the platform runtime. + * @param error - Error or arbitrary thrown value from package code. + * @param fallback - FFLogs fallback message used by health checks. + * @returns String message consumed by legacy plugin health output. + */ +function normalizeFflogsGenericError( + normalizeError: QqbotGenericPluginCreateOptions['normalizeError'], + error: unknown, + fallback: string, +) { + const normalized = normalizeError(error, fallback); + return normalized instanceof Error ? normalized.message : `${normalized}`; +} + /** * 转换 FFLogs 插件输入。 * @param date - date 输入;执行 `date.getFullYear()`、`date.getMonth()`、`date.getDate()`、`date.getHours()` 对应的 FFLogs步骤。 diff --git a/src/modules/qqbot/plugins/fflogs/src/infrastructure/integration/fflogs-client.ts b/src/modules/qqbot/plugins/fflogs/src/infrastructure/integration/fflogs-client.ts index 46b1d3a..c0555e0 100644 --- a/src/modules/qqbot/plugins/fflogs/src/infrastructure/integration/fflogs-client.ts +++ b/src/modules/qqbot/plugins/fflogs/src/infrastructure/integration/fflogs-client.ts @@ -19,6 +19,14 @@ import type { import type { FflogsKnownWorldResolver } from '../../application/fflogs-input-parser'; import { resolveFflogsConfig } from '../../config/fflogs-config'; import { FflogsOAuthTokenCache } from '../storage/oauth-token-cache'; +import { + buildFf14MarketCatalog, + buildFf14MarketCatalogFromTree, + isFf14LocationName, + QQBOT_FF14_MARKET_DICT_CODES, + splitFf14WorldPath, + type Ff14DictItem, +} from '../../../../ff14-market/src/domain/ff14-worlds'; const FFLOGS_LOCALIZATION_DICT_CODES = { job: 'FFLOGS_JOB_LABEL', @@ -37,9 +45,11 @@ type FflogsEncounterCatalogItem = { export type FflogsPluginHost = { getConfig: (key: string) => T | undefined; - getDictByKey: ( + getDictByKey?: ( dictCode: string, ) => Promise>; + getDictItemsByKey?: (dictCode: string) => Promise; + relationTree?: (input: { dictCode: string }) => Promise; requestJson: (options: { body?: string; context: string; @@ -244,10 +254,20 @@ export class FflogsClient { /** * 解析Known World。 - * @param value - 待转换值;影响 resolveKnownWorld 的返回值。 + * @param value - FF14 server, region, data-center, or path token parsed from FFLogs command text. + * @returns Legacy host resolution when available, otherwise package-owned dictionary fallback for generic workers. */ async resolveKnownWorld(value: string) { - return this.host.resolveKnownWorld?.(value) || null; + if (this.host.resolveKnownWorld) { + return this.host.resolveKnownWorld(value); + } + + const catalog = await this.loadFf14MarketCatalog(); + if (!isFf14LocationName(catalog, value)) return null; + const worldPath = splitFf14WorldPath(value); + return { + serverSlug: worldPath.world || value, + }; } /** @@ -1700,7 +1720,7 @@ export class FflogsClient { * @param dictCode - dictCode 输入;驱动 `host.getDictByKey()` 的 FFLogs步骤。 */ private async getNormalizedDictMap(dictCode: string) { - const dicts = await this.host.getDictByKey(dictCode); + const dicts = await this.getDictItems(dictCode); const map = new Map(); for (const { label, value } of dicts) { for (const key of this.buildLookupKeys(`${value}`, `${label}`)) { @@ -1710,6 +1730,47 @@ export class FflogsClient { return map; } + /** + * Loads the FF14 market dictionary catalog inside the FFLogs package for known-world parsing. + * @returns FF14 region/data-center/world catalog assembled from generic host dictionary methods. + */ + private async loadFf14MarketCatalog() { + if (this.host.relationTree) { + const treeCatalog = buildFf14MarketCatalogFromTree( + await this.host.relationTree({ + dictCode: QQBOT_FF14_MARKET_DICT_CODES.region, + }), + ); + if (treeCatalog.dataCenters.length > 0) return treeCatalog; + } + + const [regions, dataCenters, worlds] = await Promise.all([ + this.getDictItems(QQBOT_FF14_MARKET_DICT_CODES.region), + this.getDictItems(QQBOT_FF14_MARKET_DICT_CODES.dataCenter), + this.getDictItems(QQBOT_FF14_MARKET_DICT_CODES.world), + ]); + return buildFf14MarketCatalog({ + dataCenters, + regions, + worlds, + }); + } + + /** + * Reads dictionary items through whichever generic or legacy host dictionary method is available. + * @param dictCode - Dictionary code used by FFLogs localization or FF14 world catalog lookup. + * @returns Dictionary items from `getDictItemsByKey`, legacy `getDictByKey`, or an empty list. + */ + private async getDictItems(dictCode: string): Promise { + if (this.host.getDictItemsByKey) { + return this.host.getDictItemsByKey(dictCode); + } + if (this.host.getDictByKey) { + return this.host.getDictByKey(dictCode); + } + return []; + } + /** * 执行 FFLogs 插件流程。 * @param value - 待转换值;影响 localizeEncounter 的返回值。 diff --git a/src/modules/qqbot/plugins/repeater/plugin.json b/src/modules/qqbot/plugins/repeater/plugin.json index c008cda..c8bf2ce 100644 --- a/src/modules/qqbot/plugins/repeater/plugin.json +++ b/src/modules/qqbot/plugins/repeater/plugin.json @@ -14,10 +14,17 @@ "plugin.storage.write" ], "runtime": { - "workerType": "node-worker", - "timeoutMs": 5000, + "workerType": "thread", + "timeoutMs": 10000, "memoryMb": 128, - "maxConcurrency": 1 + "maxConcurrency": 1, + "configKeys": [ + "QQBOT_REPEATER_CONFIG_CACHE_TTL_MS", + "QQBOT_REPEATER_MAX_TEXT_LENGTH", + "QQBOT_REPEATER_MIN_INTERVAL_MS", + "QQBOT_REPEATER_STATE_TTL_MS", + "QQBOT_REPEATER_THRESHOLD" + ] }, "configSchema": { "type": "object", diff --git a/src/modules/qqbot/plugins/repeater/src/index.ts b/src/modules/qqbot/plugins/repeater/src/index.ts index 827ad88..f981252 100644 --- a/src/modules/qqbot/plugins/repeater/src/index.ts +++ b/src/modules/qqbot/plugins/repeater/src/index.ts @@ -9,11 +9,43 @@ type RepeaterPluginOptions = { now?: () => number; }; +export type QqbotGenericPluginCreateOptions = { + host: Record; + manifest: RepeaterManifest & { key?: string }; + normalizeError: (error: unknown, fallback?: string) => string | Error; + now: () => Date; + runtime: { + configSnapshot: Record; + installationId: string; + }; +}; + +type RepeaterPluginCreateOptions = + | RepeaterPluginOptions + | QqbotGenericPluginCreateOptions; + /** - * 创建 复读插件对象或配置。 - * @param options - 模块列表;使用 `host`、`manifest`、`now` 字段生成结果。 + * Creates the Repeater plugin entry for the legacy loader or generic worker runtime. + * @param options - Legacy package-local options or generic worker options with config snapshot and host RPC facade. + * @returns Repeater event plugin instance. */ -export function createPlugin(options: RepeaterPluginOptions) { +export function createPlugin(options: RepeaterPluginCreateOptions) { + if (isRepeaterGenericPluginCreateOptions(options)) { + return createRepeaterPluginFromOptions({ + host: createRepeaterGenericHostAdapter(options), + manifest: normalizeRepeaterManifest(options.manifest), + now: () => options.now().getTime(), + }); + } + return createRepeaterPluginFromOptions(options); +} + +/** + * Creates the Repeater plugin from the package-local host contract. + * @param options - Package-local host, manifest, and millisecond clock used by repeat policy state. + * @returns Repeater event plugin used by current tests, legacy loaders, and generic workers. + */ +function createRepeaterPluginFromOptions(options: RepeaterPluginOptions) { const application = new RepeaterApplication( options.host, options.manifest, @@ -45,6 +77,18 @@ export function createPlugin(options: RepeaterPluginOptions) { connectStatus?: string; selfId: string; }) => application.getSummary(params), + /** + * Routes generic worker event calls to the package-owned Repeater message handler. + * @param eventKey - Manifest event key or event name supplied by the generic worker. + * @param event - Normalized QQBot message event payload. + */ + handleEvent: (eventKey: string, event: unknown) => + handleRepeaterGenericEvent( + eventKey, + event, + options.manifest, + handleMessage, + ), handleMessage, /** * 维护 模块事件绑定。 @@ -53,3 +97,136 @@ export function createPlugin(options: RepeaterPluginOptions) { unbind: (selfId: string) => application.unbind(selfId), }; } + +/** + * Checks whether Repeater create options came from the generic worker runtime. + * @param options - Candidate create options supplied by the plugin loader. + * @returns `true` when a runtime config snapshot is present. + */ +function isRepeaterGenericPluginCreateOptions( + options: RepeaterPluginCreateOptions, +): options is QqbotGenericPluginCreateOptions { + return ( + !!(options as QqbotGenericPluginCreateOptions).runtime?.configSnapshot && + !!(options as QqbotGenericPluginCreateOptions).manifest + ); +} + +/** + * Normalizes generic manifest aliases so Repeater keeps a package-local plugin key. + * @param manifest - Manifest supplied by the generic worker descriptor. + * @returns Manifest with `pluginKey` filled from `key` when needed. + */ +function normalizeRepeaterManifest( + manifest: QqbotGenericPluginCreateOptions['manifest'], +): RepeaterManifest { + return { + ...manifest, + events: manifest.events || [], + pluginKey: manifest.pluginKey || manifest.key || 'repeater', + }; +} + +/** + * Builds the Repeater application host over generic worker host methods. + * @param options - Generic worker context containing host RPC methods and config snapshot. + * @returns Package-local host expected by `RepeaterApplication`. + */ +function createRepeaterGenericHostAdapter( + options: QqbotGenericPluginCreateOptions, +): RepeaterPluginHost { + const { host, runtime } = options; + return { + /** + * Binds this Repeater package to one account through the generic host bridge. + * @param selfId - QQBot self account id whose event binding is being enabled. + * @param pluginKey - Package plugin key; parent bridge still uses its authoritative descriptor key. + */ + bindEventPlugin: async (selfId, pluginKey) => { + await callRepeaterGenericHost(host, 'bindEventPlugin', selfId, pluginKey); + }, + /** + * Reads currently bound event plugin keys for one QQBot account. + * @param selfId - QQBot self account id whose event plugin bindings are being queried. + * @returns Bound plugin keys returned by the host account service. + */ + getBoundEventPluginKeys: async (selfId) => + await callRepeaterGenericHost(host, 'getBoundEventPluginKeys', selfId), + /** + * Reads Repeater config synchronously from the worker startup snapshot. + * @param key - Runtime config key declared by the Repeater package manifest. + * @returns Snapshot value cast to the requested config type. + */ + getConfig: (key: string) => + runtime.configSnapshot[key] as T | undefined, + /** + * Sends text through the QQBot host send queue. + * @param input - Target account, conversation, and message text produced by the repeat policy. + * @returns Host send result for observability. + */ + sendText: async (input) => + await callRepeaterGenericHost(host, 'sendText', input), + /** + * Unbinds this Repeater package from one account through the generic host bridge. + * @param selfId - QQBot self account id whose event binding is being disabled. + * @param pluginKey - Package plugin key; parent bridge still uses its authoritative descriptor key. + */ + unbindEventPlugin: async (selfId, pluginKey) => { + await callRepeaterGenericHost( + host, + 'unbindEventPlugin', + selfId, + pluginKey, + ); + }, + /** + * Emits a package warning through the generic host bridge without blocking message handling. + * @param message - Repeater warning text to write into host logs. + */ + warn: (message) => { + void callRepeaterGenericHost(host, 'warn', message).catch( + () => undefined, + ); + }, + }; +} + +/** + * Routes a generic worker event to the Repeater message handler when it matches the manifest event. + * @param eventKey - Manifest event key, event name, or handler name supplied by the generic worker. + * @param event - Normalized QQBot event payload forwarded by the platform. + * @param manifest - Repeater manifest containing package-owned event metadata. + * @param handleMessage - Package-local message handler produced by `createRepeaterMessageEventHandler`. + * @returns Repeater message handling result or `false` for unrelated events. + */ +async function handleRepeaterGenericEvent( + eventKey: string, + event: unknown, + manifest: RepeaterManifest, + handleMessage: (message: any) => Promise, +) { + const matched = (manifest.events || []).some((item: any) => + [item.key, item.eventName, item.handlerName].includes(eventKey), + ); + if (!matched && eventKey !== 'message') return false; + return handleMessage(event as any); +} + +/** + * Calls one Repeater generic host method and fails with a package-owned error when absent. + * @param host - Generic worker host facade supplied by the platform runtime. + * @param method - Host capability required by the Repeater adapter. + * @param args - Positional arguments accepted by the host facade method. + * @returns Host method result cast to the requested package-local type. + */ +async function callRepeaterGenericHost( + host: Record, + method: string, + ...args: unknown[] +): Promise { + const fn = host[method]; + if (typeof fn !== 'function') { + throw new Error(`Repeater generic host 缺少 ${method}`); + } + return (await fn(...args)) as TResult; +} diff --git a/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts b/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts index e6c2320..0295ea2 100644 --- a/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts +++ b/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts @@ -1,3 +1,6 @@ +import * as path from 'path'; +import * as XLSX from 'xlsx'; + jest.mock( '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache', () => ({ @@ -82,11 +85,101 @@ jest.mock('@/modules/qqbot/plugins/bangdream/src/operations', () => ({ import { createPlugin } from '@/modules/qqbot/plugins/bangdream/src'; import { BangDreamCommandContext } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-command-context'; import { waitForBangDreamCatalogReady } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache'; +import { + fuzzySearchPath, + projectRoot, +} from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config'; +import type { BangDreamRuntimeIo } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io'; const manifestOperations = mockManifestOperations.map(([key, handlerName]) => ({ handlerName, key, })); +const bangDreamPackageRoot = path.resolve(projectRoot, '..'); + +/** + * Creates a minimal generic BangDream manifest for package-entry adapter tests. + * @returns Manifest descriptor fields required by the generic worker `createPlugin` contract. + */ +function createGenericBangDreamManifest() { + return { + key: 'bangdream', + pluginKey: 'bangdream', + name: 'BangDream', + version: '1.0.0', + entry: 'src/index.ts', + runtime: { + configKeys: ['BANGDREAM_TSUGU_BESTDORI_BASE_URL'], + maxConcurrency: 1, + memoryMb: 256, + timeoutMs: 30000, + workerType: 'thread', + }, + operations: manifestOperations, + tasks: [], + events: [], + }; +} + +/** + * Returns the BangDream runtime IO adapter that the package entry registered during generic creation. + * @returns Runtime IO object passed to `configureBangDreamRuntimeIo`. + */ +function getConfiguredRuntimeIo(): BangDreamRuntimeIo { + const io = mockConfigureBangDreamRuntimeIo.mock.calls.at(-1)?.[0]; + if (!io) throw new Error('BangDream runtime IO was not configured'); + return io as BangDreamRuntimeIo; +} + +/** + * Creates a deterministic XLSX workbook buffer for generic readExcelRows adapter tests. + * @returns XLSX buffer containing one data row on the first worksheet. + */ +function createWorkbookBuffer() { + const workbook = XLSX.utils.book_new(); + const worksheet = XLSX.utils.json_to_sheet([ + { name: 'Test Card', score: 42 }, + ]); + XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1'); + return XLSX.write(workbook, { bookType: 'xlsx', type: 'buffer' }) as Buffer; +} + +/** + * Normalizes arbitrary BangDream package errors the same way the generic worker passes errors to entries. + * @param error - Error or thrown value produced while creating or executing the plugin. + * @returns Stable message string consumed by the package-local error adapter. + */ +function normalizeGenericBangDreamError(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +/** + * Provides a deterministic generic worker clock for package entry creation. + * @returns Fixed Date used to prove the entry accepts the generic `now` callback. + */ +function getGenericBangDreamNow() { + return new Date('2026-06-18T00:00:00.000Z'); +} + +/** + * Creates the generic BangDream plugin with a supplied host and default config snapshot. + * @param host - Generic host facade whose methods are inspected by adapter tests. + * @returns Plugin instance created through the generic worker contract. + */ +async function createGenericBangDreamPlugin(host: Record) { + return await createPlugin({ + host, + manifest: createGenericBangDreamManifest(), + normalizeError: normalizeGenericBangDreamError, + now: getGenericBangDreamNow, + runtime: { + configSnapshot: { + BANGDREAM_TSUGU_BESTDORI_BASE_URL: 'https://example.invalid/bestdori', + }, + installationId: 'install-bangdream', + }, + }); +} describe('BangDream package entry', () => { beforeEach(() => { @@ -160,4 +253,196 @@ describe('BangDream package entry', () => { plugin.executeOperation('bangdream.event.search', { text: '50' }), ).rejects.toThrow('normalized:图片渲染失败'); }); + + it('accepts the generic plugin platform createPlugin contract', /** + * Verifies the BangDream package entry can be created from the generic worker descriptor contract. + * @returns Assertion promise proving the entry exposes command execution and health hooks. + */ + async () => { + const plugin = await createGenericBangDreamPlugin({ + /** + * Simulates the generic host config RPC; the BangDream adapter should prefer runtime.configSnapshot for sync config reads. + * @returns No value so the test fails if sync package code depends on this async RPC path. + */ + getConfig: async () => undefined, + /** + * Simulates the generic host batch config RPC that is present on real worker hosts. + * @returns Empty config map because this contract test uses the startup snapshot as its config source. + */ + getConfigMany: async () => ({}), + /** + * Simulates dictionary lookup for BangDream alias/server mappings during plugin activation. + * @returns Empty dictionary list because this test only checks entry construction. + */ + getDictItemsByKey: async () => [], + /** + * Simulates package asset reads through the generic worker host boundary. + * @returns Empty buffer because no render asset is consumed while creating the plugin instance. + */ + readAssetFile: async () => Buffer.from([]), + /** + * Simulates binary HTTP requests for BangDream resource downloads. + * @returns Empty buffer because this contract test never executes a networked operation. + */ + requestBuffer: async () => Buffer.from([]), + /** + * Simulates JSON HTTP requests for BangDream catalog/data clients. + * @returns Empty object because this contract test only asserts the created plugin shape. + */ + requestJson: async () => ({}), + /** + * Simulates host-mediated sleep used by retry logic. + * @returns Resolved promise without delaying the package-entry contract test. + */ + sleep: async () => undefined, + /** + * Simulates host warning logging for non-fatal worker adapter notices. + * @returns Nothing because warning output is irrelevant to plugin construction. + */ + warn: () => undefined, + }); + + expect(plugin).toEqual( + expect.objectContaining({ + executeOperation: expect.any(Function), + health: expect.any(Function), + }), + ); + }); + + it('maps absolute package asset paths to package-relative generic host reads', /** + * Verifies the generic BangDream IO adapter never sends absolute package paths to host file APIs. + * @returns Assertion promise covering package-root relative host path normalization. + */ + async () => { + const readAssetFile = jest.fn( + /** + * Simulates generic host package asset reads and records the normalized path. + * @param filePath - Package-relative path received by the host bridge. + * @returns Distinct asset bytes used to prove the adapter returns host content. + */ + async (filePath: string) => Buffer.from(`asset:${filePath}`), + ); + await createGenericBangDreamPlugin({ + /** + * Simulates the generic host config RPC; sync BangDream config must come from the runtime snapshot. + * @returns No value because this test only exercises file IO path normalization. + */ + getConfig: async () => undefined, + /** + * Simulates dictionary lookup for activation-time cache refresh. + * @returns Empty list because file IO path normalization is independent of dictionaries. + */ + getDictItemsByKey: async () => [], + readAssetFile, + /** + * Simulates JSON HTTP host calls that are not used in this path-normalization test. + * @returns Empty object for unused request paths. + */ + requestJson: async () => ({}), + /** + * Simulates host sleep used by retry paths outside this test. + * @returns Resolved promise without delay. + */ + sleep: async () => undefined, + /** + * Simulates host warning logging for non-fatal adapter messages. + * @returns Nothing because warning output is irrelevant here. + */ + warn: () => undefined, + }); + const io = getConfiguredRuntimeIo(); + const absoluteAssetPath = path.join( + bangDreamPackageRoot, + 'src', + 'assets', + 'BG', + 'live.png', + ); + + await expect(io.readAssetFile?.(absoluteAssetPath)).resolves.toEqual( + Buffer.from('asset:src/assets/BG/live.png'), + ); + + expect(readAssetFile).toHaveBeenCalledWith('src/assets/BG/live.png'); + }); + + it('preloads sync JSON and parses Excel rows through generic host file reads', /** + * Verifies generic BangDream IO supplies sync JSON reads and XLSX parsing without absolute host paths. + * @returns Assertion promise covering synchronous JSON cache and XLSX buffer parsing. + */ + async () => { + const fuzzySearchConfig = { + aliases: { 夏祭り: ['natsumatsuri'] }, + servers: ['cn'], + }; + const workbookBuffer = createWorkbookBuffer(); + const readJsonFile = jest.fn( + /** + * Simulates generic host JSON reads used to preload synchronous BangDream config files. + * @param filePath - Package-relative JSON path received by the host bridge. + * @returns Fuzzy-search config snapshot for sync package consumers. + */ + async (filePath: string) => + filePath === 'src/config/static/fuzzy-search-settings.json' + ? fuzzySearchConfig + : { filePath }, + ); + const readAssetFile = jest.fn( + /** + * Simulates generic host binary package reads for Excel static patch files. + * @param filePath - Package-relative asset or config path received by the host bridge. + * @returns XLSX workbook bytes for static patch parsing. + */ + async (filePath: string) => + filePath.endsWith('.xlsx') ? workbookBuffer : Buffer.from([]), + ); + await createGenericBangDreamPlugin({ + /** + * Simulates the generic host config RPC; sync BangDream config must come from the runtime snapshot. + * @returns No value because this test only exercises package file IO. + */ + getConfig: async () => undefined, + /** + * Simulates dictionary lookup for activation-time cache refresh. + * @returns Empty list because file IO behavior is independent of dictionaries. + */ + getDictItemsByKey: async () => [], + readAssetFile, + readJsonFile, + /** + * Simulates JSON HTTP host calls that are not used in this file IO test. + * @returns Empty object for unused request paths. + */ + requestJson: async () => ({}), + /** + * Simulates host sleep used by retry paths outside this test. + * @returns Resolved promise without delay. + */ + sleep: async () => undefined, + /** + * Simulates host warning logging for non-fatal adapter messages. + * @returns Nothing because warning output is irrelevant here. + */ + warn: () => undefined, + }); + const io = getConfiguredRuntimeIo(); + const absoluteExcelPath = path.join( + bangDreamPackageRoot, + 'src', + 'config', + 'static', + 'cards.xlsx', + ); + + expect(io.readJsonFileSync?.(fuzzySearchPath)).toEqual(fuzzySearchConfig); + await expect(io.readExcelRows?.(absoluteExcelPath)).resolves.toEqual([ + { name: 'Test Card', score: 42 }, + ]); + + expect(readJsonFile).toHaveBeenCalledWith( + 'src/config/static/fuzzy-search-settings.json', + ); + expect(readAssetFile).toHaveBeenCalledWith('src/config/static/cards.xlsx'); + }); });