refactor: 强化QQBot第三期运行边界
This commit is contained in:
parent
83eef0dffa
commit
2c6e845995
@ -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;
|
||||
}>;
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
: []),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user