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,
|
ToolsService,
|
||||||
} from '@/common';
|
} from '@/common';
|
||||||
import { AdminPasswordCryptoService } from '@/modules/admin/identity/auth/admin-password-crypto.service';
|
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 { QqbotAccountAbility } from '../../infrastructure/persistence/account/qqbot-account-ability.entity';
|
||||||
import { QqbotAccount } from '../../infrastructure/persistence/account/qqbot-account.entity';
|
import { QqbotAccount } from '../../infrastructure/persistence/account/qqbot-account.entity';
|
||||||
import type {
|
import type {
|
||||||
@ -16,9 +20,6 @@ import type {
|
|||||||
QqbotAccountQueryDto,
|
QqbotAccountQueryDto,
|
||||||
QqbotAccountUpdateDto,
|
QqbotAccountUpdateDto,
|
||||||
} from '../../contract/account/qqbot-account.dto';
|
} 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 {
|
import {
|
||||||
QQBOT_DEFAULT_PAGE_NO,
|
QQBOT_DEFAULT_PAGE_NO,
|
||||||
QQBOT_DEFAULT_PAGE_SIZE,
|
QQBOT_DEFAULT_PAGE_SIZE,
|
||||||
@ -27,12 +28,8 @@ import type {
|
|||||||
QqbotAccountAbilityType,
|
QqbotAccountAbilityType,
|
||||||
QqbotAccountListItem,
|
QqbotAccountListItem,
|
||||||
QqbotConnectionRole,
|
QqbotConnectionRole,
|
||||||
QqbotNapcatRuntimeStatusSnapshot,
|
|
||||||
} from '../../contract/qqbot.types';
|
} 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([
|
const INSECURE_ACCOUNT_SECRET_VALUES = new Set([
|
||||||
'change-me',
|
'change-me',
|
||||||
'kt-template-online-admin-token-secret',
|
'kt-template-online-admin-token-secret',
|
||||||
@ -45,13 +42,11 @@ export class QqbotAccountService {
|
|||||||
private readonly accountRepository: Repository<QqbotAccount>,
|
private readonly accountRepository: Repository<QqbotAccount>,
|
||||||
@InjectRepository(QqbotAccountAbility)
|
@InjectRepository(QqbotAccountAbility)
|
||||||
private readonly accountAbilityRepository: Repository<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,
|
private readonly toolsService: ToolsService,
|
||||||
@Optional()
|
@Optional()
|
||||||
|
@Inject(QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT)
|
||||||
|
private readonly napcatRuntime?: QqbotAccountNapcatRuntimePort,
|
||||||
|
@Optional()
|
||||||
@Inject(SYSTEM_NOTICE_PUBLISHER)
|
@Inject(SYSTEM_NOTICE_PUBLISHER)
|
||||||
private readonly systemNoticePublisher?: SystemNoticePublisher,
|
private readonly systemNoticePublisher?: SystemNoticePublisher,
|
||||||
@Optional()
|
@Optional()
|
||||||
@ -319,7 +314,9 @@ export class QqbotAccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const containerResult =
|
const containerResult =
|
||||||
await this.napcatContainerService.removeAccountContainers(id);
|
(await this.napcatRuntime?.removeAccountContainers(id)) || {
|
||||||
|
deletedContainers: 0,
|
||||||
|
};
|
||||||
await this.accountRepository.update(
|
await this.accountRepository.update(
|
||||||
{ id },
|
{ id },
|
||||||
{
|
{
|
||||||
@ -427,316 +424,24 @@ export class QqbotAccountService {
|
|||||||
accounts: QqbotAccount[],
|
accounts: QqbotAccount[],
|
||||||
options: { autoLogin?: boolean } = {},
|
options: { autoLogin?: boolean } = {},
|
||||||
): Promise<QqbotAccountListItem[]> {
|
): Promise<QqbotAccountListItem[]> {
|
||||||
if (accounts.length <= 0) return [];
|
if (!this.napcatRuntime) {
|
||||||
|
return accounts.map((account) => Object.assign(account, { napcat: null }));
|
||||||
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(
|
return this.napcatRuntime.appendRuntime(accounts, options, {
|
||||||
new Set(bindings.map((binding) => binding.containerId).filter(Boolean)),
|
clearQqLoginError: async (selfId) => {
|
||||||
);
|
await this.accountRepository.update({ selfId }, { lastError: null });
|
||||||
const containerMap = new Map<string, NapcatContainer>();
|
},
|
||||||
if (containerIds.length > 0) {
|
getLoginPassword: (account) => this.getNapcatLoginPassword(account),
|
||||||
const containerBuilder = this.napcatContainerRepository
|
markOnline: (selfId, clientRole, lastError) =>
|
||||||
.createQueryBuilder('container');
|
this.markOnline(selfId, clientRole, lastError),
|
||||||
containerBuilder.addSelect?.('container.webuiToken');
|
markQqLoginOffline: (selfId, lastError) =>
|
||||||
const containers = await containerBuilder
|
this.markQqLoginOffline(selfId, lastError),
|
||||||
.where('container.id IN (:...containerIds)', { containerIds })
|
publishOfflineNotice: (selfId, offlineReason, metadata) =>
|
||||||
.andWhere('container.isDeleted = :isDeleted', { isDeleted: false })
|
this.publishOfflineNotice(selfId, offlineReason, metadata),
|
||||||
.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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
private publishOfflineNotice(
|
||||||
selfId: string,
|
selfId: string,
|
||||||
offlineReason: 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 { ConfigModule } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AdminAuthGuardModule } from '@/modules/admin/identity/auth/admin-auth-guard.module';
|
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 { 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 { QqbotNapcatLoginService } from './application/login/qqbot-napcat-login.service';
|
||||||
import { QqbotNapcatWatchdogService } from './application/login/qqbot-napcat-watchdog.service';
|
import { QqbotNapcatWatchdogService } from './application/login/qqbot-napcat-watchdog.service';
|
||||||
import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller';
|
import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller';
|
||||||
@ -20,15 +22,20 @@ export const QQBOT_NAPCAT_CONTROLLERS = [QqbotNapcatLoginController];
|
|||||||
export const QQBOT_NAPCAT_PROVIDERS = [
|
export const QQBOT_NAPCAT_PROVIDERS = [
|
||||||
NapcatDeviceIdentityService,
|
NapcatDeviceIdentityService,
|
||||||
NapcatLoginStateStoreService,
|
NapcatLoginStateStoreService,
|
||||||
|
QqbotNapcatAccountRuntimeService,
|
||||||
QqbotNapcatContainerService,
|
QqbotNapcatContainerService,
|
||||||
QqbotNapcatLoginService,
|
QqbotNapcatLoginService,
|
||||||
QqbotNapcatWatchdogService,
|
QqbotNapcatWatchdogService,
|
||||||
|
{
|
||||||
|
provide: QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT,
|
||||||
|
useExisting: QqbotNapcatAccountRuntimeService,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const QQBOT_NAPCAT_EXPORTS = [
|
export const QQBOT_NAPCAT_EXPORTS = [
|
||||||
NapcatDeviceIdentityService,
|
NapcatDeviceIdentityService,
|
||||||
NapcatLoginStateStoreService,
|
NapcatLoginStateStoreService,
|
||||||
QqbotNapcatContainerService,
|
QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT,
|
||||||
QqbotNapcatLoginService,
|
QqbotNapcatLoginService,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -5,49 +5,25 @@ import type {
|
|||||||
QqbotPluginExecutionPort,
|
QqbotPluginExecutionPort,
|
||||||
QqbotPluginOperationLookup,
|
QqbotPluginOperationLookup,
|
||||||
} from '@/modules/qqbot/core/domain/plugin-execution.port';
|
} from '@/modules/qqbot/core/domain/plugin-execution.port';
|
||||||
import { QqbotEventPluginRegistryService } from './registry/qqbot-event-plugin-registry.service';
|
import { QqbotPluginPlatformService } from './plugin-platform.service';
|
||||||
import { QqbotPluginRegistryService } from './registry/qqbot-plugin-registry.service';
|
|
||||||
import { QqbotPluginArgumentParserService } from './argument/qqbot-plugin-argument-parser.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotPluginExecutionAdapter implements QqbotPluginExecutionPort {
|
export class QqbotPluginExecutionAdapter implements QqbotPluginExecutionPort {
|
||||||
constructor(
|
constructor(private readonly platformService: QqbotPluginPlatformService) {}
|
||||||
private readonly argumentParser: QqbotPluginArgumentParserService,
|
|
||||||
private readonly eventPluginRegistry: QqbotEventPluginRegistryService,
|
|
||||||
private readonly pluginRegistry: QqbotPluginRegistryService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async executeOperation(input: QqbotPluginExecutionInput) {
|
async executeOperation(input: QqbotPluginExecutionInput) {
|
||||||
const normalizedInput = await this.argumentParser.normalizeInput(input);
|
return this.platformService.executeOperation(input);
|
||||||
return this.pluginRegistry.execute(
|
|
||||||
input.pluginKey,
|
|
||||||
input.operationKey,
|
|
||||||
normalizedInput,
|
|
||||||
{
|
|
||||||
...(input.context || {}),
|
|
||||||
args: normalizedInput,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispatchEvent(input: QqbotPluginEventDispatchInput) {
|
async dispatchEvent(input: QqbotPluginEventDispatchInput) {
|
||||||
if (input.eventKey !== 'message') return false;
|
return this.platformService.dispatchEvent(input);
|
||||||
return this.eventPluginRegistry.dispatchMessage(input.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listActiveOperations() {
|
async listActiveOperations() {
|
||||||
return [
|
return this.platformService.listActiveOperations();
|
||||||
...this.pluginRegistry.listOperations(),
|
|
||||||
...this.eventPluginRegistry.listOperations(),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOperationByCommand(command: QqbotPluginOperationLookup) {
|
async getOperationByCommand(command: QqbotPluginOperationLookup) {
|
||||||
if (!command.pluginKey || !command.operationKey) return null;
|
return this.platformService.getOperationByCommand(command);
|
||||||
return (
|
|
||||||
this.pluginRegistry
|
|
||||||
.listOperations(command.pluginKey)
|
|
||||||
.find((operation) => operation.key === command.operationKey) || null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Between, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
import { Between, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||||
import { throwVbenError } from '@/common';
|
import { throwVbenError } from '@/common';
|
||||||
import {
|
import {
|
||||||
QQBOT_PLUGIN_EXECUTION_PORT,
|
|
||||||
type QqbotPluginEventDispatchInput,
|
type QqbotPluginEventDispatchInput,
|
||||||
type QqbotPluginExecutionInput,
|
type QqbotPluginExecutionInput,
|
||||||
type QqbotPluginExecutionPort,
|
type QqbotPluginExecutionPort,
|
||||||
|
type QqbotPluginOperationLookup,
|
||||||
} from '@/modules/qqbot/core/domain/plugin-execution.port';
|
} from '@/modules/qqbot/core/domain/plugin-execution.port';
|
||||||
|
import type {
|
||||||
|
QqbotPluginOperationSummary,
|
||||||
|
QqbotPluginTriggerMode,
|
||||||
|
} from '@/modules/qqbot/core/contract/qqbot.types';
|
||||||
import {
|
import {
|
||||||
parseQqbotPluginManifest,
|
parseQqbotPluginManifest,
|
||||||
type QqbotPluginManifest,
|
type QqbotPluginManifest,
|
||||||
} from '../domain/manifest';
|
} from '../domain/manifest';
|
||||||
import type { QqbotPluginWorkerRuntime } from '../infrastructure/integration/runtime';
|
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 { QqbotPluginPackageReaderService } from '../infrastructure/integration/package/plugin-package-reader.service';
|
||||||
import { QqbotEventPluginRegistryService } from './registry/qqbot-event-plugin-registry.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 { QqbotPluginRegistryService } from './registry/qqbot-plugin-registry.service';
|
||||||
import {
|
import {
|
||||||
QqbotPlugin,
|
QqbotPlugin,
|
||||||
@ -41,7 +48,13 @@ export type QqbotPluginRuntimeFactory = {
|
|||||||
version: QqbotPluginVersion,
|
version: QqbotPluginVersion,
|
||||||
): Pick<
|
): Pick<
|
||||||
QqbotPluginWorkerRuntime,
|
QqbotPluginWorkerRuntime,
|
||||||
'activate' | 'deactivate' | 'dispose' | 'health' | 'load'
|
| 'activate'
|
||||||
|
| 'deactivate'
|
||||||
|
| 'dispose'
|
||||||
|
| 'executeOperation'
|
||||||
|
| 'handleEvent'
|
||||||
|
| 'health'
|
||||||
|
| 'load'
|
||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -63,6 +76,8 @@ type ListOperationsQuery = {
|
|||||||
pageNo?: number | string;
|
pageNo?: number | string;
|
||||||
pageSize?: number | string;
|
pageSize?: number | string;
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
|
pluginKey?: string;
|
||||||
|
triggerMode?: QqbotPluginTriggerMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RuntimeEventQuery = {
|
type RuntimeEventQuery = {
|
||||||
@ -80,9 +95,33 @@ type UpdateConfigBody = {
|
|||||||
value?: unknown;
|
value?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ActiveWorkerContext = {
|
||||||
|
installationId: string;
|
||||||
|
manifest: QqbotPluginManifest;
|
||||||
|
pluginId: string;
|
||||||
|
pluginKey: string;
|
||||||
|
worker: QqbotPluginWorkerRuntime;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PersistedPluginRuntimeState = {
|
||||||
|
enabledInstallationsByPluginKey: Map<string, QqbotPluginInstallation>;
|
||||||
|
inactivePluginKeys: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotPluginPlatformService {
|
export class QqbotPluginPlatformService
|
||||||
|
implements OnModuleInit, QqbotPluginExecutionPort
|
||||||
|
{
|
||||||
private readonly activeWorkers = new Map<string, QqbotPluginWorkerRuntime>();
|
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(
|
constructor(
|
||||||
@InjectRepository(QqbotPlugin)
|
@InjectRepository(QqbotPlugin)
|
||||||
@ -104,8 +143,7 @@ export class QqbotPluginPlatformService {
|
|||||||
@InjectRepository(QqbotPluginRuntimeEvent)
|
@InjectRepository(QqbotPluginRuntimeEvent)
|
||||||
private readonly runtimeEventRepository: Repository<QqbotPluginRuntimeEvent>,
|
private readonly runtimeEventRepository: Repository<QqbotPluginRuntimeEvent>,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(QQBOT_PLUGIN_EXECUTION_PORT)
|
private readonly argumentParser?: QqbotPluginArgumentParserService,
|
||||||
private readonly pluginExecution?: QqbotPluginExecutionPort,
|
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(QQBOT_PLUGIN_RUNTIME_FACTORY)
|
@Inject(QQBOT_PLUGIN_RUNTIME_FACTORY)
|
||||||
private readonly runtimeFactory?: QqbotPluginRuntimeFactory,
|
private readonly runtimeFactory?: QqbotPluginRuntimeFactory,
|
||||||
@ -115,8 +153,14 @@ export class QqbotPluginPlatformService {
|
|||||||
private readonly eventPluginRegistry?: QqbotEventPluginRegistryService,
|
private readonly eventPluginRegistry?: QqbotEventPluginRegistryService,
|
||||||
@Optional()
|
@Optional()
|
||||||
private readonly packageReader?: QqbotPluginPackageReaderService,
|
private readonly packageReader?: QqbotPluginPackageReaderService,
|
||||||
|
@Optional()
|
||||||
|
private readonly builtinPluginLoader?: QqbotBuiltinPluginPackageLoaderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.startBuiltinWorkers();
|
||||||
|
}
|
||||||
|
|
||||||
async listInstallations() {
|
async listInstallations() {
|
||||||
return this.installationRepository.find();
|
return this.installationRepository.find();
|
||||||
}
|
}
|
||||||
@ -134,28 +178,37 @@ export class QqbotPluginPlatformService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listOperations(pluginId?: string) {
|
async listOperations(pluginId?: string) {
|
||||||
return this.operationRepository.find({
|
return this.listOperationSummaries({ pluginId });
|
||||||
where: pluginId ? { pluginId } : undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async pageOperations(query: ListOperationsQuery) {
|
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 pageNo = Number(query.pageNo || 1);
|
||||||
const pageSize = Number(query.pageSize || 10);
|
const pageSize = Number(query.pageSize || 10);
|
||||||
const safePageNo = Number.isFinite(pageNo) && pageNo > 0 ? pageNo : 1;
|
const safePageNo = Number.isFinite(pageNo) && pageNo > 0 ? pageNo : 1;
|
||||||
const safePageSize =
|
const safePageSize =
|
||||||
Number.isFinite(pageSize) && pageSize > 0 ? pageSize : 10;
|
Number.isFinite(pageSize) && pageSize > 0 ? pageSize : 10;
|
||||||
const [list, total] = await this.operationRepository.findAndCount({
|
const operations = await this.listOperationSummaries(query);
|
||||||
skip: (safePageNo - 1) * safePageSize,
|
const skip = (safePageNo - 1) * safePageSize;
|
||||||
take: safePageSize,
|
|
||||||
where: query.pluginId ? { pluginId: query.pluginId } : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list,
|
list: operations.slice(skip, skip + safePageSize),
|
||||||
pageNo: safePageNo,
|
pageNo: safePageNo,
|
||||||
pageSize: safePageSize,
|
pageSize: safePageSize,
|
||||||
total,
|
total: operations.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,7 +276,7 @@ export class QqbotPluginPlatformService {
|
|||||||
|
|
||||||
async disableInstallation(body: InstallationActionBody) {
|
async disableInstallation(body: InstallationActionBody) {
|
||||||
const installation = await this.requireInstallation(body);
|
const installation = await this.requireInstallation(body);
|
||||||
await this.stopWorker(installation.id);
|
await this.stopWorkersForInstallation(installation);
|
||||||
await this.refreshActiveRegistries(installation, false);
|
await this.refreshActiveRegistries(installation, false);
|
||||||
await this.updateInstallationRuntime(installation, 'disabled', 'stopped');
|
await this.updateInstallationRuntime(installation, 'disabled', 'stopped');
|
||||||
await this.recordRuntimeEvent(installation, 'disable-finished');
|
await this.recordRuntimeEvent(installation, 'disable-finished');
|
||||||
@ -244,16 +297,15 @@ export class QqbotPluginPlatformService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (previousWorker) {
|
if (previousWorker) {
|
||||||
this.activeWorkers.set(installation.id, previousWorker);
|
this.activeWorkers.set(installation.id, previousWorker);
|
||||||
await this.updateInstallationRuntime(installation, 'enabled', 'healthy');
|
await this.updateInstallationRuntime(
|
||||||
|
installation,
|
||||||
|
'enabled',
|
||||||
|
'healthy',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await this.recordRuntimeEvent(
|
await this.recordRuntimeEvent(installation, 'upgrade-failed', 'error', {
|
||||||
installation,
|
message: error instanceof Error ? error.message : `${error}`,
|
||||||
'upgrade-failed',
|
});
|
||||||
'error',
|
|
||||||
{
|
|
||||||
message: error instanceof Error ? error.message : `${error}`,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
await this.refreshActiveRegistries(installation, true);
|
await this.refreshActiveRegistries(installation, true);
|
||||||
@ -287,11 +339,24 @@ export class QqbotPluginPlatformService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async executeOperation(input: QqbotPluginExecutionInput) {
|
async executeOperation(input: QqbotPluginExecutionInput) {
|
||||||
if (!this.pluginExecution) {
|
const normalizedInput =
|
||||||
throwVbenError('插件执行器未初始化');
|
(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);
|
const pluginId = this.getPluginIdFromContext(input.context);
|
||||||
if (pluginId) {
|
if (pluginId) {
|
||||||
await this.runtimeEventRepository.save({
|
await this.runtimeEventRepository.save({
|
||||||
@ -313,11 +378,47 @@ export class QqbotPluginPlatformService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async dispatchEvent(input: QqbotPluginEventDispatchInput) {
|
async dispatchEvent(input: QqbotPluginEventDispatchInput) {
|
||||||
if (!this.pluginExecution) return false;
|
let handled = false;
|
||||||
const handled = await this.pluginExecution.dispatchEvent(input);
|
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;
|
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) {
|
async updateConfig(body: UpdateConfigBody) {
|
||||||
if (!body.pluginId || !body.configKey) {
|
if (!body.pluginId || !body.configKey) {
|
||||||
throwVbenError('请选择插件和配置项');
|
throwVbenError('请选择插件和配置项');
|
||||||
@ -341,7 +442,9 @@ export class QqbotPluginPlatformService {
|
|||||||
? { installationId: normalizedQuery.installationId }
|
? { installationId: normalizedQuery.installationId }
|
||||||
: {}),
|
: {}),
|
||||||
...(normalizedQuery.level ? { level: normalizedQuery.level } : {}),
|
...(normalizedQuery.level ? { level: normalizedQuery.level } : {}),
|
||||||
...(normalizedQuery.pluginId ? { pluginId: normalizedQuery.pluginId } : {}),
|
...(normalizedQuery.pluginId
|
||||||
|
? { pluginId: normalizedQuery.pluginId }
|
||||||
|
: {}),
|
||||||
...this.buildRuntimeEventTimeFilter(normalizedQuery),
|
...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) {
|
private async requireInstallation(body: InstallationActionBody) {
|
||||||
if (!body.id) throwVbenError('请选择插件安装记录');
|
if (!body.id) throwVbenError('请选择插件安装记录');
|
||||||
|
|
||||||
@ -446,10 +577,167 @@ export class QqbotPluginPlatformService {
|
|||||||
this.eventPluginRegistry?.setPluginActive(pluginKey, enabled);
|
this.eventPluginRegistry?.setPluginActive(pluginKey, enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPluginKey(pluginId: string) {
|
private async startBuiltinWorkers() {
|
||||||
const findOne = this.pluginRepository.findOne?.bind(
|
if (!this.runtimeFactory || !this.builtinPluginLoader) return;
|
||||||
this.pluginRepository,
|
|
||||||
|
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;
|
const plugin = findOne ? await findOne({ where: { id: pluginId } }) : null;
|
||||||
return plugin?.pluginKey || pluginId;
|
return plugin?.pluginKey || pluginId;
|
||||||
}
|
}
|
||||||
@ -461,16 +749,36 @@ export class QqbotPluginPlatformService {
|
|||||||
await worker.deactivate();
|
await worker.deactivate();
|
||||||
} finally {
|
} finally {
|
||||||
await worker.dispose();
|
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;
|
if (!this.runtimeFactory) return;
|
||||||
|
|
||||||
const version = await this.versionRepository.findOne({
|
const version =
|
||||||
where: { id: installation.versionId },
|
versionOverride ||
|
||||||
});
|
(await this.versionRepository.findOne({
|
||||||
|
where: { id: installation.versionId },
|
||||||
|
}));
|
||||||
if (!version) {
|
if (!version) {
|
||||||
throwVbenError('插件版本不存在,无法启动运行时');
|
throwVbenError('插件版本不存在,无法启动运行时');
|
||||||
}
|
}
|
||||||
@ -480,8 +788,9 @@ export class QqbotPluginPlatformService {
|
|||||||
await worker.load(version.manifestJson);
|
await worker.load(version.manifestJson);
|
||||||
await worker.activate();
|
await worker.activate();
|
||||||
await worker.health();
|
await worker.health();
|
||||||
this.activeWorkers.set(
|
await this.registerActiveWorker(
|
||||||
installation.id,
|
installation,
|
||||||
|
version,
|
||||||
worker as QqbotPluginWorkerRuntime,
|
worker as QqbotPluginWorkerRuntime,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -34,11 +34,13 @@ export class QqbotPluginRegistryService implements OnModuleInit {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
for (const plugin of this.builtinPluginLoader?.loadCommandPlugins() || []) {
|
|
||||||
await plugin.activate?.();
|
|
||||||
this.register(plugin);
|
|
||||||
}
|
|
||||||
await this.hydrateInactivePluginKeys();
|
await this.hydrateInactivePluginKeys();
|
||||||
|
for (const plugin of this.builtinPluginLoader?.loadCommandPlugins() || []) {
|
||||||
|
this.register(plugin);
|
||||||
|
if (this.isPluginActive(plugin.key)) {
|
||||||
|
await plugin.activate?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
register(plugin: QqbotIntegrationPlugin) {
|
register(plugin: QqbotIntegrationPlugin) {
|
||||||
@ -199,7 +201,9 @@ export class QqbotPluginRegistryService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isPluginActive(pluginKey: string) {
|
private isPluginActive(pluginKey: string) {
|
||||||
return !this.inactivePluginKeys.has(this.resolveCanonicalPluginKey(pluginKey));
|
return !this.inactivePluginKeys.has(
|
||||||
|
this.resolveCanonicalPluginKey(pluginKey),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveCanonicalPluginKey(pluginKey: string) {
|
private resolveCanonicalPluginKey(pluginKey: string) {
|
||||||
@ -213,10 +217,7 @@ export class QqbotPluginRegistryService implements OnModuleInit {
|
|||||||
this.pluginRepository.find(),
|
this.pluginRepository.find(),
|
||||||
this.installationRepository.find(),
|
this.installationRepository.find(),
|
||||||
]);
|
]);
|
||||||
for (const pluginKey of resolveInactivePluginKeys(
|
for (const pluginKey of resolveInactivePluginKeys(plugins, installations)) {
|
||||||
plugins,
|
|
||||||
installations,
|
|
||||||
)) {
|
|
||||||
this.setPluginActive(pluginKey, false);
|
this.setPluginActive(pluginKey, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,11 +9,11 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard';
|
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 { QqbotEventPluginRegistryService } from '../application/registry/qqbot-event-plugin-registry.service';
|
||||||
import { QqbotPluginRegistryService } from '../application/registry/qqbot-plugin-registry.service';
|
import { QqbotPluginRegistryService } from '../application/registry/qqbot-plugin-registry.service';
|
||||||
import type {
|
import type {
|
||||||
QqbotPluginOperationSummary,
|
|
||||||
QqbotPluginTriggerMode,
|
QqbotPluginTriggerMode,
|
||||||
} from '@/modules/qqbot/core/contract/qqbot.types';
|
} from '@/modules/qqbot/core/contract/qqbot.types';
|
||||||
|
|
||||||
@ -31,7 +31,7 @@ export class QqbotPluginController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly eventPluginRegistry: QqbotEventPluginRegistryService,
|
private readonly eventPluginRegistry: QqbotEventPluginRegistryService,
|
||||||
private readonly pluginRegistry: QqbotPluginRegistryService,
|
private readonly pluginRegistry: QqbotPluginRegistryService,
|
||||||
private readonly toolsService: ToolsService,
|
private readonly service: QqbotPluginPlatformService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('list')
|
@Get('list')
|
||||||
@ -71,7 +71,9 @@ export class QqbotPluginController {
|
|||||||
@Query('pluginKey') pluginKey?: string,
|
@Query('pluginKey') pluginKey?: string,
|
||||||
@Query('triggerMode') triggerMode?: QqbotPluginTriggerMode,
|
@Query('triggerMode') triggerMode?: QqbotPluginTriggerMode,
|
||||||
) {
|
) {
|
||||||
return vbenSuccess(this.listOperations(pluginKey, triggerMode));
|
return vbenSuccess(
|
||||||
|
await this.service.listOperationSummaries({ pluginKey, triggerMode }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('operation/page')
|
@Get('operation/page')
|
||||||
@ -85,15 +87,7 @@ export class QqbotPluginController {
|
|||||||
required: false,
|
required: false,
|
||||||
})
|
})
|
||||||
async operationPage(@Query() query: QqbotPluginOperationPageQuery) {
|
async operationPage(@Query() query: QqbotPluginOperationPageQuery) {
|
||||||
const { pageNo, pageSize, skip } = this.toolsService.getPageParams(query);
|
return vbenSuccess(await this.service.pageOperationSummaries(query));
|
||||||
const operations = this.listOperations(query.pluginKey, query.triggerMode);
|
|
||||||
|
|
||||||
return vbenSuccess({
|
|
||||||
list: operations.slice(skip, skip + pageSize),
|
|
||||||
pageNo,
|
|
||||||
pageSize,
|
|
||||||
total: operations.length,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('health')
|
@Get('health')
|
||||||
@ -159,17 +153,4 @@ export class QqbotPluginController {
|
|||||||
return !triggerMode || triggerMode === target;
|
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 createFflogsPlugin } from '@/modules/qqbot/plugins/fflogs/src';
|
||||||
import { createPlugin as createRepeaterPlugin } from '@/modules/qqbot/plugins/repeater/src';
|
import { createPlugin as createRepeaterPlugin } from '@/modules/qqbot/plugins/repeater/src';
|
||||||
|
|
||||||
export type RepeaterPluginPackage = ReturnType<
|
const BUILTIN_PLUGIN_KEYS = [
|
||||||
typeof createRepeaterPlugin
|
'bangdream',
|
||||||
>;
|
'ff14-market',
|
||||||
|
'fflogs',
|
||||||
|
'repeater',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type RepeaterPluginPackage = ReturnType<typeof createRepeaterPlugin>;
|
||||||
|
|
||||||
export type QqbotEventPluginPackage = {
|
export type QqbotEventPluginPackage = {
|
||||||
bind(selfId: string): Promise<boolean> | boolean;
|
bind(selfId: string): Promise<boolean> | boolean;
|
||||||
@ -77,6 +82,63 @@ export class QqbotBuiltinPluginPackageLoaderService {
|
|||||||
return [this.loadRepeaterPlugin()];
|
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 {
|
loadRepeaterPlugin(): RepeaterPluginPackage {
|
||||||
const manifest = this.loadManifest('repeater');
|
const manifest = this.loadManifest('repeater');
|
||||||
return createRepeaterPlugin({
|
return createRepeaterPlugin({
|
||||||
@ -253,3 +315,23 @@ function readExcelRows<T extends Record<string, unknown>>(filePath: string) {
|
|||||||
const worksheet = workbook.Sheets[sheetName];
|
const worksheet = workbook.Sheets[sheetName];
|
||||||
return XLSX.utils.sheet_to_json<T>(worksheet);
|
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 { Injectable } from '@nestjs/common';
|
||||||
|
import { Worker } from 'node:worker_threads';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { throwVbenError } from '@/common';
|
import { throwVbenError } from '@/common';
|
||||||
import {
|
import {
|
||||||
QqbotBuiltinPluginPackageLoaderService,
|
QqbotBuiltinPluginPackageLoaderService,
|
||||||
type QqbotEventPluginPackage,
|
|
||||||
} from '../package/builtin-plugin-package-loader.service';
|
} from '../package/builtin-plugin-package-loader.service';
|
||||||
import type {
|
|
||||||
QqbotIntegrationPlugin,
|
|
||||||
QqbotPluginOperation,
|
|
||||||
QqbotNormalizedMessage,
|
|
||||||
} from '@/modules/qqbot/core/contract/qqbot.types';
|
|
||||||
import type {
|
import type {
|
||||||
QqbotPluginRuntimeFactory,
|
QqbotPluginRuntimeFactory,
|
||||||
} from '@/modules/qqbot/plugin-platform/application/plugin-platform.service';
|
} from '@/modules/qqbot/plugin-platform/application/plugin-platform.service';
|
||||||
@ -24,11 +20,6 @@ import type {
|
|||||||
QqbotPluginWorkerRequest,
|
QqbotPluginWorkerRequest,
|
||||||
} from './worker-runtime.types';
|
} from './worker-runtime.types';
|
||||||
|
|
||||||
type RuntimeCommandPlugin = QqbotIntegrationPlugin & {
|
|
||||||
activate?: () => Promise<unknown> | unknown;
|
|
||||||
dispose?: () => Promise<unknown> | unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotBuiltinPluginWorkerRuntimeFactoryService
|
export class QqbotBuiltinPluginWorkerRuntimeFactoryService
|
||||||
implements QqbotPluginRuntimeFactory
|
implements QqbotPluginRuntimeFactory
|
||||||
@ -43,7 +34,7 @@ export class QqbotBuiltinPluginWorkerRuntimeFactoryService
|
|||||||
) {
|
) {
|
||||||
const pluginKey = getManifestPluginKey(version.manifestJson);
|
const pluginKey = getManifestPluginKey(version.manifestJson);
|
||||||
return new QqbotPluginWorkerRuntime(
|
return new QqbotPluginWorkerRuntime(
|
||||||
new QqbotBuiltinPluginWorkerDriver(this.pluginLoader, pluginKey),
|
new QqbotBuiltinPluginWorkerThreadDriver(this.pluginLoader, pluginKey),
|
||||||
{
|
{
|
||||||
defaultTimeoutMs: getDefaultRuntimeTimeout(version.manifestJson),
|
defaultTimeoutMs: getDefaultRuntimeTimeout(version.manifestJson),
|
||||||
installationId: installation.id,
|
installationId: installation.id,
|
||||||
@ -53,9 +44,31 @@ export class QqbotBuiltinPluginWorkerRuntimeFactoryService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class QqbotBuiltinPluginWorkerDriver implements QqbotPluginWorkerDriver {
|
type WorkerBridgeMessage =
|
||||||
private commandPlugin?: RuntimeCommandPlugin;
|
| {
|
||||||
private eventPlugin?: QqbotEventPluginPackage;
|
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(
|
constructor(
|
||||||
private readonly pluginLoader: QqbotBuiltinPluginPackageLoaderService,
|
private readonly pluginLoader: QqbotBuiltinPluginPackageLoaderService,
|
||||||
@ -63,90 +76,121 @@ class QqbotBuiltinPluginWorkerDriver implements QqbotPluginWorkerDriver {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async request(message: QqbotPluginWorkerRequest): Promise<unknown> {
|
async request(message: QqbotPluginWorkerRequest): Promise<unknown> {
|
||||||
switch (message.type) {
|
const worker = this.ensureWorker();
|
||||||
case 'load':
|
return new Promise((resolve, reject) => {
|
||||||
return this.load();
|
this.pendingRequests.set(message.correlationId, { reject, resolve });
|
||||||
case 'activate':
|
worker.postMessage({
|
||||||
await this.commandPlugin?.activate?.();
|
message,
|
||||||
return { ok: true };
|
requestId: message.correlationId,
|
||||||
case 'health':
|
type: 'request',
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispose(): Promise<void> {
|
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() {
|
private ensureWorker() {
|
||||||
this.commandPlugin = this.pluginLoader
|
if (this.worker) return this.worker;
|
||||||
.loadCommandPlugins()
|
|
||||||
.find((plugin) => plugin.key === this.pluginKey) as
|
|
||||||
| RuntimeCommandPlugin
|
|
||||||
| undefined;
|
|
||||||
this.eventPlugin = this.pluginLoader
|
|
||||||
.loadEventPlugins()
|
|
||||||
.find((plugin) => plugin.getDefinition().key === this.pluginKey);
|
|
||||||
|
|
||||||
if (!this.commandPlugin && !this.eventPlugin) {
|
const worker = new Worker(resolveWorkerEntrypoint(), {
|
||||||
throwVbenError(`QQBot 插件运行时不存在:${this.pluginKey}`);
|
execArgv: resolveWorkerExecArgv(),
|
||||||
}
|
workerData: {
|
||||||
|
pluginKey: this.pluginKey,
|
||||||
return {
|
},
|
||||||
ok: true,
|
});
|
||||||
pluginKey: this.pluginKey,
|
worker.on('message', (message: WorkerBridgeMessage) => {
|
||||||
triggerMode: this.commandPlugin ? 'command' : 'event',
|
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() {
|
private async handleWorkerMessage(message: WorkerBridgeMessage) {
|
||||||
if (this.commandPlugin?.healthCheck) {
|
if (message.type === 'response') {
|
||||||
return this.commandPlugin.healthCheck();
|
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 {
|
try {
|
||||||
message: this.eventPlugin.getDefinition().remark || '事件插件可用',
|
const result = await this.pluginLoader.handleWorkerHostCall(
|
||||||
status: 'healthy',
|
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) {
|
private rejectPending(error: Error) {
|
||||||
const operation = this.commandPlugin?.operations.find(
|
for (const pending of this.pendingRequests.values()) {
|
||||||
(item: QqbotPluginOperation) => item.key === message.operationKey,
|
pending.reject(error);
|
||||||
);
|
|
||||||
if (!operation) {
|
|
||||||
throwVbenError(`QQBot 插件能力不存在:${message.operationKey}`);
|
|
||||||
}
|
}
|
||||||
return operation.execute(
|
this.pendingRequests.clear();
|
||||||
(message.input || {}) as Record<string, unknown>,
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleEvent(message: QqbotPluginWorkerRequest) {
|
function resolveWorkerEntrypoint() {
|
||||||
if (!this.eventPlugin) {
|
const extension = __filename.endsWith('.ts') ? '.ts' : '.js';
|
||||||
return false;
|
return join(__dirname, `builtin-plugin-worker.thread${extension}`);
|
||||||
}
|
}
|
||||||
const definition = this.eventPlugin.getDefinition();
|
|
||||||
if (message.eventKey && message.eventKey !== definition.key) return false;
|
function resolveWorkerExecArgv() {
|
||||||
if (definition.triggerType !== 'message') return false;
|
if (!__filename.endsWith('.ts')) return [];
|
||||||
return this.eventPlugin.handleMessage(
|
return ['-r', 'ts-node/register', '-r', 'tsconfig-paths/register'];
|
||||||
(message.event || {}) as QqbotNormalizedMessage,
|
}
|
||||||
);
|
|
||||||
}
|
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) {
|
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);
|
requestPromise.catch(() => undefined);
|
||||||
|
|
||||||
|
const timeout = this.createTimeoutPromise(type, message, timeoutMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await Promise.race([
|
return await Promise.race([requestPromise, timeout.promise]);
|
||||||
requestPromise,
|
|
||||||
this.createTimeoutPromise(type, message, timeoutMs),
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof QqbotPluginRuntimeError) {
|
if (error instanceof QqbotPluginRuntimeError) {
|
||||||
throw error;
|
throw error;
|
||||||
@ -151,6 +150,8 @@ export class QqbotPluginWorkerRuntime {
|
|||||||
);
|
);
|
||||||
this.recordRuntimeEvent('worker-crash', runtimeError.safeSummary);
|
this.recordRuntimeEvent('worker-crash', runtimeError.safeSummary);
|
||||||
throw runtimeError;
|
throw runtimeError;
|
||||||
|
} finally {
|
||||||
|
timeout.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,8 +160,9 @@ export class QqbotPluginWorkerRuntime {
|
|||||||
message: QqbotPluginWorkerRequest,
|
message: QqbotPluginWorkerRequest,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
) {
|
) {
|
||||||
return new Promise<never>((_, reject) => {
|
let timer: NodeJS.Timeout | undefined;
|
||||||
const timer = setTimeout(async () => {
|
const promise = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(async () => {
|
||||||
this.status = 'failed';
|
this.status = 'failed';
|
||||||
const error = new QqbotPluginRuntimeError(
|
const error = new QqbotPluginRuntimeError(
|
||||||
'PLUGIN_WORKER_TIMEOUT',
|
'PLUGIN_WORKER_TIMEOUT',
|
||||||
@ -184,6 +186,12 @@ export class QqbotPluginWorkerRuntime {
|
|||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
timer.unref?.();
|
timer.unref?.();
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
clear: () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
},
|
||||||
|
promise,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private recordRuntimeEvent(
|
private recordRuntimeEvent(
|
||||||
|
|||||||
@ -76,6 +76,28 @@ describe('QQBot third-phase module boundaries', () => {
|
|||||||
expect(violations).toEqual([]);
|
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', () => {
|
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 source = readSource(join(qqbotRoot, 'core/qqbot-core.module.ts'));
|
||||||
const bannedSymbols = [
|
const bannedSymbols = [
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { getMetadataArgsStorage } from 'typeorm';
|
import { getMetadataArgsStorage } from 'typeorm';
|
||||||
import { AdminAuthGuardModule } from '../../../../src/modules/admin/identity/auth/admin-auth-guard.module';
|
import { AdminAuthGuardModule } from '../../../../src/modules/admin/identity/auth/admin-auth-guard.module';
|
||||||
import { AppModule } from '../../../../src/app.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 { QqbotCoreModule } from '../../../../src/modules/qqbot/core/qqbot-core.module';
|
||||||
import {
|
import {
|
||||||
QQBOT_NAPCAT_CONTROLLERS,
|
QQBOT_NAPCAT_CONTROLLERS,
|
||||||
@ -14,7 +15,9 @@ import {
|
|||||||
QQBOT_NAPCAT_PROVIDERS,
|
QQBOT_NAPCAT_PROVIDERS,
|
||||||
QqbotNapcatModule,
|
QqbotNapcatModule,
|
||||||
} from '../../../../src/modules/qqbot/napcat/qqbot-napcat.module';
|
} 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 { 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 {
|
import {
|
||||||
collectControllerRoutes,
|
collectControllerRoutes,
|
||||||
routeKey,
|
routeKey,
|
||||||
@ -26,7 +29,10 @@ const getModuleMetadata = <T>(moduleClass: unknown, key: string): T[] => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getNames = (items: unknown[]) =>
|
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 unwrapForwardRef = (item: unknown) => {
|
||||||
const maybeForwardRef = item as { forwardRef?: () => unknown };
|
const maybeForwardRef = item as { forwardRef?: () => unknown };
|
||||||
@ -102,6 +108,7 @@ describe('QQBot NapCat module ownership', () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'NapcatDeviceIdentityService',
|
'NapcatDeviceIdentityService',
|
||||||
'NapcatLoginStateStoreService',
|
'NapcatLoginStateStoreService',
|
||||||
|
'QqbotNapcatAccountRuntimeService',
|
||||||
'QqbotNapcatContainerService',
|
'QqbotNapcatContainerService',
|
||||||
'QqbotNapcatLoginService',
|
'QqbotNapcatLoginService',
|
||||||
'QqbotNapcatWatchdogService',
|
'QqbotNapcatWatchdogService',
|
||||||
@ -124,10 +131,19 @@ describe('QQBot NapCat module ownership', () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'NapcatDeviceIdentityService',
|
'NapcatDeviceIdentityService',
|
||||||
'NapcatLoginStateStoreService',
|
'NapcatLoginStateStoreService',
|
||||||
'QqbotNapcatContainerService',
|
'QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT',
|
||||||
'QqbotNapcatLoginService',
|
'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', () => {
|
it('keeps every registered NapCat entity mapped to the refactor-v3 schema', () => {
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { MODULE_METADATA } from '@nestjs/common/constants';
|
import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||||
import {
|
import { QQBOT_PLUGIN_RUNTIME_FACTORY } from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service';
|
||||||
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 { 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 { 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 { 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);
|
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', () => {
|
it('uses dedicated lifecycle use cases instead of direct status flips', () => {
|
||||||
const controller = readSource(
|
const controller = readSource(
|
||||||
'src/modules/qqbot/plugin-platform/contract/plugin-platform.controller.ts',
|
'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', () => {
|
it('activates workers and refreshes active registries during lifecycle transitions', () => {
|
||||||
const source = [
|
const source = [
|
||||||
readSource('src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts'),
|
readSource(
|
||||||
readSource('src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts'),
|
'src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts',
|
||||||
|
),
|
||||||
|
readSource(
|
||||||
|
'src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts',
|
||||||
|
),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const missingRuntimeSignals = [
|
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 () => {
|
it('loads, activates, health-checks, refreshes, and disposes workers during lifecycle transitions', async () => {
|
||||||
const manifest = {
|
const manifest = {
|
||||||
|
assets: [],
|
||||||
|
configSchema: {},
|
||||||
entry: 'src/index.ts',
|
entry: 'src/index.ts',
|
||||||
|
events: [],
|
||||||
|
legacyAliases: [],
|
||||||
|
migrations: [],
|
||||||
|
minApiSdkVersion: '1.0.0',
|
||||||
|
name: 'Demo Plugin',
|
||||||
|
operations: [],
|
||||||
|
permissions: [],
|
||||||
pluginKey: 'demo-plugin',
|
pluginKey: 'demo-plugin',
|
||||||
|
runtime: {
|
||||||
|
maxConcurrency: 1,
|
||||||
|
memoryMb: 128,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
workerType: 'node-worker',
|
||||||
|
},
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
};
|
};
|
||||||
const installation = {
|
const installation = {
|
||||||
@ -264,6 +292,744 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
|
|||||||
expect(worker.dispose).toHaveBeenCalled();
|
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 () => {
|
it('blocks command execution and event dispatch for inactive plugins', async () => {
|
||||||
const commandPlugin = {
|
const commandPlugin = {
|
||||||
key: 'demo-plugin',
|
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 () => {
|
it('keeps the previous active worker when upgrade health check fails', async () => {
|
||||||
const installation = {
|
const installation = {
|
||||||
id: 'installation-upgrade',
|
id: 'installation-upgrade',
|
||||||
@ -436,8 +1233,23 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
|
|||||||
const version = {
|
const version = {
|
||||||
id: 'version-upgrade',
|
id: 'version-upgrade',
|
||||||
manifestJson: {
|
manifestJson: {
|
||||||
|
assets: [],
|
||||||
|
configSchema: {},
|
||||||
entry: 'src/index.ts',
|
entry: 'src/index.ts',
|
||||||
|
events: [],
|
||||||
|
legacyAliases: [],
|
||||||
|
migrations: [],
|
||||||
|
minApiSdkVersion: '1.0.0',
|
||||||
|
name: 'Demo Plugin',
|
||||||
|
operations: [],
|
||||||
|
permissions: [],
|
||||||
pluginKey: 'demo-plugin',
|
pluginKey: 'demo-plugin',
|
||||||
|
runtime: {
|
||||||
|
maxConcurrency: 1,
|
||||||
|
memoryMb: 128,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
workerType: 'node-worker',
|
||||||
|
},
|
||||||
version: '0.2.0',
|
version: '0.2.0',
|
||||||
},
|
},
|
||||||
packageHash: 'hash',
|
packageHash: 'hash',
|
||||||
|
|||||||
@ -289,7 +289,9 @@ describe('QQBot plugin platform API contract', () => {
|
|||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(repositoryMocks.get(QqbotPluginRuntimeEvent)?.find).toHaveBeenCalledWith({
|
expect(
|
||||||
|
repositoryMocks.get(QqbotPluginRuntimeEvent)?.find,
|
||||||
|
).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
eventType: 'worker-crash',
|
eventType: 'worker-crash',
|
||||||
installationId: '2002',
|
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(driver.dispose).toHaveBeenCalled();
|
||||||
expect(runtime.status).toBe('failed');
|
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', () => {
|
describe('QQBot plugin SDK contract', () => {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { Test } from '@nestjs/testing';
|
|||||||
import * as request from 'supertest';
|
import * as request from 'supertest';
|
||||||
import { ToolsService } from '../../../../src/common';
|
import { ToolsService } from '../../../../src/common';
|
||||||
import { QqbotPluginController } from '../../../../src/modules/qqbot/plugin-platform/contract/qqbot-plugin.controller';
|
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 { 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 { QqbotPluginRegistryService } from '../../../../src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service';
|
||||||
import type { QqbotIntegrationPlugin } from '../../../../src/modules/qqbot/core/contract/qqbot.types';
|
import type { QqbotIntegrationPlugin } from '../../../../src/modules/qqbot/core/contract/qqbot.types';
|
||||||
@ -31,6 +32,30 @@ const createPlugin = (
|
|||||||
version: '1.0.0',
|
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', () => {
|
describe('QQBot plugin controller local HTTP smoke', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
|
||||||
@ -43,29 +68,7 @@ describe('QQBot plugin controller local HTTP smoke', () => {
|
|||||||
provide: QqbotPluginRegistryService,
|
provide: QqbotPluginRegistryService,
|
||||||
useValue: {
|
useValue: {
|
||||||
health: jest.fn(async () => []),
|
health: jest.fn(async () => []),
|
||||||
listOperations: jest.fn((pluginKey?: string) => {
|
listOperations: jest.fn(listOperationSummaries),
|
||||||
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',
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
listPlugins: jest.fn(() =>
|
listPlugins: jest.fn(() =>
|
||||||
[
|
[
|
||||||
createPlugin('bangdream', ['bangDream']),
|
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,
|
provide: QqbotEventPluginRegistryService,
|
||||||
useValue: {
|
useValue: {
|
||||||
|
|||||||
@ -1,5 +1,65 @@
|
|||||||
import { ToolsService } from '@/common';
|
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 { 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', () => {
|
describe('QqbotAccountService', () => {
|
||||||
it('stores NapCat login password as encrypted secret and never persists the transport field', async () => {
|
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),
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
|
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountService({
|
||||||
accountRepository as any,
|
accountRepository,
|
||||||
{} as any,
|
configService: {
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
toolsService,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
get: jest.fn((key: string) =>
|
get: jest.fn((key: string) =>
|
||||||
key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '',
|
key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '',
|
||||||
),
|
),
|
||||||
} as any,
|
},
|
||||||
{
|
passwordCryptoService: {
|
||||||
decryptPassword: jest.fn().mockReturnValue('qq-login-password'),
|
decryptPassword: jest.fn().mockReturnValue('qq-login-password'),
|
||||||
} as any,
|
},
|
||||||
);
|
toolsService,
|
||||||
|
});
|
||||||
|
|
||||||
await service.save({
|
await service.save({
|
||||||
encryptedLoginPassword: 'encrypted-payload',
|
encryptedLoginPassword: 'encrypted-payload',
|
||||||
@ -52,21 +107,15 @@ describe('QqbotAccountService', () => {
|
|||||||
findOne: jest.fn().mockResolvedValue(null),
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
|
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountService({
|
||||||
accountRepository as any,
|
accountRepository,
|
||||||
{} as any,
|
configService: {
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
new ToolsService(),
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
get: jest.fn().mockReturnValue(''),
|
get: jest.fn().mockReturnValue(''),
|
||||||
} as any,
|
},
|
||||||
{
|
passwordCryptoService: {
|
||||||
decryptPassword: jest.fn().mockReturnValue('qq-login-password'),
|
decryptPassword: jest.fn().mockReturnValue('qq-login-password'),
|
||||||
} as any,
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.save({
|
service.save({
|
||||||
@ -87,23 +136,17 @@ describe('QqbotAccountService', () => {
|
|||||||
findOne: jest.fn().mockResolvedValue(null),
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
|
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountService({
|
||||||
accountRepository as any,
|
accountRepository,
|
||||||
{} as any,
|
configService: {
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
new ToolsService(),
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
get: jest.fn((key: string) =>
|
get: jest.fn((key: string) =>
|
||||||
key === 'ADMIN_TOKEN_SECRET' ? 'change-me' : '',
|
key === 'ADMIN_TOKEN_SECRET' ? 'change-me' : '',
|
||||||
),
|
),
|
||||||
} as any,
|
},
|
||||||
{
|
passwordCryptoService: {
|
||||||
decryptPassword: jest.fn().mockReturnValue('qq-login-password'),
|
decryptPassword: jest.fn().mockReturnValue('qq-login-password'),
|
||||||
} as any,
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.save({
|
service.save({
|
||||||
@ -125,23 +168,18 @@ describe('QqbotAccountService', () => {
|
|||||||
findOne: jest.fn().mockResolvedValue(null),
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
|
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountService({
|
||||||
accountRepository as any,
|
accountRepository,
|
||||||
{} as any,
|
configService: {
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
toolsService,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
get: jest.fn((key: string) =>
|
get: jest.fn((key: string) =>
|
||||||
key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '',
|
key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '',
|
||||||
),
|
),
|
||||||
} as any,
|
},
|
||||||
{
|
passwordCryptoService: {
|
||||||
decryptPassword: jest.fn().mockReturnValue(' qq-login-password '),
|
decryptPassword: jest.fn().mockReturnValue(' qq-login-password '),
|
||||||
} as any,
|
},
|
||||||
);
|
toolsService,
|
||||||
|
});
|
||||||
|
|
||||||
await service.save({
|
await service.save({
|
||||||
encryptedLoginPassword: 'encrypted-payload',
|
encryptedLoginPassword: 'encrypted-payload',
|
||||||
@ -162,14 +200,10 @@ describe('QqbotAccountService', () => {
|
|||||||
findOne: jest.fn().mockResolvedValue({ id: 'account-1' }),
|
findOne: jest.fn().mockResolvedValue({ id: 'account-1' }),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountService({
|
||||||
accountRepository as any,
|
accountAbilityRepository: { update: jest.fn() },
|
||||||
{ update: jest.fn() } as any,
|
accountRepository,
|
||||||
{} as any,
|
});
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
new ToolsService(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await service.update({
|
await service.update({
|
||||||
id: 'account-1',
|
id: 'account-1',
|
||||||
@ -190,14 +224,7 @@ describe('QqbotAccountService', () => {
|
|||||||
const accountRepository = {
|
const accountRepository = {
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountService({ accountRepository });
|
||||||
accountRepository as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
new ToolsService(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await service.markOffline('1914728559');
|
await service.markOffline('1914728559');
|
||||||
|
|
||||||
@ -213,14 +240,7 @@ describe('QqbotAccountService', () => {
|
|||||||
const accountRepository = {
|
const accountRepository = {
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountService({ accountRepository });
|
||||||
accountRepository as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
new ToolsService(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await service.markOnline('1914728559', 'Universal');
|
await service.markOnline('1914728559', 'Universal');
|
||||||
|
|
||||||
@ -239,14 +259,7 @@ describe('QqbotAccountService', () => {
|
|||||||
const accountRepository = {
|
const accountRepository = {
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountService({ accountRepository });
|
||||||
accountRepository as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
new ToolsService(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await service.markOnline('1914728559', 'Universal', null);
|
await service.markOnline('1914728559', 'Universal', null);
|
||||||
|
|
||||||
@ -265,14 +278,7 @@ describe('QqbotAccountService', () => {
|
|||||||
const accountRepository = {
|
const accountRepository = {
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountService({ accountRepository });
|
||||||
accountRepository as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
new ToolsService(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await service.markOffline('1914728559', '错误'.repeat(300));
|
await service.markOffline('1914728559', '错误'.repeat(300));
|
||||||
|
|
||||||
@ -343,15 +349,15 @@ describe('QqbotAccountService', () => {
|
|||||||
const systemNoticePublisher = {
|
const systemNoticePublisher = {
|
||||||
publishSystemNotice: jest.fn().mockResolvedValue('notice-1'),
|
publishSystemNotice: jest.fn().mockResolvedValue('notice-1'),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountServiceWithNapcatRuntime({
|
||||||
accountRepository as any,
|
accountRepository,
|
||||||
{} as any,
|
bindingRepository: { createQueryBuilder: jest.fn(createBindingBuilder) },
|
||||||
{ createQueryBuilder: jest.fn(createBindingBuilder) } as any,
|
containerRepository: {
|
||||||
{ createQueryBuilder: jest.fn(createContainerBuilder) } as any,
|
createQueryBuilder: jest.fn(createContainerBuilder),
|
||||||
napcatContainerService as any,
|
},
|
||||||
new ToolsService(),
|
containerService: napcatContainerService,
|
||||||
systemNoticePublisher as any,
|
systemNoticePublisher,
|
||||||
);
|
});
|
||||||
|
|
||||||
const page = await service.page({});
|
const page = await service.page({});
|
||||||
|
|
||||||
@ -425,10 +431,9 @@ describe('QqbotAccountService', () => {
|
|||||||
const napcatContainerService = {
|
const napcatContainerService = {
|
||||||
detectRuntimeOffline: jest.fn(),
|
detectRuntimeOffline: jest.fn(),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountServiceWithNapcatRuntime({
|
||||||
accountRepository as any,
|
accountRepository,
|
||||||
{} as any,
|
bindingRepository: {
|
||||||
{
|
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
addOrderBy: jest.fn().mockReturnThis(),
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
@ -436,17 +441,16 @@ describe('QqbotAccountService', () => {
|
|||||||
orderBy: jest.fn().mockReturnThis(),
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
{
|
containerRepository: {
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
getMany: jest.fn().mockResolvedValue([container]),
|
getMany: jest.fn().mockResolvedValue([container]),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
napcatContainerService as any,
|
containerService: napcatContainerService,
|
||||||
new ToolsService(),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const page = await service.page({});
|
const page = await service.page({});
|
||||||
|
|
||||||
@ -510,10 +514,9 @@ describe('QqbotAccountService', () => {
|
|||||||
webuiOnline: true,
|
webuiOnline: true,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountServiceWithNapcatRuntime({
|
||||||
accountRepository as any,
|
accountRepository,
|
||||||
{} as any,
|
bindingRepository: {
|
||||||
{
|
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
addOrderBy: jest.fn().mockReturnThis(),
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
@ -521,18 +524,17 @@ describe('QqbotAccountService', () => {
|
|||||||
orderBy: jest.fn().mockReturnThis(),
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
{
|
containerRepository: {
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
addSelect: jest.fn().mockReturnThis(),
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
getMany: jest.fn().mockResolvedValue([container]),
|
getMany: jest.fn().mockResolvedValue([container]),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
napcatContainerService as any,
|
containerService: napcatContainerService,
|
||||||
new ToolsService(),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const page = await service.page({});
|
const page = await service.page({});
|
||||||
|
|
||||||
@ -593,10 +595,9 @@ describe('QqbotAccountService', () => {
|
|||||||
})),
|
})),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountServiceWithNapcatRuntime({
|
||||||
accountRepository as any,
|
accountRepository,
|
||||||
{} as any,
|
bindingRepository: {
|
||||||
{
|
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
addOrderBy: jest.fn().mockReturnThis(),
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
@ -604,18 +605,17 @@ describe('QqbotAccountService', () => {
|
|||||||
orderBy: jest.fn().mockReturnThis(),
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
{
|
containerRepository: {
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
addSelect: jest.fn().mockReturnThis(),
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
getMany: jest.fn().mockResolvedValue([container]),
|
getMany: jest.fn().mockResolvedValue([container]),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
{} as any,
|
containerService: {},
|
||||||
new ToolsService(),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const page = await service.page({});
|
const page = await service.page({});
|
||||||
|
|
||||||
@ -670,10 +670,9 @@ describe('QqbotAccountService', () => {
|
|||||||
})),
|
})),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountServiceWithNapcatRuntime({
|
||||||
accountRepository as any,
|
accountRepository,
|
||||||
{} as any,
|
bindingRepository: {
|
||||||
{
|
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
addOrderBy: jest.fn().mockReturnThis(),
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
@ -681,18 +680,17 @@ describe('QqbotAccountService', () => {
|
|||||||
orderBy: jest.fn().mockReturnThis(),
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
{
|
containerRepository: {
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
addSelect: jest.fn().mockReturnThis(),
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
getMany: jest.fn().mockResolvedValue([container]),
|
getMany: jest.fn().mockResolvedValue([container]),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
{} as any,
|
containerService: {},
|
||||||
new ToolsService(),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const page = await service.page({});
|
const page = await service.page({});
|
||||||
|
|
||||||
@ -752,10 +750,9 @@ describe('QqbotAccountService', () => {
|
|||||||
const systemNoticePublisher = {
|
const systemNoticePublisher = {
|
||||||
publishSystemNotice: jest.fn().mockResolvedValue(undefined),
|
publishSystemNotice: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountServiceWithNapcatRuntime({
|
||||||
accountRepository as any,
|
accountRepository,
|
||||||
{} as any,
|
bindingRepository: {
|
||||||
{
|
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
addOrderBy: jest.fn().mockReturnThis(),
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
@ -763,18 +760,17 @@ describe('QqbotAccountService', () => {
|
|||||||
orderBy: jest.fn().mockReturnThis(),
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
{
|
containerRepository: {
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
getMany: jest.fn().mockResolvedValue([container]),
|
getMany: jest.fn().mockResolvedValue([container]),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
napcatContainerService as any,
|
containerService: napcatContainerService,
|
||||||
new ToolsService(),
|
systemNoticePublisher,
|
||||||
systemNoticePublisher as any,
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const page = await service.page({});
|
const page = await service.page({});
|
||||||
|
|
||||||
@ -840,10 +836,9 @@ describe('QqbotAccountService', () => {
|
|||||||
webuiOnline: true,
|
webuiOnline: true,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountServiceWithNapcatRuntime({
|
||||||
accountRepository as any,
|
accountRepository,
|
||||||
{} as any,
|
bindingRepository: {
|
||||||
{
|
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
addOrderBy: jest.fn().mockReturnThis(),
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
@ -851,18 +846,17 @@ describe('QqbotAccountService', () => {
|
|||||||
orderBy: jest.fn().mockReturnThis(),
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
{
|
containerRepository: {
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
addSelect: jest.fn().mockReturnThis(),
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
getMany: jest.fn().mockResolvedValue([container]),
|
getMany: jest.fn().mockResolvedValue([container]),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
napcatContainerService as any,
|
containerService: napcatContainerService,
|
||||||
new ToolsService(),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const page = await service.page({});
|
const page = await service.page({});
|
||||||
|
|
||||||
@ -937,10 +931,9 @@ describe('QqbotAccountService', () => {
|
|||||||
webuiOnline: true,
|
webuiOnline: true,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountServiceWithNapcatRuntime({
|
||||||
accountRepository as any,
|
accountRepository,
|
||||||
{} as any,
|
bindingRepository: {
|
||||||
{
|
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
addOrderBy: jest.fn().mockReturnThis(),
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
@ -948,18 +941,17 @@ describe('QqbotAccountService', () => {
|
|||||||
orderBy: jest.fn().mockReturnThis(),
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
{
|
containerRepository: {
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
addSelect: jest.fn().mockReturnThis(),
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
getMany: jest.fn().mockResolvedValue([container]),
|
getMany: jest.fn().mockResolvedValue([container]),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
napcatContainerService as any,
|
containerService: napcatContainerService,
|
||||||
new ToolsService(),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const page = await service.page({});
|
const page = await service.page({});
|
||||||
|
|
||||||
@ -1029,10 +1021,9 @@ describe('QqbotAccountService', () => {
|
|||||||
const systemNoticePublisher = {
|
const systemNoticePublisher = {
|
||||||
publishSystemNotice: jest.fn().mockResolvedValue(undefined),
|
publishSystemNotice: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountServiceWithNapcatRuntime({
|
||||||
accountRepository as any,
|
accountRepository,
|
||||||
{} as any,
|
bindingRepository: {
|
||||||
{
|
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
addOrderBy: jest.fn().mockReturnThis(),
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
@ -1040,23 +1031,23 @@ describe('QqbotAccountService', () => {
|
|||||||
orderBy: jest.fn().mockReturnThis(),
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
where: 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(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
getMany: jest.fn().mockResolvedValue([container]),
|
getMany: jest.fn().mockResolvedValue([container]),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
napcatContainerService as any,
|
containerService: napcatContainerService,
|
||||||
|
systemNoticePublisher,
|
||||||
toolsService,
|
toolsService,
|
||||||
systemNoticePublisher as any,
|
});
|
||||||
{
|
|
||||||
get: jest.fn((key: string) =>
|
|
||||||
key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '',
|
|
||||||
),
|
|
||||||
} as any,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await service.runOfflineWatchdog();
|
const result = await service.runOfflineWatchdog();
|
||||||
|
|
||||||
@ -1132,10 +1123,9 @@ describe('QqbotAccountService', () => {
|
|||||||
const systemNoticePublisher = {
|
const systemNoticePublisher = {
|
||||||
publishSystemNotice: jest.fn().mockResolvedValue(undefined),
|
publishSystemNotice: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const service = new QqbotAccountService(
|
const service = createAccountServiceWithNapcatRuntime({
|
||||||
accountRepository as any,
|
accountRepository,
|
||||||
{} as any,
|
bindingRepository: {
|
||||||
{
|
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
addOrderBy: jest.fn().mockReturnThis(),
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
@ -1143,23 +1133,23 @@ describe('QqbotAccountService', () => {
|
|||||||
orderBy: jest.fn().mockReturnThis(),
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
where: 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(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
getMany: jest.fn().mockResolvedValue([container]),
|
getMany: jest.fn().mockResolvedValue([container]),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
} as any,
|
},
|
||||||
napcatContainerService as any,
|
containerService: napcatContainerService,
|
||||||
|
systemNoticePublisher,
|
||||||
toolsService,
|
toolsService,
|
||||||
systemNoticePublisher as any,
|
});
|
||||||
{
|
|
||||||
get: jest.fn((key: string) =>
|
|
||||||
key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '',
|
|
||||||
),
|
|
||||||
} as any,
|
|
||||||
);
|
|
||||||
|
|
||||||
await service.runOfflineWatchdog();
|
await service.runOfflineWatchdog();
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user