refactor: 强化QQBot第三期运行边界

This commit is contained in:
sunlei 2026-06-16 13:00:19 +08:00
parent 83eef0dffa
commit 2c6e845995
19 changed files with 2795 additions and 752 deletions

View File

@ -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<void>;
getLoginPassword(
account: Pick<QqbotAccount, 'napcatLoginPasswordSecret'>,
): string;
markOnline(
selfId: string,
clientRole: QqbotConnectionRole,
lastError?: null | string,
): Promise<void>;
markQqLoginOffline(selfId: string, lastError: string): Promise<void>;
publishOfflineNotice(
selfId: string,
offlineReason: string,
metadata: Record<string, unknown>,
): void;
};
export type QqbotAccountNapcatRuntimePort = {
appendRuntime(
accounts: QqbotAccount[],
options: { autoLogin?: boolean },
actions: QqbotAccountNapcatRuntimeActions,
): Promise<QqbotAccountListItem[]>;
removeAccountContainers(accountId: string): Promise<{
deletedContainers: number;
}>;
};

View File

@ -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<QqbotAccount>,
@InjectRepository(QqbotAccountAbility)
private readonly accountAbilityRepository: Repository<QqbotAccountAbility>,
@InjectRepository(NapcatAccountBinding)
private readonly accountNapcatRepository: Repository<NapcatAccountBinding>,
@InjectRepository(NapcatContainer)
private readonly napcatContainerRepository: Repository<NapcatContainer>,
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<QqbotAccountListItem[]> {
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<string, NapcatAccountBinding>();
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<string, NapcatContainer>();
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<QqbotNapcatRuntimeStatusSnapshot | undefined> {
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,

View File

@ -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<NapcatAccountBinding>,
@InjectRepository(NapcatContainer)
private readonly napcatContainerRepository: Repository<NapcatContainer>,
private readonly napcatContainerService: QqbotNapcatContainerService,
private readonly toolsService: ToolsService,
) {}
async appendRuntime(
accounts: QqbotAccount[],
options: { autoLogin?: boolean },
actions: QqbotAccountNapcatRuntimeActions,
): Promise<QqbotAccountListItem[]> {
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<string, NapcatAccountBinding>();
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<string, NapcatContainer>();
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<QqbotNapcatRuntimeStatusSnapshot | undefined> {
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;
}
}

View File

@ -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,
];

View File

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

View File

@ -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<string, QqbotPluginInstallation>;
inactivePluginKeys: Set<string>;
};
@Injectable()
export class QqbotPluginPlatformService {
export class QqbotPluginPlatformService
implements OnModuleInit, QqbotPluginExecutionPort
{
private readonly activeWorkers = new Map<string, QqbotPluginWorkerRuntime>();
private readonly activeWorkerContexts = new Map<
string,
ActiveWorkerContext
>();
private readonly activeWorkersByPluginKey = new Map<
string,
ActiveWorkerContext
>();
private readonly activeWorkerPluginAliases = new Map<string, string>();
constructor(
@InjectRepository(QqbotPlugin)
@ -104,8 +143,7 @@ export class QqbotPluginPlatformService {
@InjectRepository(QqbotPluginRuntimeEvent)
private readonly runtimeEventRepository: Repository<QqbotPluginRuntimeEvent>,
@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<string, unknown>,
packageHash: `builtin-${manifest.pluginKey}`,
pluginId: manifest.pluginKey,
version: manifest.version,
} as QqbotPluginVersion);
}
}
private async resolvePersistedPluginRuntimeState(): Promise<PersistedPluginRuntimeState> {
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<string>();
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) {

View File

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

View File

@ -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)
: []),
];
}
}

View File

@ -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<typeof createRepeaterPlugin>;
export type QqbotEventPluginPackage = {
bind(selfId: string): Promise<boolean> | 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<string, any> = {}) {
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<T extends Record<string, unknown>>(filePath: string) {
const worksheet = workbook.Sheets[sheetName];
return XLSX.utils.sheet_to_json<T>(worksheet);
}
function createWorkerHttpRequest(
input: Record<string, any> = {},
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,
};
}

View File

@ -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> | unknown;
dispose?: () => Promise<unknown> | 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<string, unknown>;
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<string, PendingRequest>();
private worker?: Worker;
constructor(
private readonly pluginLoader: QqbotBuiltinPluginPackageLoaderService,
@ -63,90 +76,121 @@ class QqbotBuiltinPluginWorkerDriver implements QqbotPluginWorkerDriver {
) {}
async request(message: QqbotPluginWorkerRequest): Promise<unknown> {
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<void> {
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<string, unknown>,
{},
);
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) {

View File

@ -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> | unknown;
dispose?: () => Promise<unknown> | unknown;
};
type RuntimeEventPlugin = {
bind(selfId: string): Promise<boolean> | boolean;
getDefinition(): {
key: string;
remark?: string;
triggerType?: string;
};
handleMessage(message: QqbotNormalizedMessage): Promise<boolean> | boolean;
unbind(selfId: string): Promise<boolean> | 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<string, string>;
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<string, unknown>();
const pendingHostCalls = new Map<string, PendingHostCall>();
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<string, unknown>,
{},
);
}
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<RepeaterManifest>('repeater');
return createRepeaterPlugin({
host: {
bindEventPlugin: (selfId, targetPluginKey) =>
callHost('bindEventPlugin', { pluginKey: targetPluginKey, selfId }),
getBoundEventPluginKeys: (selfId) =>
callHost<string[]>('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<Ff14MarketManifest>('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<FflogsManifest>('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<Uint8Array>('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<TManifest = QqbotPluginManifest>(
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<T = string>(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<TResult = any>(
method: string,
args: Record<string, unknown> = {},
) {
const requestId = `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return new Promise<TResult>((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<T = unknown>(filePath: string) {
return JSON.parse(readFileSync(filePath, 'utf8')) as T;
}
function readExcelRows<T extends Record<string, unknown>>(filePath: string) {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
return XLSX.utils.sheet_to_json<T>(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}`);
}

View File

@ -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<never>((_, reject) => {
const timer = setTimeout(async () => {
let timer: NodeJS.Timeout | undefined;
const promise = new Promise<never>((_, 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(

View File

@ -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 = [

View File

@ -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 = <T>(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', () => {

View File

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

View File

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

View File

@ -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', () => {

View File

@ -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: {

View File

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