feat: 统一内置插件包入口契约

This commit is contained in:
sunlei 2026-06-18 04:34:59 +08:00
parent 1c6659def5
commit 0aa2e470cd
10 changed files with 1410 additions and 30 deletions

View File

@ -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"

View File

@ -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<string, unknown>;
tasks?: unknown[];
version?: string;
};
export type QqbotGenericPluginCreateOptions = {
host: Record<string, unknown>;
manifest: BangDreamGenericManifest;
normalizeError: (error: unknown, fallback?: string) => string | Error;
now: () => Date;
runtime: {
configSnapshot: Record<string, string | undefined>;
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<BangDreamCommandPlugin>;
export function createPlugin(
options: BangDreamPluginCreateOptions,
): BangDreamCommandPlugin | Promise<BangDreamCommandPlugin> {
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<BangDreamCommandPlugin> {
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<string, string | undefined>,
): 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: <T = string>(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<string, unknown>,
): 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<string, unknown>,
): 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 <T extends Record<string, unknown>>(
filePath: string,
) =>
parseBangDreamExcelRows<T>(
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 <T = unknown>(filePath: string) =>
await callBangDreamGenericHost<T>(
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: <T = unknown>(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 <T = unknown>(url: string, requestOptions) => ({
body: await callBangDreamGenericHost<T>(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<string, unknown>,
pathMapper: BangDreamGenericPathMapper,
) {
const cache = new Map<string, unknown>();
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<T extends Record<string, unknown>>(
buffer: Buffer,
): T[] {
const workbook = XLSX.read(buffer, { type: 'buffer' });
const sheetName = workbook.SheetNames[0];
if (!sheetName) return [];
return XLSX.utils.sheet_to_json<T>(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<TResult = any>(
host: Record<string, unknown>,
method: string,
...args: unknown[]
): Promise<TResult> {
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列表项

View File

@ -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",

View File

@ -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<string, unknown>;
manifest: Ff14MarketManifest & { key?: string };
normalizeError: (error: unknown, fallback?: string) => string | Error;
now: () => Date;
runtime: {
configSnapshot: Record<string, string | undefined>;
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: <T = string>(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 <T>(request) =>
await callFf14GenericHost<T>(
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<TResult = any>(
host: Record<string, unknown>,
method: string,
...args: unknown[]
): Promise<TResult> {
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<Ff14MarketPluginHost['requestJson']>[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

View File

@ -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",

View File

@ -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<string, unknown>;
manifest: FflogsManifest & { key?: string };
normalizeError: (error: unknown, fallback?: string) => string | Error;
now: () => Date;
runtime: {
configSnapshot: Record<string, string | undefined>;
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: <T = string>(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 <T>(request) =>
await callFflogsGenericHost<T>(
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<string, unknown>,
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<TResult = any>(
host: Record<string, unknown>,
method: string,
...args: unknown[]
): Promise<TResult> {
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<FflogsPluginHost['requestJson']>[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步骤

View File

@ -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: <T = string>(key: string) => T | undefined;
getDictByKey: (
getDictByKey?: (
dictCode: string,
) => Promise<Array<{ label?: string; value?: string }>>;
getDictItemsByKey?: (dictCode: string) => Promise<Ff14DictItem[]>;
relationTree?: (input: { dictCode: string }) => Promise<Ff14DictItem[]>;
requestJson: <T>(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<string, string>();
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<Ff14DictItem[]> {
if (this.host.getDictItemsByKey) {
return this.host.getDictItemsByKey(dictCode);
}
if (this.host.getDictByKey) {
return this.host.getDictByKey(dictCode);
}
return [];
}
/**
* FFLogs
* @param value - localizeEncounter

View File

@ -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",

View File

@ -9,11 +9,43 @@ type RepeaterPluginOptions = {
now?: () => number;
};
export type QqbotGenericPluginCreateOptions = {
host: Record<string, unknown>;
manifest: RepeaterManifest & { key?: string };
normalizeError: (error: unknown, fallback?: string) => string | Error;
now: () => Date;
runtime: {
configSnapshot: Record<string, string | undefined>;
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: <T = string>(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<boolean>,
) {
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<TResult = any>(
host: Record<string, unknown>,
method: string,
...args: unknown[]
): Promise<TResult> {
const fn = host[method];
if (typeof fn !== 'function') {
throw new Error(`Repeater generic host 缺少 ${method}`);
}
return (await fn(...args)) as TResult;
}

View File

@ -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<string, unknown>) {
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');
});
});