From 2c6e845995e505618c2accc3c6735e058c9d8c60 Mon Sep 17 00:00:00 2001 From: sunlei Date: Tue, 16 Jun 2026 13:00:19 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=BC=BA=E5=8C=96QQBot=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=9C=9F=E8=BF=90=E8=A1=8C=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qqbot-account-napcat-runtime.port.ts | 38 + .../account/qqbot-account.service.ts | 341 +------- .../qqbot-napcat-account-runtime.service.ts | 377 ++++++++ .../qqbot/napcat/qqbot-napcat.module.ts | 9 +- .../application/plugin-execution.adapter.ts | 36 +- .../application/plugin-platform.service.ts | 393 ++++++++- .../registry/qqbot-plugin-registry.service.ts | 19 +- .../contract/qqbot-plugin.controller.ts | 33 +- .../builtin-plugin-package-loader.service.ts | 88 +- .../builtin-plugin-worker-runtime.factory.ts | 212 +++-- .../runtime/builtin-plugin-worker.thread.ts | 531 +++++++++++ .../integration/runtime/worker-runtime.ts | 20 +- .../qqbot-module-boundary.spec.ts | 22 + .../napcat/napcat-module-ownership.spec.ts | 20 +- .../plugin-lifecycle-runtime.spec.ts | 822 +++++++++++++++++- .../plugin-platform-api-contract.spec.ts | 99 ++- .../plugin-platform/worker-runtime.spec.ts | 24 + .../plugin-controller-http-smoke.spec.ts | 69 +- .../account/qqbot-account.service.spec.ts | 394 ++++----- 19 files changed, 2795 insertions(+), 752 deletions(-) create mode 100644 src/modules/qqbot/core/application/account/qqbot-account-napcat-runtime.port.ts create mode 100644 src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts create mode 100644 src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker.thread.ts diff --git a/src/modules/qqbot/core/application/account/qqbot-account-napcat-runtime.port.ts b/src/modules/qqbot/core/application/account/qqbot-account-napcat-runtime.port.ts new file mode 100644 index 0000000..37f1419 --- /dev/null +++ b/src/modules/qqbot/core/application/account/qqbot-account-napcat-runtime.port.ts @@ -0,0 +1,38 @@ +import type { QqbotAccount } from '../../infrastructure/persistence/account/qqbot-account.entity'; +import type { + QqbotAccountListItem, + QqbotConnectionRole, +} from '../../contract/qqbot.types'; + +export const QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT = Symbol( + 'QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT', +); + +export type QqbotAccountNapcatRuntimeActions = { + clearQqLoginError(selfId: string): Promise; + getLoginPassword( + account: Pick, + ): string; + markOnline( + selfId: string, + clientRole: QqbotConnectionRole, + lastError?: null | string, + ): Promise; + markQqLoginOffline(selfId: string, lastError: string): Promise; + publishOfflineNotice( + selfId: string, + offlineReason: string, + metadata: Record, + ): void; +}; + +export type QqbotAccountNapcatRuntimePort = { + appendRuntime( + accounts: QqbotAccount[], + options: { autoLogin?: boolean }, + actions: QqbotAccountNapcatRuntimeActions, + ): Promise; + removeAccountContainers(accountId: string): Promise<{ + deletedContainers: number; + }>; +}; diff --git a/src/modules/qqbot/core/application/account/qqbot-account.service.ts b/src/modules/qqbot/core/application/account/qqbot-account.service.ts index 9b29fa4..ffd811b 100644 --- a/src/modules/qqbot/core/application/account/qqbot-account.service.ts +++ b/src/modules/qqbot/core/application/account/qqbot-account.service.ts @@ -9,6 +9,10 @@ import { ToolsService, } from '@/common'; import { AdminPasswordCryptoService } from '@/modules/admin/identity/auth/admin-password-crypto.service'; +import { + QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT, + type QqbotAccountNapcatRuntimePort, +} from './qqbot-account-napcat-runtime.port'; import { QqbotAccountAbility } from '../../infrastructure/persistence/account/qqbot-account-ability.entity'; import { QqbotAccount } from '../../infrastructure/persistence/account/qqbot-account.entity'; import type { @@ -16,9 +20,6 @@ import type { QqbotAccountQueryDto, QqbotAccountUpdateDto, } from '../../contract/account/qqbot-account.dto'; -import { NapcatAccountBinding } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-account-binding.entity'; -import { NapcatContainer } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-container.entity'; -import { QqbotNapcatContainerService } from '@/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service'; import { QQBOT_DEFAULT_PAGE_NO, QQBOT_DEFAULT_PAGE_SIZE, @@ -27,12 +28,8 @@ import type { QqbotAccountAbilityType, QqbotAccountListItem, QqbotConnectionRole, - QqbotNapcatRuntimeStatusSnapshot, } from '../../contract/qqbot.types'; -const NAPCAT_RUNTIME_CHECK_TTL_MS = 30_000; -const NAPCAT_AUTO_LOGIN_CLEANUP_FAILED_MESSAGE = - 'NapCat 自动登录后运行态密码清理失败,请手动更新登录'; const INSECURE_ACCOUNT_SECRET_VALUES = new Set([ 'change-me', 'kt-template-online-admin-token-secret', @@ -45,13 +42,11 @@ export class QqbotAccountService { private readonly accountRepository: Repository, @InjectRepository(QqbotAccountAbility) private readonly accountAbilityRepository: Repository, - @InjectRepository(NapcatAccountBinding) - private readonly accountNapcatRepository: Repository, - @InjectRepository(NapcatContainer) - private readonly napcatContainerRepository: Repository, - private readonly napcatContainerService: QqbotNapcatContainerService, private readonly toolsService: ToolsService, @Optional() + @Inject(QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT) + private readonly napcatRuntime?: QqbotAccountNapcatRuntimePort, + @Optional() @Inject(SYSTEM_NOTICE_PUBLISHER) private readonly systemNoticePublisher?: SystemNoticePublisher, @Optional() @@ -319,7 +314,9 @@ export class QqbotAccountService { } const containerResult = - await this.napcatContainerService.removeAccountContainers(id); + (await this.napcatRuntime?.removeAccountContainers(id)) || { + deletedContainers: 0, + }; await this.accountRepository.update( { id }, { @@ -427,316 +424,24 @@ export class QqbotAccountService { accounts: QqbotAccount[], options: { autoLogin?: boolean } = {}, ): Promise { - if (accounts.length <= 0) return []; - - const accountIds = accounts.map((account) => account.id); - const bindings = await this.accountNapcatRepository - .createQueryBuilder('binding') - .where('binding.accountId IN (:...accountIds)', { accountIds }) - .andWhere('binding.isDeleted = :isDeleted', { isDeleted: false }) - .orderBy('binding.isPrimary', 'DESC') - .addOrderBy('binding.updateTime', 'DESC') - .getMany(); - const bindingMap = new Map(); - for (const binding of bindings) { - if (!bindingMap.has(binding.accountId)) { - bindingMap.set(binding.accountId, binding); - } + if (!this.napcatRuntime) { + return accounts.map((account) => Object.assign(account, { napcat: null })); } - const containerIds = Array.from( - new Set(bindings.map((binding) => binding.containerId).filter(Boolean)), - ); - const containerMap = new Map(); - if (containerIds.length > 0) { - const containerBuilder = this.napcatContainerRepository - .createQueryBuilder('container'); - containerBuilder.addSelect?.('container.webuiToken'); - const containers = await containerBuilder - .where('container.id IN (:...containerIds)', { containerIds }) - .andWhere('container.isDeleted = :isDeleted', { isDeleted: false }) - .getMany(); - for (const container of containers) { - containerMap.set(container.id, container); - } - } - - return Promise.all( - accounts.map(async (account) => { - const binding = bindingMap.get(account.id); - if (!binding) { - return Object.assign(account, { napcat: null }); - } - - const container = containerMap.get(binding.containerId); - const runtimeStatus = await this.syncNapcatRuntimeState( - account, - container, - options, - ); - return Object.assign(account, { - napcat: { - bindStatus: binding.bindStatus, - containerId: binding.containerId, - containerName: container?.name, - containerOnline: - runtimeStatus?.containerOnline ?? - (container?.status === 'running' || false), - containerStatus: container?.status, - lastCheckedAt: runtimeStatus?.checkedAt || container?.lastCheckedAt, - lastError: runtimeStatus?.lastError ?? container?.lastError, - lastLoginAt: binding.lastLoginAt, - lastStartedAt: container?.lastStartedAt, - oneBotOnline: account.connectStatus === 'online', - qqLoginMessage: runtimeStatus?.qqLoginMessage, - qqLoginStatus: runtimeStatus?.qqLoginStatus, - webuiOnline: runtimeStatus?.webuiOnline, - webuiPort: container?.webuiPort, - }, - }); - }), - ); - } - - private async syncNapcatRuntimeState( - account: QqbotAccount, - container?: NapcatContainer, - options: { autoLogin?: boolean } = {}, - ) { - const runtimeStatus = await this.getNapcatRuntimeStatus(account, container); - if (!container || container.status !== 'running') return runtimeStatus; - if (account.connectStatus !== 'online') return runtimeStatus; - - if (this.isRecentConnectNewerThanRuntimeCheck(account, container)) { - return runtimeStatus; - } - - const runtimeOfflineReason = - this.getRuntimeStatusOfflineReason(runtimeStatus); - if (runtimeOfflineReason) { - if (options.autoLogin && (await this.tryAutoLogin(account, container))) { - return this.toCachedNapcatRuntimeStatus(container); - } - await this.applyNapcatOfflineState( - account, - container, - runtimeOfflineReason, - ); - return runtimeStatus; - } - - const cachedOfflineReason = this.getFreshCachedOfflineReason(container); - if (cachedOfflineReason) { - if (options.autoLogin && (await this.tryAutoLogin(account, container))) { - return this.toCachedNapcatRuntimeStatus(container); - } - await this.applyNapcatOfflineState( - account, - container, - cachedOfflineReason, - ); - return runtimeStatus; - } - if (this.isFreshRuntimeCheck(container.lastCheckedAt)) { - return runtimeStatus; - } - - const offlineReason = - await this.napcatContainerService.detectRuntimeOffline(container); - if (!offlineReason) return runtimeStatus; - - if (options.autoLogin && (await this.tryAutoLogin(account, container))) { - return this.toCachedNapcatRuntimeStatus(container); - } - - await this.applyNapcatOfflineState(account, container, offlineReason); - return { - ...runtimeStatus, - checkedAt: new Date(), - lastError: offlineReason, - qqLoginMessage: offlineReason, - qqLoginStatus: 'offline', - } as QqbotNapcatRuntimeStatusSnapshot; - } - - private async getNapcatRuntimeStatus( - account: QqbotAccount, - container?: NapcatContainer, - ): Promise { - if (!container) return undefined; - const cached = this.toCachedNapcatRuntimeStatus(container); - if (container.status !== 'running') return cached; - if (this.isRecentConnectNewerThanRuntimeCheck(account, container)) { - return cached; - } - if (this.isFreshRuntimeCheck(container.lastCheckedAt)) return cached; - if ( - typeof this.napcatContainerService.inspectRuntimeStatus !== 'function' - ) { - return cached; - } - - const inspected = - await this.napcatContainerService.inspectRuntimeStatus(container); - container.lastCheckedAt = inspected.checkedAt as any; - container.lastError = inspected.lastError || null; - await this.clearQqLoginErrorIfConfirmedOnline(account, inspected); - return inspected; - } - - private async clearQqLoginErrorIfConfirmedOnline( - account: QqbotAccount, - runtimeStatus: QqbotNapcatRuntimeStatusSnapshot, - ) { - if (runtimeStatus.qqLoginStatus !== 'online') return; - const lastError = this.toolsService.toTrimmedString(account.lastError); - if (!lastError || !this.isQqLoginStateError(lastError)) return; - - await this.accountRepository.update( - { selfId: account.selfId }, - { lastError: null }, - ); - account.lastError = null; - } - - private toCachedNapcatRuntimeStatus( - container: NapcatContainer, - ): QqbotNapcatRuntimeStatusSnapshot { - const containerOnline = container.status === 'running'; - const lastError = this.toolsService.toTrimmedString(container.lastError); - const offlineReason = this.toolsService.isNapcatOfflineLoginMessage( - lastError, - ) - ? lastError - : null; - return { - checkedAt: container.lastCheckedAt || undefined, - containerOnline, - lastError: lastError || null, - qqLoginMessage: offlineReason, - qqLoginStatus: this.toCachedQqLoginStatus(containerOnline, lastError), - webuiOnline: containerOnline ? null : false, - }; - } - - private toCachedQqLoginStatus( - containerOnline: boolean, - lastError: string, - ): QqbotNapcatRuntimeStatusSnapshot['qqLoginStatus'] { - if (!containerOnline) return 'offline'; - if ( - lastError.includes('二维码已过期') || - lastError.includes('二维码过期') - ) { - return 'qrcode_expired'; - } - if (this.toolsService.isNapcatOfflineLoginMessage(lastError)) { - return 'offline'; - } - return 'unknown'; - } - - private isQqLoginStateError(message: string) { - return ( - this.toolsService.isNapcatOfflineLoginMessage(message) || - message.includes('二维码已过期') || - message.includes('二维码过期') - ); - } - - private getRuntimeStatusOfflineReason( - runtimeStatus?: QqbotNapcatRuntimeStatusSnapshot, - ) { - if (!runtimeStatus) return null; - if ( - runtimeStatus.qqLoginStatus !== 'offline' && - runtimeStatus.qqLoginStatus !== 'qrcode_expired' - ) { - return null; - } - return ( - this.toolsService.toTrimmedString(runtimeStatus.qqLoginMessage) || - this.toolsService.toTrimmedString(runtimeStatus.lastError) || - 'NapCat QQ 登录态不可用' - ); - } - - private async tryAutoLogin( - account: QqbotAccount, - container: NapcatContainer, - ) { - try { - const result = await this.napcatContainerService.tryAutoLogin(container, { - loginPassword: this.getNapcatLoginPassword(account), - selfId: account.selfId, - }); - if (result.cleanupFailed) { - await this.applyNapcatOfflineState( - account, - container, - NAPCAT_AUTO_LOGIN_CLEANUP_FAILED_MESSAGE, - ); - return true; - } - if (!result.success) return false; - - await this.markOnline(account.selfId, 'Universal', null); - account.clientRole = 'Universal'; - account.connectStatus = 'online'; - account.lastConnectedAt = new Date() as any; - account.lastError = null; - return true; - } catch { - return false; - } - } - - private async applyNapcatOfflineState( - account: QqbotAccount, - container: NapcatContainer, - offlineReason: string, - ) { - await this.markQqLoginOffline(account.selfId, offlineReason); - account.lastError = offlineReason; - this.publishOfflineNotice(account.selfId, offlineReason, { - containerId: container.id, - containerName: container.name, + return this.napcatRuntime.appendRuntime(accounts, options, { + clearQqLoginError: async (selfId) => { + await this.accountRepository.update({ selfId }, { lastError: null }); + }, + getLoginPassword: (account) => this.getNapcatLoginPassword(account), + markOnline: (selfId, clientRole, lastError) => + this.markOnline(selfId, clientRole, lastError), + markQqLoginOffline: (selfId, lastError) => + this.markQqLoginOffline(selfId, lastError), + publishOfflineNotice: (selfId, offlineReason, metadata) => + this.publishOfflineNotice(selfId, offlineReason, metadata), }); } - private getFreshCachedOfflineReason(container: NapcatContainer) { - if (!this.isFreshRuntimeCheck(container.lastCheckedAt)) return null; - const reason = this.toolsService.toTrimmedString(container.lastError); - return this.toolsService.isNapcatOfflineLoginMessage(reason) - ? reason - : null; - } - - private isRecentConnectNewerThanRuntimeCheck( - account: QqbotAccount, - container: NapcatContainer, - ) { - const checkedAt = this.toTime(container.lastCheckedAt); - if (!checkedAt) return false; - const connectedAt = this.toTime(account.lastConnectedAt); - if (connectedAt <= checkedAt) return false; - - return Date.now() - connectedAt < NAPCAT_RUNTIME_CHECK_TTL_MS; - } - - private isFreshRuntimeCheck(lastCheckedAt?: Date | null) { - if (!lastCheckedAt) return false; - const checkedAt = this.toTime(lastCheckedAt); - if (!Number.isFinite(checkedAt)) return false; - return Date.now() - checkedAt < NAPCAT_RUNTIME_CHECK_TTL_MS; - } - - private toTime(value?: Date | null) { - if (!value) return 0; - const time = new Date(value).getTime(); - return Number.isFinite(time) ? time : 0; - } - private publishOfflineNotice( selfId: string, offlineReason: string, diff --git a/src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts b/src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts new file mode 100644 index 0000000..487b342 --- /dev/null +++ b/src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts @@ -0,0 +1,377 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ToolsService } from '@/common'; +import type { + QqbotAccountNapcatRuntimeActions, + QqbotAccountNapcatRuntimePort, +} from '@/modules/qqbot/core/application/account/qqbot-account-napcat-runtime.port'; +import { QqbotAccount } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity'; +import type { + QqbotAccountListItem, + QqbotNapcatRuntimeStatusSnapshot, +} from '@/modules/qqbot/core/contract/qqbot.types'; +import { QqbotNapcatContainerService } from '../../infrastructure/integration/container/qqbot-napcat-container.service'; +import { NapcatAccountBinding } from '../../infrastructure/persistence/napcat-account-binding.entity'; +import { NapcatContainer } from '../../infrastructure/persistence/napcat-container.entity'; + +const NAPCAT_RUNTIME_CHECK_TTL_MS = 30_000; +const NAPCAT_AUTO_LOGIN_CLEANUP_FAILED_MESSAGE = + 'NapCat 自动登录后运行态密码清理失败,请手动更新登录'; + +@Injectable() +export class QqbotNapcatAccountRuntimeService + implements QqbotAccountNapcatRuntimePort +{ + constructor( + @InjectRepository(NapcatAccountBinding) + private readonly accountNapcatRepository: Repository, + @InjectRepository(NapcatContainer) + private readonly napcatContainerRepository: Repository, + private readonly napcatContainerService: QqbotNapcatContainerService, + private readonly toolsService: ToolsService, + ) {} + + async appendRuntime( + accounts: QqbotAccount[], + options: { autoLogin?: boolean }, + actions: QqbotAccountNapcatRuntimeActions, + ): Promise { + if (accounts.length <= 0) return []; + + const accountIds = accounts.map((account) => account.id); + const bindings = await this.accountNapcatRepository + .createQueryBuilder('binding') + .where('binding.accountId IN (:...accountIds)', { accountIds }) + .andWhere('binding.isDeleted = :isDeleted', { isDeleted: false }) + .orderBy('binding.isPrimary', 'DESC') + .addOrderBy('binding.updateTime', 'DESC') + .getMany(); + const bindingMap = new Map(); + for (const binding of bindings) { + if (!bindingMap.has(binding.accountId)) { + bindingMap.set(binding.accountId, binding); + } + } + + const containerIds = Array.from( + new Set(bindings.map((binding) => binding.containerId).filter(Boolean)), + ); + const containerMap = new Map(); + if (containerIds.length > 0) { + const containerBuilder = + this.napcatContainerRepository.createQueryBuilder('container'); + containerBuilder.addSelect?.('container.webuiToken'); + const containers = await containerBuilder + .where('container.id IN (:...containerIds)', { containerIds }) + .andWhere('container.isDeleted = :isDeleted', { isDeleted: false }) + .getMany(); + for (const container of containers) { + containerMap.set(container.id, container); + } + } + + return Promise.all( + accounts.map(async (account) => { + const binding = bindingMap.get(account.id); + if (!binding) { + return Object.assign(account, { napcat: null }); + } + + const container = containerMap.get(binding.containerId); + const runtimeStatus = await this.syncNapcatRuntimeState( + account, + container, + options, + actions, + ); + return Object.assign(account, { + napcat: { + bindStatus: binding.bindStatus, + containerId: binding.containerId, + containerName: container?.name, + containerOnline: + runtimeStatus?.containerOnline ?? + (container?.status === 'running' || false), + containerStatus: container?.status, + lastCheckedAt: runtimeStatus?.checkedAt || container?.lastCheckedAt, + lastError: runtimeStatus?.lastError ?? container?.lastError, + lastLoginAt: binding.lastLoginAt, + lastStartedAt: container?.lastStartedAt, + oneBotOnline: account.connectStatus === 'online', + qqLoginMessage: runtimeStatus?.qqLoginMessage, + qqLoginStatus: runtimeStatus?.qqLoginStatus, + webuiOnline: runtimeStatus?.webuiOnline, + webuiPort: container?.webuiPort, + }, + }); + }), + ); + } + + removeAccountContainers(accountId: string) { + return this.napcatContainerService.removeAccountContainers(accountId); + } + + private async syncNapcatRuntimeState( + account: QqbotAccount, + container: NapcatContainer | undefined, + options: { autoLogin?: boolean }, + actions: QqbotAccountNapcatRuntimeActions, + ) { + const runtimeStatus = await this.getNapcatRuntimeStatus( + account, + container, + actions, + ); + if (!container || container.status !== 'running') return runtimeStatus; + if (account.connectStatus !== 'online') return runtimeStatus; + + if (this.isRecentConnectNewerThanRuntimeCheck(account, container)) { + return runtimeStatus; + } + + const runtimeOfflineReason = + this.getRuntimeStatusOfflineReason(runtimeStatus); + if (runtimeOfflineReason) { + if ( + options.autoLogin && + (await this.tryAutoLogin(account, container, actions)) + ) { + return this.toCachedNapcatRuntimeStatus(container); + } + await this.applyNapcatOfflineState( + account, + container, + runtimeOfflineReason, + actions, + ); + return runtimeStatus; + } + + const cachedOfflineReason = this.getFreshCachedOfflineReason(container); + if (cachedOfflineReason) { + if ( + options.autoLogin && + (await this.tryAutoLogin(account, container, actions)) + ) { + return this.toCachedNapcatRuntimeStatus(container); + } + await this.applyNapcatOfflineState( + account, + container, + cachedOfflineReason, + actions, + ); + return runtimeStatus; + } + if (this.isFreshRuntimeCheck(container.lastCheckedAt)) { + return runtimeStatus; + } + + const offlineReason = + await this.napcatContainerService.detectRuntimeOffline(container); + if (!offlineReason) return runtimeStatus; + + if ( + options.autoLogin && + (await this.tryAutoLogin(account, container, actions)) + ) { + return this.toCachedNapcatRuntimeStatus(container); + } + + await this.applyNapcatOfflineState( + account, + container, + offlineReason, + actions, + ); + return { + ...runtimeStatus, + checkedAt: new Date(), + lastError: offlineReason, + qqLoginMessage: offlineReason, + qqLoginStatus: 'offline', + } as QqbotNapcatRuntimeStatusSnapshot; + } + + private async getNapcatRuntimeStatus( + account: QqbotAccount, + container: NapcatContainer | undefined, + actions: QqbotAccountNapcatRuntimeActions, + ): Promise { + if (!container) return undefined; + const cached = this.toCachedNapcatRuntimeStatus(container); + if (container.status !== 'running') return cached; + if (this.isRecentConnectNewerThanRuntimeCheck(account, container)) { + return cached; + } + if (this.isFreshRuntimeCheck(container.lastCheckedAt)) return cached; + if ( + typeof this.napcatContainerService.inspectRuntimeStatus !== 'function' + ) { + return cached; + } + + const inspected = + await this.napcatContainerService.inspectRuntimeStatus(container); + container.lastCheckedAt = inspected.checkedAt as any; + container.lastError = inspected.lastError || null; + await this.clearQqLoginErrorIfConfirmedOnline(account, inspected, actions); + return inspected; + } + + private async clearQqLoginErrorIfConfirmedOnline( + account: QqbotAccount, + runtimeStatus: QqbotNapcatRuntimeStatusSnapshot, + actions: QqbotAccountNapcatRuntimeActions, + ) { + if (runtimeStatus.qqLoginStatus !== 'online') return; + const lastError = this.toolsService.toTrimmedString(account.lastError); + if (!lastError || !this.isQqLoginStateError(lastError)) return; + + await actions.clearQqLoginError(account.selfId); + account.lastError = null; + } + + private toCachedNapcatRuntimeStatus( + container: NapcatContainer, + ): QqbotNapcatRuntimeStatusSnapshot { + const containerOnline = container.status === 'running'; + const lastError = this.toolsService.toTrimmedString(container.lastError); + const offlineReason = this.toolsService.isNapcatOfflineLoginMessage( + lastError, + ) + ? lastError + : null; + return { + checkedAt: container.lastCheckedAt || undefined, + containerOnline, + lastError: lastError || null, + qqLoginMessage: offlineReason, + qqLoginStatus: this.toCachedQqLoginStatus(containerOnline, lastError), + webuiOnline: containerOnline ? null : false, + }; + } + + private toCachedQqLoginStatus( + containerOnline: boolean, + lastError: string, + ): QqbotNapcatRuntimeStatusSnapshot['qqLoginStatus'] { + if (!containerOnline) return 'offline'; + if ( + lastError.includes('二维码已过期') || + lastError.includes('二维码过期') + ) { + return 'qrcode_expired'; + } + if (this.toolsService.isNapcatOfflineLoginMessage(lastError)) { + return 'offline'; + } + return 'unknown'; + } + + private isQqLoginStateError(message: string) { + return ( + this.toolsService.isNapcatOfflineLoginMessage(message) || + message.includes('二维码已过期') || + message.includes('二维码过期') + ); + } + + private getRuntimeStatusOfflineReason( + runtimeStatus?: QqbotNapcatRuntimeStatusSnapshot, + ) { + if (!runtimeStatus) return null; + if ( + runtimeStatus.qqLoginStatus !== 'offline' && + runtimeStatus.qqLoginStatus !== 'qrcode_expired' + ) { + return null; + } + return ( + this.toolsService.toTrimmedString(runtimeStatus.qqLoginMessage) || + this.toolsService.toTrimmedString(runtimeStatus.lastError) || + 'NapCat QQ 登录态不可用' + ); + } + + private async tryAutoLogin( + account: QqbotAccount, + container: NapcatContainer, + actions: QqbotAccountNapcatRuntimeActions, + ) { + try { + const result = await this.napcatContainerService.tryAutoLogin(container, { + loginPassword: actions.getLoginPassword(account), + selfId: account.selfId, + }); + if (result.cleanupFailed) { + await this.applyNapcatOfflineState( + account, + container, + NAPCAT_AUTO_LOGIN_CLEANUP_FAILED_MESSAGE, + actions, + ); + return true; + } + if (!result.success) return false; + + await actions.markOnline(account.selfId, 'Universal', null); + account.clientRole = 'Universal'; + account.connectStatus = 'online'; + account.lastConnectedAt = new Date() as any; + account.lastError = null; + return true; + } catch { + return false; + } + } + + private async applyNapcatOfflineState( + account: QqbotAccount, + container: NapcatContainer, + offlineReason: string, + actions: QqbotAccountNapcatRuntimeActions, + ) { + await actions.markQqLoginOffline(account.selfId, offlineReason); + account.lastError = offlineReason; + actions.publishOfflineNotice(account.selfId, offlineReason, { + containerId: container.id, + containerName: container.name, + }); + } + + private getFreshCachedOfflineReason(container: NapcatContainer) { + if (!this.isFreshRuntimeCheck(container.lastCheckedAt)) return null; + const reason = this.toolsService.toTrimmedString(container.lastError); + return this.toolsService.isNapcatOfflineLoginMessage(reason) + ? reason + : null; + } + + private isRecentConnectNewerThanRuntimeCheck( + account: QqbotAccount, + container: NapcatContainer, + ) { + const checkedAt = this.toTime(container.lastCheckedAt); + if (!checkedAt) return false; + const connectedAt = this.toTime(account.lastConnectedAt); + if (connectedAt <= checkedAt) return false; + + return Date.now() - connectedAt < NAPCAT_RUNTIME_CHECK_TTL_MS; + } + + private isFreshRuntimeCheck(lastCheckedAt?: Date | null) { + if (!lastCheckedAt) return false; + const checkedAt = this.toTime(lastCheckedAt); + if (!Number.isFinite(checkedAt)) return false; + return Date.now() - checkedAt < NAPCAT_RUNTIME_CHECK_TTL_MS; + } + + private toTime(value?: Date | null) { + if (!value) return 0; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : 0; + } +} diff --git a/src/modules/qqbot/napcat/qqbot-napcat.module.ts b/src/modules/qqbot/napcat/qqbot-napcat.module.ts index 144faf9..3f393df 100644 --- a/src/modules/qqbot/napcat/qqbot-napcat.module.ts +++ b/src/modules/qqbot/napcat/qqbot-napcat.module.ts @@ -2,7 +2,9 @@ import { forwardRef, Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AdminAuthGuardModule } from '@/modules/admin/identity/auth/admin-auth-guard.module'; +import { QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT } from '@/modules/qqbot/core/application/account/qqbot-account-napcat-runtime.port'; import { QqbotCoreModule } from '@/modules/qqbot/core/qqbot-core.module'; +import { QqbotNapcatAccountRuntimeService } from './application/account-runtime/qqbot-napcat-account-runtime.service'; import { QqbotNapcatLoginService } from './application/login/qqbot-napcat-login.service'; import { QqbotNapcatWatchdogService } from './application/login/qqbot-napcat-watchdog.service'; import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller'; @@ -20,15 +22,20 @@ export const QQBOT_NAPCAT_CONTROLLERS = [QqbotNapcatLoginController]; export const QQBOT_NAPCAT_PROVIDERS = [ NapcatDeviceIdentityService, NapcatLoginStateStoreService, + QqbotNapcatAccountRuntimeService, QqbotNapcatContainerService, QqbotNapcatLoginService, QqbotNapcatWatchdogService, + { + provide: QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT, + useExisting: QqbotNapcatAccountRuntimeService, + }, ]; export const QQBOT_NAPCAT_EXPORTS = [ NapcatDeviceIdentityService, NapcatLoginStateStoreService, - QqbotNapcatContainerService, + QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT, QqbotNapcatLoginService, ]; diff --git a/src/modules/qqbot/plugin-platform/application/plugin-execution.adapter.ts b/src/modules/qqbot/plugin-platform/application/plugin-execution.adapter.ts index 8dab6e3..b3f8dfb 100644 --- a/src/modules/qqbot/plugin-platform/application/plugin-execution.adapter.ts +++ b/src/modules/qqbot/plugin-platform/application/plugin-execution.adapter.ts @@ -5,49 +5,25 @@ import type { QqbotPluginExecutionPort, QqbotPluginOperationLookup, } from '@/modules/qqbot/core/domain/plugin-execution.port'; -import { QqbotEventPluginRegistryService } from './registry/qqbot-event-plugin-registry.service'; -import { QqbotPluginRegistryService } from './registry/qqbot-plugin-registry.service'; -import { QqbotPluginArgumentParserService } from './argument/qqbot-plugin-argument-parser.service'; +import { QqbotPluginPlatformService } from './plugin-platform.service'; @Injectable() export class QqbotPluginExecutionAdapter implements QqbotPluginExecutionPort { - constructor( - private readonly argumentParser: QqbotPluginArgumentParserService, - private readonly eventPluginRegistry: QqbotEventPluginRegistryService, - private readonly pluginRegistry: QqbotPluginRegistryService, - ) {} + constructor(private readonly platformService: QqbotPluginPlatformService) {} async executeOperation(input: QqbotPluginExecutionInput) { - const normalizedInput = await this.argumentParser.normalizeInput(input); - return this.pluginRegistry.execute( - input.pluginKey, - input.operationKey, - normalizedInput, - { - ...(input.context || {}), - args: normalizedInput, - }, - ); + return this.platformService.executeOperation(input); } async dispatchEvent(input: QqbotPluginEventDispatchInput) { - if (input.eventKey !== 'message') return false; - return this.eventPluginRegistry.dispatchMessage(input.message); + return this.platformService.dispatchEvent(input); } async listActiveOperations() { - return [ - ...this.pluginRegistry.listOperations(), - ...this.eventPluginRegistry.listOperations(), - ]; + return this.platformService.listActiveOperations(); } async getOperationByCommand(command: QqbotPluginOperationLookup) { - if (!command.pluginKey || !command.operationKey) return null; - return ( - this.pluginRegistry - .listOperations(command.pluginKey) - .find((operation) => operation.key === command.operationKey) || null - ); + return this.platformService.getOperationByCommand(command); } } diff --git a/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts b/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts index 340c31d..fb0c34b 100644 --- a/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts +++ b/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts @@ -1,20 +1,27 @@ -import { Inject, Injectable, Optional } from '@nestjs/common'; +import { Inject, Injectable, OnModuleInit, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Between, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; import { throwVbenError } from '@/common'; import { - QQBOT_PLUGIN_EXECUTION_PORT, type QqbotPluginEventDispatchInput, type QqbotPluginExecutionInput, type QqbotPluginExecutionPort, + type QqbotPluginOperationLookup, } from '@/modules/qqbot/core/domain/plugin-execution.port'; +import type { + QqbotPluginOperationSummary, + QqbotPluginTriggerMode, +} from '@/modules/qqbot/core/contract/qqbot.types'; import { parseQqbotPluginManifest, type QqbotPluginManifest, } from '../domain/manifest'; import type { QqbotPluginWorkerRuntime } from '../infrastructure/integration/runtime'; +import { QqbotPluginArgumentParserService } from './argument/qqbot-plugin-argument-parser.service'; +import { QqbotBuiltinPluginPackageLoaderService } from '../infrastructure/integration/package/builtin-plugin-package-loader.service'; import { QqbotPluginPackageReaderService } from '../infrastructure/integration/package/plugin-package-reader.service'; import { QqbotEventPluginRegistryService } from './registry/qqbot-event-plugin-registry.service'; +import { resolveInactivePluginKeys } from './registry/plugin-installation-state'; import { QqbotPluginRegistryService } from './registry/qqbot-plugin-registry.service'; import { QqbotPlugin, @@ -41,7 +48,13 @@ export type QqbotPluginRuntimeFactory = { version: QqbotPluginVersion, ): Pick< QqbotPluginWorkerRuntime, - 'activate' | 'deactivate' | 'dispose' | 'health' | 'load' + | 'activate' + | 'deactivate' + | 'dispose' + | 'executeOperation' + | 'handleEvent' + | 'health' + | 'load' >; }; @@ -63,6 +76,8 @@ type ListOperationsQuery = { pageNo?: number | string; pageSize?: number | string; pluginId?: string; + pluginKey?: string; + triggerMode?: QqbotPluginTriggerMode; }; type RuntimeEventQuery = { @@ -80,9 +95,33 @@ type UpdateConfigBody = { value?: unknown; }; +type ActiveWorkerContext = { + installationId: string; + manifest: QqbotPluginManifest; + pluginId: string; + pluginKey: string; + worker: QqbotPluginWorkerRuntime; +}; + +type PersistedPluginRuntimeState = { + enabledInstallationsByPluginKey: Map; + inactivePluginKeys: Set; +}; + @Injectable() -export class QqbotPluginPlatformService { +export class QqbotPluginPlatformService + implements OnModuleInit, QqbotPluginExecutionPort +{ private readonly activeWorkers = new Map(); + private readonly activeWorkerContexts = new Map< + string, + ActiveWorkerContext + >(); + private readonly activeWorkersByPluginKey = new Map< + string, + ActiveWorkerContext + >(); + private readonly activeWorkerPluginAliases = new Map(); constructor( @InjectRepository(QqbotPlugin) @@ -104,8 +143,7 @@ export class QqbotPluginPlatformService { @InjectRepository(QqbotPluginRuntimeEvent) private readonly runtimeEventRepository: Repository, @Optional() - @Inject(QQBOT_PLUGIN_EXECUTION_PORT) - private readonly pluginExecution?: QqbotPluginExecutionPort, + private readonly argumentParser?: QqbotPluginArgumentParserService, @Optional() @Inject(QQBOT_PLUGIN_RUNTIME_FACTORY) private readonly runtimeFactory?: QqbotPluginRuntimeFactory, @@ -115,8 +153,14 @@ export class QqbotPluginPlatformService { private readonly eventPluginRegistry?: QqbotEventPluginRegistryService, @Optional() private readonly packageReader?: QqbotPluginPackageReaderService, + @Optional() + private readonly builtinPluginLoader?: QqbotBuiltinPluginPackageLoaderService, ) {} + async onModuleInit() { + await this.startBuiltinWorkers(); + } + async listInstallations() { return this.installationRepository.find(); } @@ -134,28 +178,37 @@ export class QqbotPluginPlatformService { } async listOperations(pluginId?: string) { - return this.operationRepository.find({ - where: pluginId ? { pluginId } : undefined, - }); + return this.listOperationSummaries({ pluginId }); } async pageOperations(query: ListOperationsQuery) { + return this.pageOperationSummaries(query); + } + + async listOperationSummaries(query: ListOperationsQuery = {}) { + const pluginKey = await this.resolveOperationPluginKeyFilter(query); + const operations = await this.resolveActiveOperationSummaries(); + return operations.filter( + (operation) => + (!pluginKey || operation.pluginKey === pluginKey) && + (!query.triggerMode || operation.triggerMode === query.triggerMode), + ); + } + + async pageOperationSummaries(query: ListOperationsQuery) { const pageNo = Number(query.pageNo || 1); const pageSize = Number(query.pageSize || 10); const safePageNo = Number.isFinite(pageNo) && pageNo > 0 ? pageNo : 1; const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? pageSize : 10; - const [list, total] = await this.operationRepository.findAndCount({ - skip: (safePageNo - 1) * safePageSize, - take: safePageSize, - where: query.pluginId ? { pluginId: query.pluginId } : undefined, - }); + const operations = await this.listOperationSummaries(query); + const skip = (safePageNo - 1) * safePageSize; return { - list, + list: operations.slice(skip, skip + safePageSize), pageNo: safePageNo, pageSize: safePageSize, - total, + total: operations.length, }; } @@ -223,7 +276,7 @@ export class QqbotPluginPlatformService { async disableInstallation(body: InstallationActionBody) { const installation = await this.requireInstallation(body); - await this.stopWorker(installation.id); + await this.stopWorkersForInstallation(installation); await this.refreshActiveRegistries(installation, false); await this.updateInstallationRuntime(installation, 'disabled', 'stopped'); await this.recordRuntimeEvent(installation, 'disable-finished'); @@ -244,16 +297,15 @@ export class QqbotPluginPlatformService { } catch (error) { if (previousWorker) { this.activeWorkers.set(installation.id, previousWorker); - await this.updateInstallationRuntime(installation, 'enabled', 'healthy'); + await this.updateInstallationRuntime( + installation, + 'enabled', + 'healthy', + ); } - await this.recordRuntimeEvent( - installation, - 'upgrade-failed', - 'error', - { - message: error instanceof Error ? error.message : `${error}`, - }, - ); + await this.recordRuntimeEvent(installation, 'upgrade-failed', 'error', { + message: error instanceof Error ? error.message : `${error}`, + }); throw error; } await this.refreshActiveRegistries(installation, true); @@ -287,11 +339,24 @@ export class QqbotPluginPlatformService { } async executeOperation(input: QqbotPluginExecutionInput) { - if (!this.pluginExecution) { - throwVbenError('插件执行器未初始化'); + const normalizedInput = + (await this.argumentParser?.normalizeInput(input)) || input.input; + const workerContext = this.requireActiveWorker(input.pluginKey); + const operation = workerContext.manifest.operations.find( + (item) => item.key === input.operationKey, + ); + if (!operation) { + throwVbenError( + `QQBot 插件能力不存在:${input.pluginKey}.${input.operationKey}`, + ); } - const output = await this.pluginExecution.executeOperation(input); + const output = await workerContext.worker.executeOperation({ + input: normalizedInput, + operationId: operation.key, + operationKey: operation.key, + timeoutMs: operation.timeoutMs, + }); const pluginId = this.getPluginIdFromContext(input.context); if (pluginId) { await this.runtimeEventRepository.save({ @@ -313,11 +378,47 @@ export class QqbotPluginPlatformService { } async dispatchEvent(input: QqbotPluginEventDispatchInput) { - if (!this.pluginExecution) return false; - const handled = await this.pluginExecution.dispatchEvent(input); + let handled = false; + for (const workerContext of this.activeWorkerContexts.values()) { + for (const event of workerContext.manifest.events) { + if ( + event.eventName !== input.eventKey && + event.key !== input.eventKey + ) { + continue; + } + const result = await workerContext.worker.handleEvent({ + event: input.message, + eventKey: event.eventName || input.eventKey, + timeoutMs: workerContext.manifest.runtime.timeoutMs, + }); + handled = Boolean(result) || handled; + } + } return handled; } + async listActiveOperations() { + const workerOperations = this.listActiveWorkerOperations(); + if (workerOperations.length > 0) return workerOperations; + return [ + ...(this.pluginRegistry?.listOperations() || []), + ...(this.eventPluginRegistry?.listOperations() || []), + ]; + } + + async getOperationByCommand(command: QqbotPluginOperationLookup) { + if (!command.pluginKey || !command.operationKey) return null; + return ( + (await this.listActiveOperations()).find( + (operation) => + operation.pluginKey === + this.resolveActivePluginKey(command.pluginKey) && + operation.key === command.operationKey, + ) || null + ); + } + async updateConfig(body: UpdateConfigBody) { if (!body.pluginId || !body.configKey) { throwVbenError('请选择插件和配置项'); @@ -341,7 +442,9 @@ export class QqbotPluginPlatformService { ? { installationId: normalizedQuery.installationId } : {}), ...(normalizedQuery.level ? { level: normalizedQuery.level } : {}), - ...(normalizedQuery.pluginId ? { pluginId: normalizedQuery.pluginId } : {}), + ...(normalizedQuery.pluginId + ? { pluginId: normalizedQuery.pluginId } + : {}), ...this.buildRuntimeEventTimeFilter(normalizedQuery), }; @@ -389,6 +492,34 @@ export class QqbotPluginPlatformService { ]); } + private async resolveActiveOperationSummaries() { + const operations = await this.listActiveOperations(); + return operations.map((operation) => + this.toPlatformOperationSummary(operation), + ); + } + + private toPlatformOperationSummary(operation: QqbotPluginOperationSummary) { + return { + ...operation, + enabled: true, + operationKey: operation.key, + operationName: operation.name, + pluginId: operation.pluginKey, + }; + } + + private async resolveOperationPluginKeyFilter(query: ListOperationsQuery) { + if (query.pluginKey) return this.resolveActivePluginKey(query.pluginKey); + if (!query.pluginId) return undefined; + + const findOne = this.pluginRepository.findOne?.bind(this.pluginRepository); + const plugin = findOne + ? await findOne({ where: { id: query.pluginId } }) + : null; + return plugin?.pluginKey || query.pluginId; + } + private async requireInstallation(body: InstallationActionBody) { if (!body.id) throwVbenError('请选择插件安装记录'); @@ -446,10 +577,167 @@ export class QqbotPluginPlatformService { this.eventPluginRegistry?.setPluginActive(pluginKey, enabled); } - private async getPluginKey(pluginId: string) { - const findOne = this.pluginRepository.findOne?.bind( - this.pluginRepository, + private async startBuiltinWorkers() { + if (!this.runtimeFactory || !this.builtinPluginLoader) return; + + const persistedState = await this.resolvePersistedPluginRuntimeState(); + for (const manifest of this.builtinPluginLoader.loadBuiltinManifests()) { + if (persistedState.inactivePluginKeys.has(manifest.pluginKey)) continue; + if (this.activeWorkersByPluginKey.has(manifest.pluginKey)) continue; + + const persistedInstallation = + persistedState.enabledInstallationsByPluginKey.get(manifest.pluginKey); + const installation = + persistedInstallation || + ({ + id: `builtin-${manifest.pluginKey}`, + installedPath: `builtin://${manifest.pluginKey}`, + pluginId: manifest.pluginKey, + runtimeStatus: 'stopped', + status: 'installed', + versionId: `builtin-${manifest.pluginKey}-${manifest.version}`, + } as QqbotPluginInstallation); + + await this.startWorker(installation, { + id: `builtin-${manifest.pluginKey}-${manifest.version}`, + manifestJson: manifest as unknown as Record, + packageHash: `builtin-${manifest.pluginKey}`, + pluginId: manifest.pluginKey, + version: manifest.version, + } as QqbotPluginVersion); + } + } + + private async resolvePersistedPluginRuntimeState(): Promise { + const [plugins, installations] = await Promise.all([ + this.pluginRepository.find(), + this.installationRepository.find(), + ]); + const pluginsById = new Map( + plugins.map((plugin) => [plugin.id, plugin] as const), ); + const enabledInstallationsByPluginKey = new Map< + string, + QqbotPluginInstallation + >(); + + for (const installation of installations) { + if (installation.status !== 'enabled') continue; + const plugin = pluginsById.get(installation.pluginId); + if (!plugin?.pluginKey) continue; + enabledInstallationsByPluginKey.set(plugin.pluginKey, installation); + } + + return { + enabledInstallationsByPluginKey, + inactivePluginKeys: new Set( + resolveInactivePluginKeys(plugins, installations), + ), + }; + } + + private requireActiveWorker(pluginKey: string) { + const resolvedPluginKey = this.resolveActivePluginKey(pluginKey); + const workerContext = this.activeWorkersByPluginKey.get(resolvedPluginKey); + if (!workerContext) { + throwVbenError(`QQBot 插件运行时未启用:${pluginKey}`); + } + return workerContext; + } + + private resolveActivePluginKey(pluginKey: string) { + return this.activeWorkerPluginAliases.get(pluginKey) || pluginKey; + } + + private listActiveWorkerOperations(): QqbotPluginOperationSummary[] { + return [...this.activeWorkerContexts.values()].flatMap((workerContext) => [ + ...workerContext.manifest.operations.map((operation) => ({ + aliases: operation.aliases, + description: operation.description, + inputSchema: operation.inputSchema, + key: operation.key, + name: operation.name, + outputSchema: operation.outputSchema, + pluginKey: workerContext.pluginKey, + timeoutMs: operation.timeoutMs, + triggerMode: 'command' as const, + })), + ...workerContext.manifest.events.map((event) => ({ + description: event.description, + inputSchema: { + triggerType: event.eventName, + }, + key: event.eventName || event.key, + name: event.name, + pluginKey: workerContext.pluginKey, + triggerMode: 'event' as const, + })), + ]); + } + + private async registerActiveWorker( + installation: QqbotPluginInstallation, + version: QqbotPluginVersion, + worker: QqbotPluginWorkerRuntime, + ) { + const manifest = parseQqbotPluginManifest(version.manifestJson); + await this.stopExistingWorkersForManifest(manifest); + const workerContext: ActiveWorkerContext = { + installationId: installation.id, + manifest, + pluginId: installation.pluginId, + pluginKey: manifest.pluginKey, + worker, + }; + this.activeWorkers.set(installation.id, worker); + this.activeWorkerContexts.set(installation.id, workerContext); + this.activeWorkersByPluginKey.set(manifest.pluginKey, workerContext); + for (const alias of manifest.legacyAliases) { + this.activeWorkerPluginAliases.set(alias, manifest.pluginKey); + this.activeWorkersByPluginKey.set(alias, workerContext); + } + } + + private async stopExistingWorkersForManifest(manifest: QqbotPluginManifest) { + const installationIds = new Set(); + for (const pluginKey of [manifest.pluginKey, ...manifest.legacyAliases]) { + const workerContext = this.activeWorkersByPluginKey.get(pluginKey); + if (!workerContext) { + continue; + } + installationIds.add(workerContext.installationId); + } + + for (const installationId of installationIds) { + await this.stopWorker(installationId); + } + } + + private unregisterActiveWorker(installationId: string) { + const workerContext = this.activeWorkerContexts.get(installationId); + this.activeWorkers.delete(installationId); + this.activeWorkerContexts.delete(installationId); + if (!workerContext) return; + + for (const pluginKey of [ + workerContext.pluginKey, + ...workerContext.manifest.legacyAliases, + ]) { + if (this.activeWorkersByPluginKey.get(pluginKey) === workerContext) { + this.activeWorkersByPluginKey.delete(pluginKey); + } + } + for (const alias of workerContext.manifest.legacyAliases) { + if ( + this.activeWorkerPluginAliases.get(alias) === workerContext.pluginKey + ) { + this.activeWorkerPluginAliases.delete(alias); + } + } + } + + private async getPluginKey(pluginId: string) { + const findOne = this.pluginRepository.findOne?.bind(this.pluginRepository); const plugin = findOne ? await findOne({ where: { id: pluginId } }) : null; return plugin?.pluginKey || pluginId; } @@ -461,16 +749,36 @@ export class QqbotPluginPlatformService { await worker.deactivate(); } finally { await worker.dispose(); - this.activeWorkers.delete(installationId); + this.unregisterActiveWorker(installationId); } } - private async startWorker(installation: QqbotPluginInstallation) { + private async stopWorkersForInstallation( + installation: QqbotPluginInstallation, + ) { + const pluginKey = await this.getPluginKey(installation.pluginId); + const installationIds = new Set([installation.id]); + const pluginWorkerContext = this.activeWorkersByPluginKey.get(pluginKey); + if (pluginWorkerContext) { + installationIds.add(pluginWorkerContext.installationId); + } + + for (const installationId of installationIds) { + await this.stopWorker(installationId); + } + } + + private async startWorker( + installation: QqbotPluginInstallation, + versionOverride?: QqbotPluginVersion, + ) { if (!this.runtimeFactory) return; - const version = await this.versionRepository.findOne({ - where: { id: installation.versionId }, - }); + const version = + versionOverride || + (await this.versionRepository.findOne({ + where: { id: installation.versionId }, + })); if (!version) { throwVbenError('插件版本不存在,无法启动运行时'); } @@ -480,8 +788,9 @@ export class QqbotPluginPlatformService { await worker.load(version.manifestJson); await worker.activate(); await worker.health(); - this.activeWorkers.set( - installation.id, + await this.registerActiveWorker( + installation, + version, worker as QqbotPluginWorkerRuntime, ); } catch (error) { diff --git a/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts b/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts index 19adbf0..b595382 100644 --- a/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts +++ b/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts @@ -34,11 +34,13 @@ export class QqbotPluginRegistryService implements OnModuleInit { ) {} async onModuleInit() { - for (const plugin of this.builtinPluginLoader?.loadCommandPlugins() || []) { - await plugin.activate?.(); - this.register(plugin); - } await this.hydrateInactivePluginKeys(); + for (const plugin of this.builtinPluginLoader?.loadCommandPlugins() || []) { + this.register(plugin); + if (this.isPluginActive(plugin.key)) { + await plugin.activate?.(); + } + } } register(plugin: QqbotIntegrationPlugin) { @@ -199,7 +201,9 @@ export class QqbotPluginRegistryService implements OnModuleInit { } private isPluginActive(pluginKey: string) { - return !this.inactivePluginKeys.has(this.resolveCanonicalPluginKey(pluginKey)); + return !this.inactivePluginKeys.has( + this.resolveCanonicalPluginKey(pluginKey), + ); } private resolveCanonicalPluginKey(pluginKey: string) { @@ -213,10 +217,7 @@ export class QqbotPluginRegistryService implements OnModuleInit { this.pluginRepository.find(), this.installationRepository.find(), ]); - for (const pluginKey of resolveInactivePluginKeys( - plugins, - installations, - )) { + for (const pluginKey of resolveInactivePluginKeys(plugins, installations)) { this.setPluginActive(pluginKey, false); } } diff --git a/src/modules/qqbot/plugin-platform/contract/qqbot-plugin.controller.ts b/src/modules/qqbot/plugin-platform/contract/qqbot-plugin.controller.ts index d468a33..9b6c989 100644 --- a/src/modules/qqbot/plugin-platform/contract/qqbot-plugin.controller.ts +++ b/src/modules/qqbot/plugin-platform/contract/qqbot-plugin.controller.ts @@ -9,11 +9,11 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard'; -import { ToolsService, vbenSuccess } from '@/common'; +import { vbenSuccess } from '@/common'; +import { QqbotPluginPlatformService } from '../application/plugin-platform.service'; import { QqbotEventPluginRegistryService } from '../application/registry/qqbot-event-plugin-registry.service'; import { QqbotPluginRegistryService } from '../application/registry/qqbot-plugin-registry.service'; import type { - QqbotPluginOperationSummary, QqbotPluginTriggerMode, } from '@/modules/qqbot/core/contract/qqbot.types'; @@ -31,7 +31,7 @@ export class QqbotPluginController { constructor( private readonly eventPluginRegistry: QqbotEventPluginRegistryService, private readonly pluginRegistry: QqbotPluginRegistryService, - private readonly toolsService: ToolsService, + private readonly service: QqbotPluginPlatformService, ) {} @Get('list') @@ -71,7 +71,9 @@ export class QqbotPluginController { @Query('pluginKey') pluginKey?: string, @Query('triggerMode') triggerMode?: QqbotPluginTriggerMode, ) { - return vbenSuccess(this.listOperations(pluginKey, triggerMode)); + return vbenSuccess( + await this.service.listOperationSummaries({ pluginKey, triggerMode }), + ); } @Get('operation/page') @@ -85,15 +87,7 @@ export class QqbotPluginController { required: false, }) async operationPage(@Query() query: QqbotPluginOperationPageQuery) { - const { pageNo, pageSize, skip } = this.toolsService.getPageParams(query); - const operations = this.listOperations(query.pluginKey, query.triggerMode); - - return vbenSuccess({ - list: operations.slice(skip, skip + pageSize), - pageNo, - pageSize, - total: operations.length, - }); + return vbenSuccess(await this.service.pageOperationSummaries(query)); } @Get('health') @@ -159,17 +153,4 @@ export class QqbotPluginController { return !triggerMode || triggerMode === target; } - private listOperations( - pluginKey?: string, - triggerMode?: QqbotPluginTriggerMode, - ): QqbotPluginOperationSummary[] { - return [ - ...(this.includesTriggerMode('command', triggerMode) - ? this.pluginRegistry.listOperations(pluginKey) - : []), - ...(this.includesTriggerMode('event', triggerMode) - ? this.eventPluginRegistry.listOperations(pluginKey) - : []), - ]; - } } diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service.ts index b535cba..7241020 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service.ts @@ -34,9 +34,14 @@ import { createPlugin as createFf14MarketPlugin } from '@/modules/qqbot/plugins/ import { createPlugin as createFflogsPlugin } from '@/modules/qqbot/plugins/fflogs/src'; import { createPlugin as createRepeaterPlugin } from '@/modules/qqbot/plugins/repeater/src'; -export type RepeaterPluginPackage = ReturnType< - typeof createRepeaterPlugin ->; +const BUILTIN_PLUGIN_KEYS = [ + 'bangdream', + 'ff14-market', + 'fflogs', + 'repeater', +] as const; + +export type RepeaterPluginPackage = ReturnType; export type QqbotEventPluginPackage = { bind(selfId: string): Promise | boolean; @@ -77,6 +82,63 @@ export class QqbotBuiltinPluginPackageLoaderService { return [this.loadRepeaterPlugin()]; } + loadBuiltinManifests(): QqbotPluginManifest[] { + return BUILTIN_PLUGIN_KEYS.map((pluginKey) => this.loadManifest(pluginKey)); + } + + async handleWorkerHostCall(method: string, args: Record = {}) { + switch (method) { + case 'bindEventPlugin': + await this.accountService.bindEventPlugin(args.selfId, args.pluginKey); + return undefined; + case 'getBoundEventPluginKeys': + return this.accountService.getBoundEventPluginKeys(args.selfId); + case 'getConfig': + return this.configService.get(args.key); + case 'getDictByKey': + return this.dictService.getDictByKey(args.dictCode); + case 'getDictItemsByKey': + return this.dictService.getDictItemsByKey(args.dictCode); + case 'relationTree': + return this.dictService.relationTree(args.input); + case 'bangdreamRequestBuffer': + return this.httpClient.requestBuffer( + createWorkerHttpRequest( + args.options, + (statusCode) => `BangDream 资源下载失败:${statusCode}`, + ), + ); + case 'bangdreamRequestJson': + return this.httpClient.requestJson( + createWorkerHttpRequest( + args.options, + (statusCode) => `BangDream 数据接口失败:${statusCode}`, + ), + ); + case 'requestBuffer': + return this.httpClient.requestBuffer( + createWorkerHttpRequest(args.options), + ); + case 'requestJson': + return this.httpClient.requestJson( + createWorkerHttpRequest(args.options), + ); + case 'sendText': + return this.sendService.sendText(args.input); + case 'unbindEventPlugin': + await this.accountService.unbindEventPlugin( + args.selfId, + args.pluginKey, + ); + return undefined; + case 'warn': + this.logger.warn(args.message); + return undefined; + default: + throw new Error(`未知插件 Host 调用:${method}`); + } + } + loadRepeaterPlugin(): RepeaterPluginPackage { const manifest = this.loadManifest('repeater'); return createRepeaterPlugin({ @@ -253,3 +315,23 @@ function readExcelRows>(filePath: string) { const worksheet = workbook.Sheets[sheetName]; return XLSX.utils.sheet_to_json(worksheet); } + +function createWorkerHttpRequest( + input: Record = {}, + fallbackFailureMessage?: (statusCode: number) => string, +) { + const failureMessageTemplate = + typeof input.failureMessageTemplate === 'string' + ? input.failureMessageTemplate + : undefined; + const { url, ...rest } = input; + delete rest.failureMessageTemplate; + return { + ...rest, + failureMessage: failureMessageTemplate + ? (statusCode: number) => + failureMessageTemplate.replaceAll('{statusCode}', `${statusCode}`) + : fallbackFailureMessage, + url, + }; +} diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker-runtime.factory.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker-runtime.factory.ts index 63fe024..a316490 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker-runtime.factory.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker-runtime.factory.ts @@ -1,14 +1,10 @@ import { Injectable } from '@nestjs/common'; +import { Worker } from 'node:worker_threads'; +import { join } from 'node:path'; import { throwVbenError } from '@/common'; import { QqbotBuiltinPluginPackageLoaderService, - type QqbotEventPluginPackage, } from '../package/builtin-plugin-package-loader.service'; -import type { - QqbotIntegrationPlugin, - QqbotPluginOperation, - QqbotNormalizedMessage, -} from '@/modules/qqbot/core/contract/qqbot.types'; import type { QqbotPluginRuntimeFactory, } from '@/modules/qqbot/plugin-platform/application/plugin-platform.service'; @@ -24,11 +20,6 @@ import type { QqbotPluginWorkerRequest, } from './worker-runtime.types'; -type RuntimeCommandPlugin = QqbotIntegrationPlugin & { - activate?: () => Promise | unknown; - dispose?: () => Promise | unknown; -}; - @Injectable() export class QqbotBuiltinPluginWorkerRuntimeFactoryService implements QqbotPluginRuntimeFactory @@ -43,7 +34,7 @@ export class QqbotBuiltinPluginWorkerRuntimeFactoryService ) { const pluginKey = getManifestPluginKey(version.manifestJson); return new QqbotPluginWorkerRuntime( - new QqbotBuiltinPluginWorkerDriver(this.pluginLoader, pluginKey), + new QqbotBuiltinPluginWorkerThreadDriver(this.pluginLoader, pluginKey), { defaultTimeoutMs: getDefaultRuntimeTimeout(version.manifestJson), installationId: installation.id, @@ -53,9 +44,31 @@ export class QqbotBuiltinPluginWorkerRuntimeFactoryService } } -class QqbotBuiltinPluginWorkerDriver implements QqbotPluginWorkerDriver { - private commandPlugin?: RuntimeCommandPlugin; - private eventPlugin?: QqbotEventPluginPackage; +type WorkerBridgeMessage = + | { + ok: boolean; + requestId: string; + result?: unknown; + error?: { message?: string; name?: string; stack?: string }; + type: 'response'; + } + | { + args?: Record; + method: string; + requestId: string; + type: 'hostCall'; + }; + +type PendingRequest = { + reject: (reason?: unknown) => void; + resolve: (value: unknown) => void; +}; + +export class QqbotBuiltinPluginWorkerThreadDriver + implements QqbotPluginWorkerDriver +{ + private readonly pendingRequests = new Map(); + private worker?: Worker; constructor( private readonly pluginLoader: QqbotBuiltinPluginPackageLoaderService, @@ -63,90 +76,121 @@ class QqbotBuiltinPluginWorkerDriver implements QqbotPluginWorkerDriver { ) {} async request(message: QqbotPluginWorkerRequest): Promise { - switch (message.type) { - case 'load': - return this.load(); - case 'activate': - await this.commandPlugin?.activate?.(); - return { ok: true }; - case 'health': - return this.health(); - case 'executeOperation': - return this.executeOperation(message); - case 'handleEvent': - return this.handleEvent(message); - case 'deactivate': - return { ok: true }; - case 'dispose': - await this.commandPlugin?.dispose?.(); - return { ok: true }; - default: - throwVbenError(`未知插件运行时请求:${message.type}`); - } + const worker = this.ensureWorker(); + return new Promise((resolve, reject) => { + this.pendingRequests.set(message.correlationId, { reject, resolve }); + worker.postMessage({ + message, + requestId: message.correlationId, + type: 'request', + }); + }); } async dispose(): Promise { - await this.commandPlugin?.dispose?.(); + const worker = this.worker; + this.worker = undefined; + this.rejectPending(new Error('QQBot 插件 worker 已关闭')); + if (worker) { + await worker.terminate(); + } } - private load() { - this.commandPlugin = this.pluginLoader - .loadCommandPlugins() - .find((plugin) => plugin.key === this.pluginKey) as - | RuntimeCommandPlugin - | undefined; - this.eventPlugin = this.pluginLoader - .loadEventPlugins() - .find((plugin) => plugin.getDefinition().key === this.pluginKey); + private ensureWorker() { + if (this.worker) return this.worker; - if (!this.commandPlugin && !this.eventPlugin) { - throwVbenError(`QQBot 插件运行时不存在:${this.pluginKey}`); - } - - return { - ok: true, - pluginKey: this.pluginKey, - triggerMode: this.commandPlugin ? 'command' : 'event', - }; + const worker = new Worker(resolveWorkerEntrypoint(), { + execArgv: resolveWorkerExecArgv(), + workerData: { + pluginKey: this.pluginKey, + }, + }); + worker.on('message', (message: WorkerBridgeMessage) => { + void this.handleWorkerMessage(message); + }); + worker.on('error', (error) => { + this.worker = undefined; + this.rejectPending(error); + }); + worker.on('exit', (code) => { + this.worker = undefined; + if (code !== 0) { + this.rejectPending( + new Error(`QQBot 插件 worker 异常退出:${code}`), + ); + } + }); + this.worker = worker; + return worker; } - private async health() { - if (this.commandPlugin?.healthCheck) { - return this.commandPlugin.healthCheck(); + private async handleWorkerMessage(message: WorkerBridgeMessage) { + if (message.type === 'response') { + const pending = this.pendingRequests.get(message.requestId); + if (!pending) return; + this.pendingRequests.delete(message.requestId); + if (message.ok) { + pending.resolve(message.result); + return; + } + pending.reject(deserializeWorkerError(message.error)); + return; } - if (this.eventPlugin) { - return { - message: this.eventPlugin.getDefinition().remark || '事件插件可用', - status: 'healthy', - }; + + try { + const result = await this.pluginLoader.handleWorkerHostCall( + message.method, + message.args, + ); + this.worker?.postMessage({ + ok: true, + requestId: message.requestId, + result, + type: 'hostResponse', + }); + } catch (error) { + this.worker?.postMessage({ + error: serializeWorkerError(error), + ok: false, + requestId: message.requestId, + type: 'hostResponse', + }); } - throwVbenError(`QQBot 插件运行时未加载:${this.pluginKey}`); } - private async executeOperation(message: QqbotPluginWorkerRequest) { - const operation = this.commandPlugin?.operations.find( - (item: QqbotPluginOperation) => item.key === message.operationKey, - ); - if (!operation) { - throwVbenError(`QQBot 插件能力不存在:${message.operationKey}`); + private rejectPending(error: Error) { + for (const pending of this.pendingRequests.values()) { + pending.reject(error); } - return operation.execute( - (message.input || {}) as Record, - {}, - ); + this.pendingRequests.clear(); } +} - private async handleEvent(message: QqbotPluginWorkerRequest) { - if (!this.eventPlugin) { - return false; - } - const definition = this.eventPlugin.getDefinition(); - if (message.eventKey && message.eventKey !== definition.key) return false; - if (definition.triggerType !== 'message') return false; - return this.eventPlugin.handleMessage( - (message.event || {}) as QqbotNormalizedMessage, - ); - } +function resolveWorkerEntrypoint() { + const extension = __filename.endsWith('.ts') ? '.ts' : '.js'; + return join(__dirname, `builtin-plugin-worker.thread${extension}`); +} + +function resolveWorkerExecArgv() { + if (!__filename.endsWith('.ts')) return []; + return ['-r', 'ts-node/register', '-r', 'tsconfig-paths/register']; +} + +function serializeWorkerError(error: unknown) { + return { + message: error instanceof Error ? error.message : `${error}`, + name: error instanceof Error ? error.name : 'Error', + stack: error instanceof Error ? error.stack : undefined, + }; +} + +function deserializeWorkerError( + error?: { message?: string; name?: string; stack?: string }, +) { + const output = new Error(error?.message || 'QQBot 插件 worker 请求失败'); + if (error?.name) output.name = error.name; + if (error?.stack) output.stack = error.stack; + return output; } function getManifestPluginKey(manifest: unknown) { diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker.thread.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker.thread.ts new file mode 100644 index 0000000..1a2bf32 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker.thread.ts @@ -0,0 +1,531 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parentPort, workerData } from 'node:worker_threads'; +import * as XLSX from 'xlsx'; +import type { + QqbotIntegrationPlugin, + QqbotNormalizedMessage, + QqbotPluginOperation, +} from '@/modules/qqbot/core/contract/qqbot.types'; +import { + parseQqbotPluginManifest, + type QqbotPluginManifest, +} from '@/modules/qqbot/plugin-platform/domain/manifest'; +import { createPlugin as createBangDreamPlugin } from '@/modules/qqbot/plugins/bangdream/src'; +import type { + BangDreamOperationHandlerName, + BangDreamOperationKey, +} from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream.types'; +import { BANGDREAM_TSUGU_ENV_KEYS } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-options'; +import type { BangDreamRuntimeIo } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io'; +import { createPlugin as createFf14MarketPlugin } from '@/modules/qqbot/plugins/ff14-market/src'; +import { + buildFf14MarketCatalog, + buildFf14MarketCatalogFromTree, + isFf14LocationName, + QQBOT_FF14_MARKET_DICT_CODES, + splitFf14WorldPath, +} from '@/modules/qqbot/plugins/ff14-market/src/domain/ff14-worlds'; +import type { Ff14MarketManifest } from '@/modules/qqbot/plugins/ff14-market/src/operations'; +import { createPlugin as createFflogsPlugin } from '@/modules/qqbot/plugins/fflogs/src'; +import type { FflogsManifest } from '@/modules/qqbot/plugins/fflogs/src/operations'; +import { createPlugin as createRepeaterPlugin } from '@/modules/qqbot/plugins/repeater/src'; +import type { RepeaterManifest } from '@/modules/qqbot/plugins/repeater/src/domain/repeater.types'; +import type { QqbotPluginWorkerRequest } from './worker-runtime.types'; + +type RuntimeCommandPlugin = QqbotIntegrationPlugin & { + activate?: () => Promise | unknown; + dispose?: () => Promise | unknown; +}; + +type RuntimeEventPlugin = { + bind(selfId: string): Promise | boolean; + getDefinition(): { + key: string; + remark?: string; + triggerType?: string; + }; + handleMessage(message: QqbotNormalizedMessage): Promise | boolean; + unbind(selfId: string): Promise | boolean; +}; + +type ParentMessage = + | { + message: QqbotPluginWorkerRequest; + requestId: string; + type: 'request'; + } + | { + ok: boolean; + requestId: string; + result?: unknown; + error?: { message?: string; name?: string; stack?: string }; + type: 'hostResponse'; + }; + +type PendingHostCall = { + reject: (reason?: unknown) => void; + resolve: (value: unknown) => void; +}; + +type WorkerHttpRequestInput = { + body?: Buffer | string; + context?: string; + failureMessage?: (statusCode: number) => string; + headers?: Record; + invalidJsonMessage?: string; + method?: string; + timeoutMessage?: string; + timeoutMs?: number; + url: string | URL; +}; + +const HTTP_STATUS_PLACEHOLDER = 599; + +const port = parentPort; +if (!port) { + throw new Error('QQBot plugin worker must run inside worker_threads'); +} + +const pluginKey = `${workerData?.pluginKey || ''}`.trim(); +const configCache = new Map(); +const pendingHostCalls = new Map(); +let commandPlugin: RuntimeCommandPlugin | undefined; +let eventPlugin: RuntimeEventPlugin | undefined; + +port.on('message', (message: ParentMessage) => { + void handleParentMessage(message); +}); + +async function handleParentMessage(message: ParentMessage) { + if (message.type === 'hostResponse') { + const pending = pendingHostCalls.get(message.requestId); + if (!pending) return; + pendingHostCalls.delete(message.requestId); + if (message.ok) { + pending.resolve(message.result); + return; + } + pending.reject(deserializeError(message.error)); + return; + } + + try { + const result = await handleWorkerRequest(message.message); + port?.postMessage({ + ok: true, + requestId: message.requestId, + result, + type: 'response', + }); + } catch (error) { + port?.postMessage({ + error: serializeError(error), + ok: false, + requestId: message.requestId, + type: 'response', + }); + } +} + +async function handleWorkerRequest(message: QqbotPluginWorkerRequest) { + switch (message.type) { + case 'load': + return loadPlugin(); + case 'activate': + await commandPlugin?.activate?.(); + return { ok: true }; + case 'health': + return health(); + case 'executeOperation': + return executeOperation(message); + case 'handleEvent': + return handleEvent(message); + case 'deactivate': + return { ok: true }; + case 'dispose': + await commandPlugin?.dispose?.(); + return { ok: true }; + default: + return assertNever(message.type); + } +} + +async function loadPlugin() { + await preloadHostConfig(getConfigKeysForPlugin(pluginKey)); + commandPlugin = createCommandPlugin(pluginKey); + eventPlugin = commandPlugin ? undefined : createEventPlugin(pluginKey); + + if (!commandPlugin && !eventPlugin) { + throw new Error(`QQBot 插件运行时不存在:${pluginKey}`); + } + + return { + ok: true, + pluginKey, + triggerMode: commandPlugin ? 'command' : 'event', + }; +} + +async function health() { + if (commandPlugin?.healthCheck) { + return commandPlugin.healthCheck(); + } + if (eventPlugin) { + return { + message: eventPlugin.getDefinition().remark || '事件插件可用', + status: 'healthy', + }; + } + throw new Error(`QQBot 插件运行时未加载:${pluginKey}`); +} + +async function executeOperation(message: QqbotPluginWorkerRequest) { + const operation = commandPlugin?.operations.find( + (item: QqbotPluginOperation) => item.key === message.operationKey, + ); + if (!operation) { + throw new Error(`QQBot 插件能力不存在:${message.operationKey}`); + } + return operation.execute( + (message.input || {}) as Record, + {}, + ); +} + +async function handleEvent(message: QqbotPluginWorkerRequest) { + if (!eventPlugin) return false; + const definition = eventPlugin.getDefinition(); + if ( + message.eventKey && + message.eventKey !== definition.key && + message.eventKey !== definition.triggerType + ) { + return false; + } + if (definition.triggerType !== 'message') return false; + return eventPlugin.handleMessage( + (message.event || {}) as QqbotNormalizedMessage, + ); +} + +function createCommandPlugin(targetPluginKey: string) { + switch (targetPluginKey) { + case 'bangdream': + return createBangDreamCommandPlugin(); + case 'ff14-market': + return createFf14MarketCommandPlugin(); + case 'fflogs': + return createFflogsCommandPlugin(); + default: + return undefined; + } +} + +function createEventPlugin(targetPluginKey: string) { + if (targetPluginKey !== 'repeater') return undefined; + const manifest = loadManifest('repeater'); + return createRepeaterPlugin({ + host: { + bindEventPlugin: (selfId, targetPluginKey) => + callHost('bindEventPlugin', { pluginKey: targetPluginKey, selfId }), + getBoundEventPluginKeys: (selfId) => + callHost('getBoundEventPluginKeys', { selfId }), + getConfig, + sendText: (input) => callHost('sendText', { input }), + unbindEventPlugin: (selfId, targetPluginKey) => + callHost('unbindEventPlugin', { pluginKey: targetPluginKey, selfId }), + warn: (message) => { + void callHost('warn', { message }); + }, + }, + manifest, + }) as RuntimeEventPlugin; +} + +function createBangDreamCommandPlugin() { + const manifest = loadManifest('bangdream'); + return createBangDreamPlugin({ + configReader: { + get: getConfig, + }, + description: manifest.description, + dictionaryReader: { + getDictItemsByKey: (dictCode) => + callHost('getDictItemsByKey', { dictCode }), + }, + io: createBangDreamRuntimeIo(), + legacyAliases: manifest.legacyAliases, + name: manifest.name, + normalizeError: normalizeErrorMessage, + operations: manifest.operations.map((operation) => ({ + aliases: operation.aliases, + description: operation.description, + handlerName: operation.handlerName as BangDreamOperationHandlerName, + inputSchema: operation.inputSchema, + key: operation.key as BangDreamOperationKey, + name: operation.name, + outputSchema: operation.outputSchema, + timeoutMs: operation.timeoutMs, + })), + pluginKey: manifest.pluginKey, + version: manifest.version, + }) as RuntimeCommandPlugin; +} + +function createFf14MarketCommandPlugin() { + const manifest = loadManifest('ff14-market'); + return createFf14MarketPlugin({ + host: { + getConfig, + getDictItemsByKey: (dictCode) => + callHost('getDictItemsByKey', { dictCode }), + relationTree: (input) => callHost('relationTree', { input }), + requestJson: (options) => + callHost('requestJson', { options: serializeHttpRequest(options) }), + }, + manifest, + normalizeError: normalizeErrorMessage, + }) as RuntimeCommandPlugin; +} + +function createFflogsCommandPlugin() { + const manifest = loadManifest('fflogs'); + return createFflogsPlugin({ + host: { + getConfig, + getDictByKey: (dictCode) => callHost('getDictByKey', { dictCode }), + requestJson: (options) => + callHost('requestJson', { options: serializeHttpRequest(options) }), + resolveKnownWorld, + }, + manifest, + normalizeError: normalizeErrorMessage, + }) as RuntimeCommandPlugin; +} + +function createBangDreamRuntimeIo(): BangDreamRuntimeIo { + return { + getConfig, + readAssetFile: async (filePath) => readFileSync(filePath), + readExcelRows: async (filePath) => readExcelRows(filePath), + readJsonFile: async (filePath) => readJsonFile(filePath), + readJsonFileSync: (filePath) => readJsonFile(filePath), + requestArrayBuffer: async (url, options) => ({ + body: Buffer.from( + await callHost('requestBuffer', { + options: serializeHttpRequest({ + context: 'BangDream 资源下载', + failureMessage: (statusCode: number) => + `BangDream 资源下载失败:${statusCode}`, + headers: options?.headers, + timeoutMessage: 'BangDream 资源下载超时', + timeoutMs: options?.timeoutMs, + url, + }), + }), + ), + }), + requestJson: async (url, options) => ({ + body: await callHost('bangdreamRequestJson', { + options: serializeHttpRequest({ + context: 'BangDream 数据接口', + failureMessage: (statusCode: number) => + `BangDream 数据接口失败:${statusCode}`, + headers: options?.headers, + invalidJsonMessage: 'BangDream 数据接口返回不是合法 JSON', + timeoutMessage: 'BangDream 数据接口请求超时', + timeoutMs: options?.timeoutMs, + url, + }), + }), + }), + sleep: async (ms) => + await new Promise((resolve) => setTimeout(resolve, ms)), + writeJsonFile: async (filePath, data) => + writeFileSync(filePath, JSON.stringify(data)), + }; +} + +async function resolveKnownWorld(candidate: string) { + const catalog = await loadFf14MarketCatalog(); + if (!isFf14LocationName(catalog, candidate)) return null; + const worldPath = splitFf14WorldPath(candidate); + return { serverSlug: worldPath.world || candidate }; +} + +async function loadFf14MarketCatalog() { + const treeCatalog = buildFf14MarketCatalogFromTree( + await callHost('relationTree', { + input: { + dictCode: QQBOT_FF14_MARKET_DICT_CODES.region, + }, + }), + ); + if (treeCatalog.dataCenters.length > 0) return treeCatalog; + + const [regions, dataCenters, worlds] = await Promise.all([ + callHost('getDictItemsByKey', { + dictCode: QQBOT_FF14_MARKET_DICT_CODES.region, + }), + callHost('getDictItemsByKey', { + dictCode: QQBOT_FF14_MARKET_DICT_CODES.dataCenter, + }), + callHost('getDictItemsByKey', { + dictCode: QQBOT_FF14_MARKET_DICT_CODES.world, + }), + ]); + return buildFf14MarketCatalog({ + dataCenters, + regions, + worlds, + }); +} + +function loadManifest( + targetPluginKey: string, +) { + const pluginRoot = resolvePluginRoot(targetPluginKey); + return parseQqbotPluginManifest( + readJsonFile(join(pluginRoot, 'plugin.json')), + { pluginRoot }, + ) as TManifest; +} + +async function preloadHostConfig(keys: readonly string[]) { + const uniqueKeys = [...new Set(keys.filter(Boolean))]; + const entries = await Promise.all( + uniqueKeys.map( + async (key) => [key, await callHost('getConfig', { key })] as const, + ), + ); + configCache.clear(); + for (const [key, value] of entries) { + configCache.set(key, value); + } +} + +function getConfig(key: string): T | undefined { + const value = configCache.get(key); + return value === undefined || value === null || value === '' + ? undefined + : (value as T); +} + +function getConfigKeysForPlugin(targetPluginKey: string) { + switch (targetPluginKey) { + case 'bangdream': + return Object.values(BANGDREAM_TSUGU_ENV_KEYS); + case 'ff14-market': + return [ + 'FF14_DEFAULT_WORLD', + 'FF14_UNIVERSALIS_BASE_URL', + 'FF14_XIVAPI_BASE_URL', + 'FF14_XIVAPI_CHS_BASE_URL', + ]; + case 'fflogs': + return [ + 'FFLOGS_BASE_URL', + 'FFLOGS_CLIENT_ID', + 'FFLOGS_CLIENT_SECRET', + 'FFLOGS_DEFAULT_SERVER', + 'FFLOGS_DEFAULT_SERVER_REGION', + 'FFLOGS_GRAPHQL_URL', + 'FFLOGS_REQUEST_TIMEOUT_MS', + 'FFLOGS_TOKEN_URL', + 'FFLOGS_WEB_BASE_URL', + ]; + case 'repeater': + return [ + 'QQBOT_REPEATER_CONFIG_CACHE_TTL_MS', + 'QQBOT_REPEATER_MAX_TEXT_LENGTH', + 'QQBOT_REPEATER_MIN_INTERVAL_MS', + 'QQBOT_REPEATER_STATE_TTL_MS', + 'QQBOT_REPEATER_THRESHOLD', + ]; + default: + return []; + } +} + +function resolvePluginRoot(targetPluginKey: string) { + const sourceRoot = join( + process.cwd(), + `src/modules/qqbot/plugins/${targetPluginKey}`, + ); + if (existsSync(join(sourceRoot, 'plugin.json'))) return sourceRoot; + return join(__dirname, `../../../../plugins/${targetPluginKey}`); +} + +function callHost( + method: string, + args: Record = {}, +) { + const requestId = `host-${Date.now()}-${Math.random().toString(16).slice(2)}`; + return new Promise((resolve, reject) => { + pendingHostCalls.set(requestId, { + reject, + resolve: (value) => resolve(value as TResult), + }); + port?.postMessage({ + args, + method, + requestId, + type: 'hostCall', + }); + }); +} + +function serializeHttpRequest(input: WorkerHttpRequestInput) { + const { failureMessage, url, ...rest } = input; + return { + ...rest, + failureMessageTemplate: failureMessage + ? failureMessage(HTTP_STATUS_PLACEHOLDER).replaceAll( + `${HTTP_STATUS_PLACEHOLDER}`, + '{statusCode}', + ) + : undefined, + url: url instanceof URL ? url.toString() : `${url}`, + }; +} + +function readJsonFile(filePath: string) { + return JSON.parse(readFileSync(filePath, 'utf8')) as T; +} + +function readExcelRows>(filePath: string) { + const workbook = XLSX.readFile(filePath); + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + return XLSX.utils.sheet_to_json(worksheet); +} + +function normalizeErrorMessage(error: unknown, fallback = '插件执行失败') { + if (error instanceof Error && error.message) return error.message; + const message = `${error || ''}`.trim(); + return message || fallback; +} + +function serializeError(error: unknown) { + return { + message: error instanceof Error ? error.message : `${error}`, + name: error instanceof Error ? error.name : 'Error', + stack: error instanceof Error ? error.stack : undefined, + }; +} + +function deserializeError(error?: { + message?: string; + name?: string; + stack?: string; +}) { + const output = new Error(error?.message || '插件 Host 调用失败'); + if (error?.name) output.name = error.name; + if (error?.stack) output.stack = error.stack; + return output; +} + +function assertNever(value: never): never { + throw new Error(`未知插件运行时请求:${value}`); +} diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts index 07a2baa..4b092f0 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts @@ -127,11 +127,10 @@ export class QqbotPluginWorkerRuntime { ); requestPromise.catch(() => undefined); + const timeout = this.createTimeoutPromise(type, message, timeoutMs); + try { - return await Promise.race([ - requestPromise, - this.createTimeoutPromise(type, message, timeoutMs), - ]); + return await Promise.race([requestPromise, timeout.promise]); } catch (error) { if (error instanceof QqbotPluginRuntimeError) { throw error; @@ -151,6 +150,8 @@ export class QqbotPluginWorkerRuntime { ); this.recordRuntimeEvent('worker-crash', runtimeError.safeSummary); throw runtimeError; + } finally { + timeout.clear(); } } @@ -159,8 +160,9 @@ export class QqbotPluginWorkerRuntime { message: QqbotPluginWorkerRequest, timeoutMs: number, ) { - return new Promise((_, reject) => { - const timer = setTimeout(async () => { + let timer: NodeJS.Timeout | undefined; + const promise = new Promise((_, reject) => { + timer = setTimeout(async () => { this.status = 'failed'; const error = new QqbotPluginRuntimeError( 'PLUGIN_WORKER_TIMEOUT', @@ -184,6 +186,12 @@ export class QqbotPluginWorkerRuntime { }, timeoutMs); timer.unref?.(); }); + return { + clear: () => { + if (timer) clearTimeout(timer); + }, + promise, + }; } private recordRuntimeEvent( diff --git a/test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts b/test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts index 7869aac..50c4f69 100644 --- a/test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts +++ b/test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts @@ -76,6 +76,28 @@ describe('QQBot third-phase module boundaries', () => { expect(violations).toEqual([]); }); + it('prevents QQBot core application from importing NapCat infrastructure or persistence directly', () => { + const violations = collectTsFiles(join(qqbotRoot, 'core/application')) + .map((filePath) => ({ + file: toRepoPath(filePath), + source: readSource(filePath), + })) + .flatMap(({ file, source }) => { + const bannedPatterns = [ + /@\/modules\/qqbot\/napcat\/infrastructure\//, + /NapcatAccountBinding/, + /NapcatContainer/, + /QqbotNapcatContainerService/, + /@InjectRepository\(Napcat/, + ]; + return bannedPatterns + .filter((pattern) => pattern.test(source)) + .map((pattern) => `${file}: ${pattern}`); + }); + + expect(violations).toEqual([]); + }); + it('keeps QqbotCoreModule free of plugin platform controllers, registries, SDKs, and concrete plugin services', () => { const source = readSource(join(qqbotRoot, 'core/qqbot-core.module.ts')); const bannedSymbols = [ diff --git a/test/modules/qqbot/napcat/napcat-module-ownership.spec.ts b/test/modules/qqbot/napcat/napcat-module-ownership.spec.ts index ef04655..42d79d8 100644 --- a/test/modules/qqbot/napcat/napcat-module-ownership.spec.ts +++ b/test/modules/qqbot/napcat/napcat-module-ownership.spec.ts @@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { getMetadataArgsStorage } from 'typeorm'; import { AdminAuthGuardModule } from '../../../../src/modules/admin/identity/auth/admin-auth-guard.module'; import { AppModule } from '../../../../src/app.module'; +import { QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT } from '../../../../src/modules/qqbot/core/application/account/qqbot-account-napcat-runtime.port'; import { QqbotCoreModule } from '../../../../src/modules/qqbot/core/qqbot-core.module'; import { QQBOT_NAPCAT_CONTROLLERS, @@ -14,7 +15,9 @@ import { QQBOT_NAPCAT_PROVIDERS, QqbotNapcatModule, } from '../../../../src/modules/qqbot/napcat/qqbot-napcat.module'; +import { QqbotNapcatAccountRuntimeService } from '../../../../src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service'; import { QqbotNapcatLoginController } from '../../../../src/modules/qqbot/napcat/contract/qqbot-napcat-login.controller'; +import { QqbotNapcatContainerService } from '../../../../src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service'; import { collectControllerRoutes, routeKey, @@ -26,7 +29,10 @@ const getModuleMetadata = (moduleClass: unknown, key: string): T[] => { }; const getNames = (items: unknown[]) => - items.map((item) => (item as { name?: string }).name || `${item}`); + items.map((item) => { + if (typeof item === 'symbol') return item.description || item.toString(); + return (item as { name?: string }).name || `${item}`; + }); const unwrapForwardRef = (item: unknown) => { const maybeForwardRef = item as { forwardRef?: () => unknown }; @@ -102,6 +108,7 @@ describe('QQBot NapCat module ownership', () => { expect.arrayContaining([ 'NapcatDeviceIdentityService', 'NapcatLoginStateStoreService', + 'QqbotNapcatAccountRuntimeService', 'QqbotNapcatContainerService', 'QqbotNapcatLoginService', 'QqbotNapcatWatchdogService', @@ -124,10 +131,19 @@ describe('QQBot NapCat module ownership', () => { expect.arrayContaining([ 'NapcatDeviceIdentityService', 'NapcatLoginStateStoreService', - 'QqbotNapcatContainerService', + 'QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT', 'QqbotNapcatLoginService', ]), ); + expect(QQBOT_NAPCAT_EXPORTS).not.toContain(QqbotNapcatContainerService); + expect(QQBOT_NAPCAT_PROVIDERS).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + provide: QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT, + useExisting: QqbotNapcatAccountRuntimeService, + }), + ]), + ); }); it('keeps every registered NapCat entity mapped to the refactor-v3 schema', () => { diff --git a/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts b/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts index b9499b2..d881638 100644 --- a/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts @@ -1,9 +1,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; import { MODULE_METADATA } from '@nestjs/common/constants'; -import { - QQBOT_PLUGIN_RUNTIME_FACTORY, -} from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service'; +import { QQBOT_PLUGIN_RUNTIME_FACTORY } from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service'; import { QqbotPluginPlatformService } from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service'; import { QqbotEventPluginRegistryService } from '../../../../src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service'; import { QqbotPluginRegistryService } from '../../../../src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service'; @@ -53,6 +51,17 @@ describe('QQBot plugin platform lifecycle runtime contract', () => { expect(dependencies?.[0]).toBe(QqbotBuiltinPluginPackageLoaderService); }); + it('uses a real worker-thread boundary for built-in plugin runtimes', () => { + const source = readSource( + 'src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker-runtime.factory.ts', + ); + + expect(source).toContain('node:worker_threads'); + expect(source).toContain('QqbotBuiltinPluginWorkerThreadDriver'); + expect(source).not.toContain('class QqbotBuiltinPluginWorkerDriver'); + expect(source).not.toContain('new QqbotBuiltinPluginWorkerDriver'); + }); + it('uses dedicated lifecycle use cases instead of direct status flips', () => { const controller = readSource( 'src/modules/qqbot/plugin-platform/contract/plugin-platform.controller.ts', @@ -82,8 +91,12 @@ describe('QQBot plugin platform lifecycle runtime contract', () => { it('activates workers and refreshes active registries during lifecycle transitions', () => { const source = [ - readSource('src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts'), - readSource('src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts'), + readSource( + 'src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts', + ), + readSource( + 'src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts', + ), ].join('\n'); const missingRuntimeSignals = [ @@ -146,8 +159,23 @@ describe('QQBot plugin platform lifecycle runtime contract', () => { it('loads, activates, health-checks, refreshes, and disposes workers during lifecycle transitions', async () => { const manifest = { + assets: [], + configSchema: {}, entry: 'src/index.ts', + events: [], + legacyAliases: [], + migrations: [], + minApiSdkVersion: '1.0.0', + name: 'Demo Plugin', + operations: [], + permissions: [], pluginKey: 'demo-plugin', + runtime: { + maxConcurrency: 1, + memoryMb: 128, + timeoutMs: 5000, + workerType: 'node-worker', + }, version: '0.1.0', }; const installation = { @@ -264,6 +292,744 @@ describe('QQBot plugin platform lifecycle runtime contract', () => { expect(worker.dispose).toHaveBeenCalled(); }); + it('routes enabled command and message executions through active worker runtimes', async () => { + const manifest = { + assets: [], + configSchema: {}, + entry: 'src/index.ts', + events: [ + { + eventName: 'message', + handlerName: 'handleMessage', + key: 'demo-plugin.message', + name: '消息事件', + }, + ], + legacyAliases: ['demoLegacy'], + migrations: [], + minApiSdkVersion: '1.0.0', + name: 'Demo Plugin', + operations: [ + { + handlerName: 'echo', + key: 'demo-plugin.echo', + name: 'Echo', + permissions: ['qqbot.send'], + timeoutMs: 123, + }, + ], + permissions: ['qqbot.send', 'qqbot.event.receive'], + pluginKey: 'demo-plugin', + runtime: { + maxConcurrency: 1, + memoryMb: 128, + timeoutMs: 456, + workerType: 'node-worker', + }, + version: '0.1.0', + }; + const installation = { + id: 'installation-execute', + installedPath: 'D:/plugins/demo-plugin', + pluginId: 'plugin-execute', + runtimeStatus: 'stopped', + status: 'installed', + versionId: 'version-execute', + }; + const version = { + id: 'version-execute', + manifestJson: manifest, + packageHash: 'hash', + pluginId: installation.pluginId, + version: manifest.version, + }; + const createRepository = (findOneValue?: unknown) => ({ + find: jest.fn(async () => []), + findAndCount: jest.fn(async () => [[], 0]), + findOne: jest.fn(async () => findOneValue), + save: jest.fn(async (value) => value), + update: jest.fn(async () => ({ affected: 1 })), + }); + const worker = { + activate: jest.fn(async () => ({ ok: true })), + deactivate: jest.fn(async () => ({ ok: true })), + dispose: jest.fn(async () => undefined), + executeOperation: jest.fn(async () => ({ replyText: 'worker-ok' })), + handleEvent: jest.fn(async () => true), + health: jest.fn(async () => ({ ok: true })), + load: jest.fn(async () => ({ ok: true })), + }; + const runtimeFactory = { + create: jest.fn(() => worker), + }; + const argumentParser = { + normalizeInput: jest.fn(async () => ({ text: 'normalized' })), + }; + const fallbackCommandRegistry = { + execute: jest.fn(), + listOperations: jest.fn(() => [ + { + key: 'demo-plugin.echo', + name: 'Echo', + pluginKey: 'demo-plugin', + triggerMode: 'command', + }, + ]), + setPluginActive: jest.fn(), + }; + const fallbackEventRegistry = { + dispatchMessage: jest.fn(), + listOperations: jest.fn(() => [ + { + key: 'message', + name: '消息事件', + pluginKey: 'demo-plugin', + triggerMode: 'event', + }, + ]), + setPluginActive: jest.fn(), + }; + const service = new (QqbotPluginPlatformService as any)( + createRepository({ id: installation.pluginId, pluginKey: 'demo-plugin' }), + createRepository(version), + createRepository(installation), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + argumentParser, + runtimeFactory, + fallbackCommandRegistry, + fallbackEventRegistry, + ) as QqbotPluginPlatformService; + + await service.enableInstallation({ id: installation.id }); + + await expect( + service.executeOperation({ + input: { text: 'raw' }, + operationKey: 'demo-plugin.echo', + pluginKey: 'demoLegacy', + }), + ).resolves.toEqual({ replyText: 'worker-ok' }); + await expect( + service.dispatchEvent({ + eventKey: 'message', + message: { + eventTime: new Date(), + messageId: 'msg-1', + messageText: 'hello', + messageType: 'group', + rawEvent: {}, + rawMessage: 'hello', + selfId: '10000', + targetId: '20000', + userId: '30000', + }, + }), + ).resolves.toBe(true); + + expect(worker.executeOperation).toHaveBeenCalledWith({ + input: { text: 'normalized' }, + operationId: 'demo-plugin.echo', + operationKey: 'demo-plugin.echo', + timeoutMs: 123, + }); + expect(worker.handleEvent).toHaveBeenCalledWith({ + event: expect.objectContaining({ + messageId: 'msg-1', + }), + eventKey: 'message', + timeoutMs: 456, + }); + expect(argumentParser.normalizeInput).toHaveBeenCalledWith( + expect.objectContaining({ + operationKey: 'demo-plugin.echo', + pluginKey: 'demoLegacy', + }), + ); + expect(fallbackCommandRegistry.execute).not.toHaveBeenCalled(); + expect(fallbackEventRegistry.dispatchMessage).not.toHaveBeenCalled(); + }); + + it('does not execute workers for persisted disabled built-in plugins', async () => { + const manifest = { + assets: [], + configSchema: {}, + entry: 'src/index.ts', + events: [], + legacyAliases: ['demoLegacy'], + migrations: [], + minApiSdkVersion: '1.0.0', + name: 'Demo Plugin', + operations: [ + { + handlerName: 'echo', + key: 'demo-plugin.echo', + name: 'Echo', + permissions: ['qqbot.send'], + timeoutMs: 123, + }, + ], + permissions: ['qqbot.send'], + pluginKey: 'demo-plugin', + runtime: { + maxConcurrency: 1, + memoryMb: 128, + timeoutMs: 456, + workerType: 'node-worker', + }, + version: '0.1.0', + }; + const plugin = { + id: 'plugin-disabled', + pluginKey: 'demo-plugin', + }; + const disabledInstallation = { + id: 'installation-disabled', + installedPath: 'D:/plugins/demo-plugin', + pluginId: plugin.id, + runtimeStatus: 'stopped', + status: 'disabled', + versionId: 'version-disabled', + }; + const createRepository = ( + rows: unknown[] = [], + findOneValue?: unknown, + ) => ({ + find: jest.fn(async () => rows), + findAndCount: jest.fn(async () => [rows, rows.length]), + findOne: jest.fn(async () => findOneValue || null), + save: jest.fn(async (value) => value), + update: jest.fn(async () => ({ affected: 1 })), + }); + const worker = { + activate: jest.fn(async () => ({ ok: true })), + deactivate: jest.fn(async () => ({ ok: true })), + dispose: jest.fn(async () => undefined), + executeOperation: jest.fn(async () => ({ replyText: 'should-not-run' })), + handleEvent: jest.fn(async () => true), + health: jest.fn(async () => ({ ok: true })), + load: jest.fn(async () => ({ ok: true })), + }; + const runtimeFactory = { + create: jest.fn(() => worker), + }; + const service = new (QqbotPluginPlatformService as any)( + createRepository([plugin], plugin), + createRepository(), + createRepository([disabledInstallation], disabledInstallation), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + undefined, + runtimeFactory, + undefined, + undefined, + undefined, + { + loadBuiltinManifests: jest.fn(() => [manifest]), + }, + ) as QqbotPluginPlatformService; + + await service.onModuleInit(); + + expect(runtimeFactory.create).not.toHaveBeenCalled(); + expect(await service.listActiveOperations()).toEqual([]); + await expect( + service.executeOperation({ + input: { text: 'raw' }, + operationKey: 'demo-plugin.echo', + pluginKey: 'demoLegacy', + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + msg: 'QQBot 插件运行时未启用:demoLegacy', + }), + }); + expect(worker.executeOperation).not.toHaveBeenCalled(); + }); + + it('clears built-in worker contexts when a persisted installation disables the same plugin', async () => { + const manifest = { + assets: [], + configSchema: {}, + entry: 'src/index.ts', + events: [], + legacyAliases: ['demoLegacy'], + migrations: [], + minApiSdkVersion: '1.0.0', + name: 'Demo Plugin', + operations: [ + { + handlerName: 'echo', + key: 'demo-plugin.echo', + name: 'Echo', + permissions: ['qqbot.send'], + timeoutMs: 123, + }, + ], + permissions: ['qqbot.send'], + pluginKey: 'demo-plugin', + runtime: { + maxConcurrency: 1, + memoryMb: 128, + timeoutMs: 456, + workerType: 'node-worker', + }, + version: '0.1.0', + }; + const plugin = { + id: 'plugin-enabled', + pluginKey: 'demo-plugin', + }; + const installation = { + id: 'installation-enabled', + installedPath: 'D:/plugins/demo-plugin', + pluginId: plugin.id, + runtimeStatus: 'healthy', + status: 'enabled', + versionId: 'version-enabled', + }; + const createRepository = ( + rows: unknown[] = [], + findOneValue?: unknown, + ) => ({ + find: jest.fn(async () => rows), + findAndCount: jest.fn(async () => [rows, rows.length]), + findOne: jest.fn(async () => findOneValue || null), + save: jest.fn(async (value) => value), + update: jest.fn(async () => ({ affected: 1 })), + }); + const builtinWorker = { + activate: jest.fn(async () => ({ ok: true })), + deactivate: jest.fn(async () => ({ ok: true })), + dispose: jest.fn(async () => undefined), + executeOperation: jest.fn(async () => ({ replyText: 'builtin' })), + handleEvent: jest.fn(async () => true), + health: jest.fn(async () => ({ ok: true })), + load: jest.fn(async () => ({ ok: true })), + }; + const runtimeFactory = { + create: jest.fn(() => builtinWorker), + }; + const service = new (QqbotPluginPlatformService as any)( + createRepository([plugin], plugin), + createRepository(), + createRepository([installation], installation), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + undefined, + runtimeFactory, + undefined, + undefined, + undefined, + { + loadBuiltinManifests: jest.fn(() => [manifest]), + }, + ) as QqbotPluginPlatformService; + + await service.onModuleInit(); + await expect( + service.executeOperation({ + input: { text: 'raw' }, + operationKey: 'demo-plugin.echo', + pluginKey: 'demoLegacy', + }), + ).resolves.toEqual({ replyText: 'builtin' }); + + await service.disableInstallation({ id: installation.id }); + + await expect( + service.executeOperation({ + input: { text: 'raw' }, + operationKey: 'demo-plugin.echo', + pluginKey: 'demoLegacy', + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + msg: 'QQBot 插件运行时未启用:demoLegacy', + }), + }); + expect(builtinWorker.deactivate).toHaveBeenCalled(); + expect(builtinWorker.dispose).toHaveBeenCalled(); + expect(builtinWorker.executeOperation).toHaveBeenCalledTimes(1); + }); + + it('replaces the default built-in worker when a real installation is enabled later', async () => { + const manifest = { + assets: [], + configSchema: {}, + entry: 'src/index.ts', + events: [ + { + eventName: 'message', + handlerName: 'handleMessage', + key: 'demo-plugin.message', + name: '消息事件', + }, + ], + legacyAliases: ['demoLegacy'], + migrations: [], + minApiSdkVersion: '1.0.0', + name: 'Demo Plugin', + operations: [ + { + handlerName: 'echo', + key: 'demo-plugin.echo', + name: 'Echo', + permissions: ['qqbot.send'], + timeoutMs: 123, + }, + ], + permissions: ['qqbot.send', 'qqbot.event.receive'], + pluginKey: 'demo-plugin', + runtime: { + maxConcurrency: 1, + memoryMb: 128, + timeoutMs: 456, + workerType: 'node-worker', + }, + version: '0.1.0', + }; + const plugin = { + id: 'plugin-late', + pluginKey: 'demo-plugin', + }; + const installation = { + id: 'installation-late', + installedPath: 'D:/plugins/demo-plugin', + pluginId: plugin.id, + runtimeStatus: 'stopped', + status: 'installed', + versionId: 'version-late', + }; + const version = { + id: 'version-late', + manifestJson: manifest, + packageHash: 'hash', + pluginId: plugin.id, + version: manifest.version, + }; + const createRepository = ( + rows: unknown[] = [], + findOneValue?: unknown, + ) => ({ + find: jest.fn(async () => rows), + findAndCount: jest.fn(async () => [rows, rows.length]), + findOne: jest.fn(async () => findOneValue || null), + save: jest.fn(async (value) => value), + update: jest.fn(async () => ({ affected: 1 })), + }); + const defaultWorker = { + activate: jest.fn(async () => ({ ok: true })), + deactivate: jest.fn(async () => ({ ok: true })), + dispose: jest.fn(async () => undefined), + executeOperation: jest.fn(async () => ({ replyText: 'default' })), + handleEvent: jest.fn(async () => true), + health: jest.fn(async () => ({ ok: true })), + load: jest.fn(async () => ({ ok: true })), + }; + const realWorker = { + activate: jest.fn(async () => ({ ok: true })), + deactivate: jest.fn(async () => ({ ok: true })), + dispose: jest.fn(async () => undefined), + executeOperation: jest.fn(async () => ({ replyText: 'real' })), + handleEvent: jest.fn(async () => true), + health: jest.fn(async () => ({ ok: true })), + load: jest.fn(async () => ({ ok: true })), + }; + const runtimeFactory = { + create: jest + .fn() + .mockReturnValueOnce(defaultWorker) + .mockReturnValueOnce(realWorker), + }; + const service = new (QqbotPluginPlatformService as any)( + createRepository([plugin], plugin), + createRepository([], version), + createRepository([], installation), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + undefined, + runtimeFactory, + undefined, + undefined, + undefined, + { + loadBuiltinManifests: jest.fn(() => [manifest]), + }, + ) as QqbotPluginPlatformService; + + await service.onModuleInit(); + await expect( + service.executeOperation({ + input: { text: 'raw' }, + operationKey: 'demo-plugin.echo', + pluginKey: 'demoLegacy', + }), + ).resolves.toEqual({ replyText: 'default' }); + + await service.enableInstallation({ id: installation.id }); + await expect( + service.executeOperation({ + input: { text: 'raw' }, + operationKey: 'demo-plugin.echo', + pluginKey: 'demoLegacy', + }), + ).resolves.toEqual({ replyText: 'real' }); + + expect(defaultWorker.deactivate).toHaveBeenCalled(); + expect(defaultWorker.dispose).toHaveBeenCalled(); + expect(realWorker.executeOperation).toHaveBeenCalledTimes(1); + + const operations = await service.listActiveOperations(); + expect( + operations.filter((operation) => operation.pluginKey === 'demo-plugin'), + ).toHaveLength(2); + + await service.dispatchEvent({ + eventKey: 'message', + message: { + eventTime: new Date(), + messageId: 'msg-late', + messageText: 'hello', + messageType: 'group', + rawEvent: {}, + rawMessage: 'hello', + selfId: '10000', + targetId: '20000', + userId: '30000', + }, + }); + expect(defaultWorker.handleEvent).not.toHaveBeenCalled(); + expect(realWorker.handleEvent).toHaveBeenCalledTimes(1); + + await service.disableInstallation({ id: installation.id }); + expect(await service.listActiveOperations()).toEqual([]); + }); + + it('filters active operation pages by legacy plugin aliases', async () => { + const manifest = { + assets: [], + configSchema: {}, + entry: 'src/index.ts', + events: [], + legacyAliases: ['demoLegacy'], + migrations: [], + minApiSdkVersion: '1.0.0', + name: 'Demo Plugin', + operations: [ + { + handlerName: 'echo', + key: 'demo-plugin.echo', + name: 'Echo', + permissions: ['qqbot.send'], + timeoutMs: 123, + }, + ], + permissions: ['qqbot.send'], + pluginKey: 'demo-plugin', + runtime: { + maxConcurrency: 1, + memoryMb: 128, + timeoutMs: 456, + workerType: 'node-worker', + }, + version: '0.1.0', + }; + const createRepository = (findOneValue?: unknown) => ({ + find: jest.fn(async () => []), + findAndCount: jest.fn(async () => [[], 0]), + findOne: jest.fn(async () => findOneValue || null), + save: jest.fn(async (value) => value), + update: jest.fn(async () => ({ affected: 1 })), + }); + const worker = { + activate: jest.fn(async () => ({ ok: true })), + deactivate: jest.fn(async () => ({ ok: true })), + dispose: jest.fn(async () => undefined), + executeOperation: jest.fn(), + handleEvent: jest.fn(), + health: jest.fn(async () => ({ ok: true })), + load: jest.fn(async () => ({ ok: true })), + }; + const service = new (QqbotPluginPlatformService as any)( + createRepository({ id: 'plugin-alias', pluginKey: 'demo-plugin' }), + createRepository({ + id: 'version-alias', + manifestJson: manifest, + packageHash: 'hash', + pluginId: 'plugin-alias', + version: manifest.version, + }), + createRepository({ + id: 'installation-alias', + installedPath: 'D:/plugins/demo-plugin', + pluginId: 'plugin-alias', + runtimeStatus: 'stopped', + status: 'installed', + versionId: 'version-alias', + }), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + undefined, + { + create: jest.fn(() => worker), + }, + ) as QqbotPluginPlatformService; + + await service.enableInstallation({ id: 'installation-alias' }); + + await expect( + service.listOperationSummaries({ pluginKey: 'demoLegacy' }), + ).resolves.toEqual([ + expect.objectContaining({ + key: 'demo-plugin.echo', + pluginKey: 'demo-plugin', + }), + ]); + await expect( + service.pageOperationSummaries({ + pageNo: 1, + pageSize: 10, + pluginKey: 'demoLegacy', + }), + ).resolves.toMatchObject({ + total: 1, + }); + }); + + it('disposes the previous worker after a successful upgrade on the same installation', async () => { + const manifest = { + assets: [], + configSchema: {}, + entry: 'src/index.ts', + events: [], + legacyAliases: ['demoLegacy'], + migrations: [], + minApiSdkVersion: '1.0.0', + name: 'Demo Plugin', + operations: [ + { + handlerName: 'echo', + key: 'demo-plugin.echo', + name: 'Echo', + permissions: ['qqbot.send'], + timeoutMs: 123, + }, + ], + permissions: ['qqbot.send'], + pluginKey: 'demo-plugin', + runtime: { + maxConcurrency: 1, + memoryMb: 128, + timeoutMs: 456, + workerType: 'node-worker', + }, + version: '0.1.0', + }; + const installation = { + id: 'installation-upgrade-success', + installedPath: 'D:/plugins/demo-plugin', + pluginId: 'plugin-upgrade-success', + runtimeStatus: 'stopped', + status: 'installed', + versionId: 'version-upgrade-success', + }; + const version = { + id: 'version-upgrade-success', + manifestJson: manifest, + packageHash: 'hash', + pluginId: installation.pluginId, + version: manifest.version, + }; + const createRepository = (findOneValue?: unknown) => ({ + find: jest.fn(async () => []), + findAndCount: jest.fn(async () => [[], 0]), + findOne: jest.fn(async () => findOneValue || null), + save: jest.fn(async (value) => value), + update: jest.fn(async () => ({ affected: 1 })), + }); + const oldWorker = { + activate: jest.fn(async () => ({ ok: true })), + deactivate: jest.fn(async () => ({ ok: true })), + dispose: jest.fn(async () => undefined), + executeOperation: jest.fn(async () => ({ replyText: 'old' })), + handleEvent: jest.fn(async () => true), + health: jest.fn(async () => ({ ok: true })), + load: jest.fn(async () => ({ ok: true })), + }; + const newWorker = { + activate: jest.fn(async () => ({ ok: true })), + deactivate: jest.fn(async () => ({ ok: true })), + dispose: jest.fn(async () => undefined), + executeOperation: jest.fn(async () => ({ replyText: 'new' })), + handleEvent: jest.fn(async () => true), + health: jest.fn(async () => ({ ok: true })), + load: jest.fn(async () => ({ ok: true })), + }; + const runtimeFactory = { + create: jest + .fn() + .mockReturnValueOnce(oldWorker) + .mockReturnValueOnce(newWorker), + }; + const service = new (QqbotPluginPlatformService as any)( + createRepository({ id: installation.pluginId, pluginKey: 'demo-plugin' }), + createRepository(version), + createRepository(installation), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + undefined, + runtimeFactory, + ) as QqbotPluginPlatformService; + + await service.enableInstallation({ id: installation.id }); + await expect( + service.executeOperation({ + input: { text: 'raw' }, + operationKey: 'demo-plugin.echo', + pluginKey: 'demoLegacy', + }), + ).resolves.toEqual({ replyText: 'old' }); + + await service.upgradeInstallation({ id: installation.id }); + + expect(oldWorker.deactivate).toHaveBeenCalled(); + expect(oldWorker.dispose).toHaveBeenCalled(); + await expect( + service.executeOperation({ + input: { text: 'raw' }, + operationKey: 'demo-plugin.echo', + pluginKey: 'demoLegacy', + }), + ).resolves.toEqual({ replyText: 'new' }); + expect(oldWorker.executeOperation).toHaveBeenCalledTimes(1); + expect(newWorker.executeOperation).toHaveBeenCalledTimes(1); + }); + it('blocks command execution and event dispatch for inactive plugins', async () => { const commandPlugin = { key: 'demo-plugin', @@ -424,6 +1190,37 @@ describe('QQBot plugin platform lifecycle runtime contract', () => { ); }); + it('does not activate legacy command plugins that are persisted inactive on startup', async () => { + const commandPlugin = { + activate: jest.fn(async () => undefined), + key: 'demo-plugin', + name: 'Demo Plugin', + operations: [ + { + execute: jest.fn(async () => ({ replyText: 'ok' })), + key: 'demo.echo', + name: 'echo', + }, + ], + version: '0.1.0', + }; + const createRepository = (rows: unknown[] = []) => ({ + find: jest.fn(async () => rows), + }); + const commandRegistry = new (QqbotPluginRegistryService as any)( + { + loadCommandPlugins: () => [commandPlugin], + }, + createRepository([{ id: 'plugin-command', pluginKey: 'demo-plugin' }]), + createRepository([{ pluginId: 'plugin-command', status: 'disabled' }]), + ) as QqbotPluginRegistryService; + + await commandRegistry.onModuleInit(); + + expect(commandPlugin.activate).not.toHaveBeenCalled(); + expect(commandRegistry.listOperations('demo-plugin')).toEqual([]); + }); + it('keeps the previous active worker when upgrade health check fails', async () => { const installation = { id: 'installation-upgrade', @@ -436,8 +1233,23 @@ describe('QQBot plugin platform lifecycle runtime contract', () => { const version = { id: 'version-upgrade', manifestJson: { + assets: [], + configSchema: {}, entry: 'src/index.ts', + events: [], + legacyAliases: [], + migrations: [], + minApiSdkVersion: '1.0.0', + name: 'Demo Plugin', + operations: [], + permissions: [], pluginKey: 'demo-plugin', + runtime: { + maxConcurrency: 1, + memoryMb: 128, + timeoutMs: 5000, + workerType: 'node-worker', + }, version: '0.2.0', }, packageHash: 'hash', diff --git a/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts b/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts index 5d40e49..0509b59 100644 --- a/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts @@ -289,7 +289,9 @@ describe('QQBot plugin platform API contract', () => { }) .expect(200); - expect(repositoryMocks.get(QqbotPluginRuntimeEvent)?.find).toHaveBeenCalledWith({ + expect( + repositoryMocks.get(QqbotPluginRuntimeEvent)?.find, + ).toHaveBeenCalledWith({ where: { eventType: 'worker-crash', installationId: '2002', @@ -299,4 +301,99 @@ describe('QQBot plugin platform API contract', () => { }, }); }); + + it('serves platform operation pages from active runtime summaries when persistence rows are empty', async () => { + const manifest = createManifest(); + const installation = { + id: 'install-demo', + installedPath: 'D:/plugins/demo', + pluginId: 'plugin-demo', + runtimeStatus: 'stopped', + status: 'installed', + versionId: 'version-demo', + }; + const version = { + id: 'version-demo', + manifestJson: manifest, + packageHash: 'hash', + pluginId: installation.pluginId, + version: manifest.version, + }; + const createRepository = (findOneValue?: unknown) => ({ + find: jest.fn(async () => []), + findAndCount: jest.fn(async () => [[], 0]), + findOne: jest.fn(async () => findOneValue || null), + save: jest.fn(async (value) => value), + update: jest.fn(async () => ({ affected: 1 })), + }); + const operationRepository = createRepository(); + const worker = { + activate: jest.fn(async () => ({ ok: true })), + deactivate: jest.fn(async () => ({ ok: true })), + dispose: jest.fn(async () => undefined), + executeOperation: jest.fn(), + handleEvent: jest.fn(), + health: jest.fn(async () => ({ ok: true })), + load: jest.fn(async () => ({ ok: true })), + }; + const runtimeFactory = { + create: jest.fn(() => worker), + }; + const service = new (QqbotPluginPlatformService as any)( + createRepository({ + id: installation.pluginId, + pluginKey: manifest.pluginKey, + }), + createRepository(version), + createRepository(installation), + operationRepository, + createRepository(), + createRepository(), + createRepository(), + createRepository(), + createRepository(), + undefined, + runtimeFactory, + ) as QqbotPluginPlatformService; + + await service.enableInstallation({ id: installation.id }); + const page = await service.pageOperations({ + pageNo: 1, + pageSize: 10, + triggerMode: 'command', + } as any); + + expect(operationRepository.findAndCount).not.toHaveBeenCalled(); + expect(page).toMatchObject({ + pageNo: 1, + pageSize: 10, + total: 1, + }); + expect(page.list).toEqual([ + expect.objectContaining({ + key: 'demo-plugin.echo', + pluginKey: 'demo-plugin', + triggerMode: 'command', + }), + ]); + }); + + it('keeps compatible plugin operation routes delegated to platform service ownership', () => { + const source = readFileSync( + join( + __dirname, + '../../../../src/modules/qqbot/plugin-platform/contract/qqbot-plugin.controller.ts', + ), + 'utf8', + ); + const operationRoutes = source.slice( + source.indexOf("@Get('operation/list')"), + source.indexOf("@Get('health')"), + ); + + expect(operationRoutes).toContain('this.service.'); + expect(operationRoutes).not.toContain('this.pluginRegistry'); + expect(operationRoutes).not.toContain('this.eventPluginRegistry'); + expect(source).not.toContain('private listOperations('); + }); }); diff --git a/test/modules/qqbot/plugin-platform/worker-runtime.spec.ts b/test/modules/qqbot/plugin-platform/worker-runtime.spec.ts index cbb8a8a..d3bdfa0 100644 --- a/test/modules/qqbot/plugin-platform/worker-runtime.spec.ts +++ b/test/modules/qqbot/plugin-platform/worker-runtime.spec.ts @@ -175,6 +175,30 @@ describe('QQBot plugin worker runtime', () => { expect(driver.dispose).toHaveBeenCalled(); expect(runtime.status).toBe('failed'); }); + + it('keeps successful worker calls alive after the timeout window', async () => { + const driver: QqbotPluginWorkerDriver = { + dispose: jest.fn(async () => undefined), + request: jest.fn(async () => ({ ok: true })), + }; + const runtime = new QqbotPluginWorkerRuntime(driver, { + defaultTimeoutMs: 5, + installationId: 'install-success', + pluginKey: 'demo-plugin', + }); + + await runtime.load({ + entry: 'src/index.ts', + pluginKey: 'demo-plugin', + version: '0.1.0', + }); + await runtime.activate(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(driver.dispose).not.toHaveBeenCalled(); + expect(runtime.status).toBe('active'); + expect(runtime.listRuntimeEvents()).toEqual([]); + }); }); describe('QQBot plugin SDK contract', () => { diff --git a/test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts b/test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts index e651c3c..72e105e 100644 --- a/test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts +++ b/test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts @@ -10,6 +10,7 @@ import { Test } from '@nestjs/testing'; import * as request from 'supertest'; import { ToolsService } from '../../../../src/common'; import { QqbotPluginController } from '../../../../src/modules/qqbot/plugin-platform/contract/qqbot-plugin.controller'; +import { QqbotPluginPlatformService } from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service'; import { QqbotEventPluginRegistryService } from '../../../../src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service'; import { QqbotPluginRegistryService } from '../../../../src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service'; import type { QqbotIntegrationPlugin } from '../../../../src/modules/qqbot/core/contract/qqbot.types'; @@ -31,6 +32,30 @@ const createPlugin = ( version: '1.0.0', }); +const listOperationSummaries = (pluginKey?: string) => { + const plugins = [ + createPlugin('bangdream', ['bangDream']), + createPlugin('ff14-market', ['ff14Market']), + createPlugin('fflogs'), + ]; + const resolvedPluginKey = + pluginKey === 'bangDream' + ? 'bangdream' + : pluginKey === 'ff14Market' + ? 'ff14-market' + : pluginKey; + return plugins + .filter((plugin) => !resolvedPluginKey || plugin.key === resolvedPluginKey) + .flatMap((plugin) => + plugin.operations.map((operation) => ({ + key: operation.key, + name: operation.name, + pluginKey: plugin.key, + triggerMode: 'command', + })), + ); +}; + describe('QQBot plugin controller local HTTP smoke', () => { let app: INestApplication; @@ -43,29 +68,7 @@ describe('QQBot plugin controller local HTTP smoke', () => { provide: QqbotPluginRegistryService, useValue: { health: jest.fn(async () => []), - listOperations: jest.fn((pluginKey?: string) => { - const plugins = [ - createPlugin('bangdream', ['bangDream']), - createPlugin('ff14-market', ['ff14Market']), - createPlugin('fflogs'), - ]; - const resolvedPluginKey = - pluginKey === 'bangDream' - ? 'bangdream' - : pluginKey === 'ff14Market' - ? 'ff14-market' - : pluginKey; - return plugins - .filter((plugin) => !resolvedPluginKey || plugin.key === resolvedPluginKey) - .flatMap((plugin) => - plugin.operations.map((operation) => ({ - key: operation.key, - name: operation.name, - pluginKey: plugin.key, - triggerMode: 'command', - })), - ); - }), + listOperations: jest.fn(listOperationSummaries), listPlugins: jest.fn(() => [ createPlugin('bangdream', ['bangDream']), @@ -81,6 +84,26 @@ describe('QQBot plugin controller local HTTP smoke', () => { ), }, }, + { + provide: QqbotPluginPlatformService, + useValue: { + listOperationSummaries: jest.fn(async (query) => + listOperationSummaries(query?.pluginKey), + ), + pageOperationSummaries: jest.fn(async (query) => { + const pageNo = Number(query?.pageNo || 1); + const pageSize = Number(query?.pageSize || 10); + const operations = listOperationSummaries(query?.pluginKey); + const skip = (pageNo - 1) * pageSize; + return { + list: operations.slice(skip, skip + pageSize), + pageNo, + pageSize, + total: operations.length, + }; + }), + }, + }, { provide: QqbotEventPluginRegistryService, useValue: { diff --git a/test/qqbot/account/qqbot-account.service.spec.ts b/test/qqbot/account/qqbot-account.service.spec.ts index 4cfd2bd..42bf4d0 100644 --- a/test/qqbot/account/qqbot-account.service.spec.ts +++ b/test/qqbot/account/qqbot-account.service.spec.ts @@ -1,5 +1,65 @@ import { ToolsService } from '@/common'; +import type { QqbotAccountNapcatRuntimePort } from '@/modules/qqbot/core/application/account/qqbot-account-napcat-runtime.port'; import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; +import { QqbotNapcatAccountRuntimeService } from '@/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service'; + +const createAccountService = (input: { + accountAbilityRepository?: any; + accountRepository: any; + configService?: any; + napcatRuntime?: QqbotAccountNapcatRuntimePort; + passwordCryptoService?: any; + systemNoticePublisher?: any; + toolsService?: ToolsService; +}) => + new QqbotAccountService( + input.accountRepository, + input.accountAbilityRepository || {}, + input.toolsService || new ToolsService(), + input.napcatRuntime, + input.systemNoticePublisher, + input.configService, + input.passwordCryptoService, + ); + +const createNapcatRuntime = (input: { + bindingRepository: any; + containerRepository: any; + containerService: any; + toolsService?: ToolsService; +}) => + new QqbotNapcatAccountRuntimeService( + input.bindingRepository, + input.containerRepository, + input.containerService, + input.toolsService || new ToolsService(), + ); + +const createAccountServiceWithNapcatRuntime = (input: { + accountRepository: any; + bindingRepository: any; + configService?: any; + containerRepository: any; + containerService: any; + passwordCryptoService?: any; + systemNoticePublisher?: any; + toolsService?: ToolsService; +}) => { + const toolsService = input.toolsService || new ToolsService(); + return createAccountService({ + accountRepository: input.accountRepository, + configService: input.configService, + napcatRuntime: createNapcatRuntime({ + bindingRepository: input.bindingRepository, + containerRepository: input.containerRepository, + containerService: input.containerService, + toolsService, + }), + passwordCryptoService: input.passwordCryptoService, + systemNoticePublisher: input.systemNoticePublisher, + toolsService, + }); +}; describe('QqbotAccountService', () => { it('stores NapCat login password as encrypted secret and never persists the transport field', async () => { @@ -9,23 +69,18 @@ describe('QqbotAccountService', () => { findOne: jest.fn().mockResolvedValue(null), save: jest.fn(async (input) => ({ ...input, id: 'account-1' })), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - {} as any, - {} as any, - {} as any, - toolsService, - undefined, - { + const service = createAccountService({ + accountRepository, + configService: { get: jest.fn((key: string) => key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '', ), - } as any, - { + }, + passwordCryptoService: { decryptPassword: jest.fn().mockReturnValue('qq-login-password'), - } as any, - ); + }, + toolsService, + }); await service.save({ encryptedLoginPassword: 'encrypted-payload', @@ -52,21 +107,15 @@ describe('QqbotAccountService', () => { findOne: jest.fn().mockResolvedValue(null), save: jest.fn(async (input) => ({ ...input, id: 'account-1' })), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - {} as any, - {} as any, - {} as any, - new ToolsService(), - undefined, - { + const service = createAccountService({ + accountRepository, + configService: { get: jest.fn().mockReturnValue(''), - } as any, - { + }, + passwordCryptoService: { decryptPassword: jest.fn().mockReturnValue('qq-login-password'), - } as any, - ); + }, + }); await expect( service.save({ @@ -87,23 +136,17 @@ describe('QqbotAccountService', () => { findOne: jest.fn().mockResolvedValue(null), save: jest.fn(async (input) => ({ ...input, id: 'account-1' })), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - {} as any, - {} as any, - {} as any, - new ToolsService(), - undefined, - { + const service = createAccountService({ + accountRepository, + configService: { get: jest.fn((key: string) => key === 'ADMIN_TOKEN_SECRET' ? 'change-me' : '', ), - } as any, - { + }, + passwordCryptoService: { decryptPassword: jest.fn().mockReturnValue('qq-login-password'), - } as any, - ); + }, + }); await expect( service.save({ @@ -125,23 +168,18 @@ describe('QqbotAccountService', () => { findOne: jest.fn().mockResolvedValue(null), save: jest.fn(async (input) => ({ ...input, id: 'account-1' })), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - {} as any, - {} as any, - {} as any, - toolsService, - undefined, - { + const service = createAccountService({ + accountRepository, + configService: { get: jest.fn((key: string) => key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '', ), - } as any, - { + }, + passwordCryptoService: { decryptPassword: jest.fn().mockReturnValue(' qq-login-password '), - } as any, - ); + }, + toolsService, + }); await service.save({ encryptedLoginPassword: 'encrypted-payload', @@ -162,14 +200,10 @@ describe('QqbotAccountService', () => { findOne: jest.fn().mockResolvedValue({ id: 'account-1' }), update: jest.fn(), }; - const service = new QqbotAccountService( - accountRepository as any, - { update: jest.fn() } as any, - {} as any, - {} as any, - {} as any, - new ToolsService(), - ); + const service = createAccountService({ + accountAbilityRepository: { update: jest.fn() }, + accountRepository, + }); await service.update({ id: 'account-1', @@ -190,14 +224,7 @@ describe('QqbotAccountService', () => { const accountRepository = { update: jest.fn(), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - {} as any, - {} as any, - {} as any, - new ToolsService(), - ); + const service = createAccountService({ accountRepository }); await service.markOffline('1914728559'); @@ -213,14 +240,7 @@ describe('QqbotAccountService', () => { const accountRepository = { update: jest.fn(), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - {} as any, - {} as any, - {} as any, - new ToolsService(), - ); + const service = createAccountService({ accountRepository }); await service.markOnline('1914728559', 'Universal'); @@ -239,14 +259,7 @@ describe('QqbotAccountService', () => { const accountRepository = { update: jest.fn(), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - {} as any, - {} as any, - {} as any, - new ToolsService(), - ); + const service = createAccountService({ accountRepository }); await service.markOnline('1914728559', 'Universal', null); @@ -265,14 +278,7 @@ describe('QqbotAccountService', () => { const accountRepository = { update: jest.fn(), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - {} as any, - {} as any, - {} as any, - new ToolsService(), - ); + const service = createAccountService({ accountRepository }); await service.markOffline('1914728559', '错误'.repeat(300)); @@ -343,15 +349,15 @@ describe('QqbotAccountService', () => { const systemNoticePublisher = { publishSystemNotice: jest.fn().mockResolvedValue('notice-1'), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - { createQueryBuilder: jest.fn(createBindingBuilder) } as any, - { createQueryBuilder: jest.fn(createContainerBuilder) } as any, - napcatContainerService as any, - new ToolsService(), - systemNoticePublisher as any, - ); + const service = createAccountServiceWithNapcatRuntime({ + accountRepository, + bindingRepository: { createQueryBuilder: jest.fn(createBindingBuilder) }, + containerRepository: { + createQueryBuilder: jest.fn(createContainerBuilder), + }, + containerService: napcatContainerService, + systemNoticePublisher, + }); const page = await service.page({}); @@ -425,10 +431,9 @@ describe('QqbotAccountService', () => { const napcatContainerService = { detectRuntimeOffline: jest.fn(), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - { + const service = createAccountServiceWithNapcatRuntime({ + accountRepository, + bindingRepository: { createQueryBuilder: jest.fn(() => ({ addOrderBy: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), @@ -436,17 +441,16 @@ describe('QqbotAccountService', () => { orderBy: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), })), - } as any, - { + }, + containerRepository: { createQueryBuilder: jest.fn(() => ({ andWhere: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([container]), where: jest.fn().mockReturnThis(), })), - } as any, - napcatContainerService as any, - new ToolsService(), - ); + }, + containerService: napcatContainerService, + }); const page = await service.page({}); @@ -510,10 +514,9 @@ describe('QqbotAccountService', () => { webuiOnline: true, }), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - { + const service = createAccountServiceWithNapcatRuntime({ + accountRepository, + bindingRepository: { createQueryBuilder: jest.fn(() => ({ addOrderBy: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), @@ -521,18 +524,17 @@ describe('QqbotAccountService', () => { orderBy: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), })), - } as any, - { + }, + containerRepository: { createQueryBuilder: jest.fn(() => ({ addSelect: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([container]), where: jest.fn().mockReturnThis(), })), - } as any, - napcatContainerService as any, - new ToolsService(), - ); + }, + containerService: napcatContainerService, + }); const page = await service.page({}); @@ -593,10 +595,9 @@ describe('QqbotAccountService', () => { })), update: jest.fn(), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - { + const service = createAccountServiceWithNapcatRuntime({ + accountRepository, + bindingRepository: { createQueryBuilder: jest.fn(() => ({ addOrderBy: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), @@ -604,18 +605,17 @@ describe('QqbotAccountService', () => { orderBy: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), })), - } as any, - { + }, + containerRepository: { createQueryBuilder: jest.fn(() => ({ addSelect: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([container]), where: jest.fn().mockReturnThis(), })), - } as any, - {} as any, - new ToolsService(), - ); + }, + containerService: {}, + }); const page = await service.page({}); @@ -670,10 +670,9 @@ describe('QqbotAccountService', () => { })), update: jest.fn(), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - { + const service = createAccountServiceWithNapcatRuntime({ + accountRepository, + bindingRepository: { createQueryBuilder: jest.fn(() => ({ addOrderBy: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), @@ -681,18 +680,17 @@ describe('QqbotAccountService', () => { orderBy: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), })), - } as any, - { + }, + containerRepository: { createQueryBuilder: jest.fn(() => ({ addSelect: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([container]), where: jest.fn().mockReturnThis(), })), - } as any, - {} as any, - new ToolsService(), - ); + }, + containerService: {}, + }); const page = await service.page({}); @@ -752,10 +750,9 @@ describe('QqbotAccountService', () => { const systemNoticePublisher = { publishSystemNotice: jest.fn().mockResolvedValue(undefined), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - { + const service = createAccountServiceWithNapcatRuntime({ + accountRepository, + bindingRepository: { createQueryBuilder: jest.fn(() => ({ addOrderBy: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), @@ -763,18 +760,17 @@ describe('QqbotAccountService', () => { orderBy: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), })), - } as any, - { + }, + containerRepository: { createQueryBuilder: jest.fn(() => ({ andWhere: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([container]), where: jest.fn().mockReturnThis(), })), - } as any, - napcatContainerService as any, - new ToolsService(), - systemNoticePublisher as any, - ); + }, + containerService: napcatContainerService, + systemNoticePublisher, + }); const page = await service.page({}); @@ -840,10 +836,9 @@ describe('QqbotAccountService', () => { webuiOnline: true, }), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - { + const service = createAccountServiceWithNapcatRuntime({ + accountRepository, + bindingRepository: { createQueryBuilder: jest.fn(() => ({ addOrderBy: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), @@ -851,18 +846,17 @@ describe('QqbotAccountService', () => { orderBy: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), })), - } as any, - { + }, + containerRepository: { createQueryBuilder: jest.fn(() => ({ addSelect: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([container]), where: jest.fn().mockReturnThis(), })), - } as any, - napcatContainerService as any, - new ToolsService(), - ); + }, + containerService: napcatContainerService, + }); const page = await service.page({}); @@ -937,10 +931,9 @@ describe('QqbotAccountService', () => { webuiOnline: true, }), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - { + const service = createAccountServiceWithNapcatRuntime({ + accountRepository, + bindingRepository: { createQueryBuilder: jest.fn(() => ({ addOrderBy: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), @@ -948,18 +941,17 @@ describe('QqbotAccountService', () => { orderBy: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), })), - } as any, - { + }, + containerRepository: { createQueryBuilder: jest.fn(() => ({ addSelect: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([container]), where: jest.fn().mockReturnThis(), })), - } as any, - napcatContainerService as any, - new ToolsService(), - ); + }, + containerService: napcatContainerService, + }); const page = await service.page({}); @@ -1029,10 +1021,9 @@ describe('QqbotAccountService', () => { const systemNoticePublisher = { publishSystemNotice: jest.fn().mockResolvedValue(undefined), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - { + const service = createAccountServiceWithNapcatRuntime({ + accountRepository, + bindingRepository: { createQueryBuilder: jest.fn(() => ({ addOrderBy: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), @@ -1040,23 +1031,23 @@ describe('QqbotAccountService', () => { orderBy: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), })), - } as any, - { + }, + configService: { + get: jest.fn((key: string) => + key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '', + ), + }, + containerRepository: { createQueryBuilder: jest.fn(() => ({ andWhere: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([container]), where: jest.fn().mockReturnThis(), })), - } as any, - napcatContainerService as any, + }, + containerService: napcatContainerService, + systemNoticePublisher, toolsService, - systemNoticePublisher as any, - { - get: jest.fn((key: string) => - key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '', - ), - } as any, - ); + }); const result = await service.runOfflineWatchdog(); @@ -1132,10 +1123,9 @@ describe('QqbotAccountService', () => { const systemNoticePublisher = { publishSystemNotice: jest.fn().mockResolvedValue(undefined), }; - const service = new QqbotAccountService( - accountRepository as any, - {} as any, - { + const service = createAccountServiceWithNapcatRuntime({ + accountRepository, + bindingRepository: { createQueryBuilder: jest.fn(() => ({ addOrderBy: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), @@ -1143,23 +1133,23 @@ describe('QqbotAccountService', () => { orderBy: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), })), - } as any, - { + }, + configService: { + get: jest.fn((key: string) => + key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '', + ), + }, + containerRepository: { createQueryBuilder: jest.fn(() => ({ andWhere: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([container]), where: jest.fn().mockReturnThis(), })), - } as any, - napcatContainerService as any, + }, + containerService: napcatContainerService, + systemNoticePublisher, toolsService, - systemNoticePublisher as any, - { - get: jest.fn((key: string) => - key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '', - ), - } as any, - ); + }); await service.runOfflineWatchdog();