From b1e346bfd7e4edea90edf8fd5e55e6d37a830b6c Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 24 Jul 2026 07:09:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81QQBot=E5=A4=9A?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E6=B6=88=E6=81=AF=E5=8F=91=E5=B8=83=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qqbot-account-message-push.service.ts | 585 ++++++++++++++++++ .../qqbot-message-target-options.service.ts | 107 ++++ src/modules/qqbot/core/qqbot-core.module.ts | 6 + ...qqbot-account-message-push.service.spec.ts | 163 +++++ ...bot-message-target-options.service.spec.ts | 101 +++ 5 files changed, 962 insertions(+) create mode 100644 src/modules/qqbot/core/application/message-push/qqbot-account-message-push.service.ts create mode 100644 src/modules/qqbot/core/application/message-push/qqbot-message-target-options.service.ts create mode 100644 test/modules/qqbot/message-push/qqbot-account-message-push.service.spec.ts create mode 100644 test/modules/qqbot/message-push/qqbot-message-target-options.service.spec.ts diff --git a/src/modules/qqbot/core/application/message-push/qqbot-account-message-push.service.ts b/src/modules/qqbot/core/application/message-push/qqbot-account-message-push.service.ts new file mode 100644 index 0000000..3a7c69b --- /dev/null +++ b/src/modules/qqbot/core/application/message-push/qqbot-account-message-push.service.ts @@ -0,0 +1,585 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { throwVbenError } from '@/common'; +import { Repository, type EntityManager } from 'typeorm'; +import { + SystemMessageContractError, + type QqbotMessagePublishBindingInput, + type QqbotMessagePublishBindingView, + type QqbotMessagePublishTargetInput, + type QqbotMessagePublishTargetView, + type QqbotMessagePushTargetType, +} from '../../contract/message-push/qqbot-message-push.types'; +import { QqbotAccountService } from '../account/qqbot-account.service'; +import { QqbotMessagePublishBinding } from '../../infrastructure/persistence/message-push/qqbot-message-publish-binding.entity'; +import { QqbotMessagePublishTarget } from '../../infrastructure/persistence/message-push/qqbot-message-publish-target.entity'; +import { QqbotMessageSubscription } from '../../infrastructure/persistence/message-push/qqbot-message-subscription.entity'; +import { QqbotMessageTemplate } from '../../infrastructure/persistence/message-push/qqbot-message-template.entity'; +import { QqbotMessageSubscriptionService } from './qqbot-message-subscription.service'; +import { QqbotMessageTemplateService } from './qqbot-message-template.service'; +import { SystemMessageSourceRegistry } from './system-message-source.registry'; +import { SystemMessageTemplateRendererService } from './system-message-template-renderer.service'; + +const TARGET_ID_PATTERN = /^[1-9]\d{4,19}$/; + +type NormalizedTarget = { + targetId: string; + targetName: null | string; + targetType: QqbotMessagePushTargetType; +}; + +/** Manages one QQBot account's publish bindings and their atomic target snapshots. */ +@Injectable() +export class QqbotAccountMessagePushService { + /** Initializes account-scoped binding persistence and the shared dependency gates. */ + constructor( + @InjectRepository(QqbotMessagePublishBinding) + private readonly bindingRepository: Repository, + @InjectRepository(QqbotMessagePublishTarget) + private readonly targetRepository: Repository, + @InjectRepository(QqbotMessageSubscription) + private readonly subscriptionRepository: Repository, + @InjectRepository(QqbotMessageTemplate) + private readonly templateRepository: Repository, + private readonly accountService: QqbotAccountService, + private readonly subscriptionService: QqbotMessageSubscriptionService, + private readonly templateService: QqbotMessageTemplateService, + private readonly sourceRegistry: SystemMessageSourceRegistry, + private readonly renderer: SystemMessageTemplateRendererService, + ) {} + + /** Lists active bindings belonging only to the requested account in deterministic order. */ + async listBindings( + selfId: string, + ): Promise { + const account = await this.requireAccount(selfId); + const bindings = await this.bindingRepository.find({ + order: { createTime: 'ASC', id: 'ASC' }, + where: { accountId: String(account.id), isDeleted: false }, + }); + return Promise.all(bindings.map((binding) => this.toView(binding))); + } + + /** Creates or revives the account/subscription binding and replaces its targets atomically. */ + async createBinding( + selfId: string, + input: QqbotMessagePublishBindingInput, + ): Promise { + const account = await this.requireAccount(selfId); + const normalizedTargets = this.normalizeTargets(input.targets); + try { + const binding = await this.bindingRepository.manager.transaction( + async (manager) => { + const bindings = manager.getRepository(QqbotMessagePublishBinding); + const activeKey = this.bindingActiveKey( + account.id, + input.subscriptionId, + ); + const active = await bindings.findOne({ + where: { activeKey, isDeleted: false }, + }); + if (active) this.throwNaturalKeyConflict(); + const historicalCandidate = await bindings.findOne({ + order: { updateTime: 'DESC', id: 'DESC' }, + where: { + accountId: String(account.id), + isDeleted: true, + subscriptionId: input.subscriptionId, + }, + }); + const subscription = + await this.subscriptionService.requireAvailableForBinding( + manager, + input.subscriptionId, + input.enabled, + ); + await this.templateService.requireAvailableForBinding( + manager, + input.templateId, + subscription.sourceKey, + input.enabled, + ); + let binding: QqbotMessagePublishBinding; + if (historicalCandidate) { + const historical = await bindings.findOne({ + lock: { mode: 'pessimistic_write' }, + where: { id: historicalCandidate.id, isDeleted: true }, + }); + if (historical) { + Object.assign( + historical, + this.toBindingFields(account, input, activeKey), + { + isDeleted: false, + }, + ); + binding = await bindings.save(historical); + } else { + binding = await bindings.save( + bindings.create({ + ...this.toBindingFields(account, input, activeKey), + isDeleted: false, + }), + ); + } + } else { + binding = await bindings.save( + bindings.create({ + ...this.toBindingFields(account, input, activeKey), + isDeleted: false, + }), + ); + } + await this.synchronizeTargets(manager, binding, normalizedTargets); + return binding; + }, + ); + return this.toView(binding); + } catch (error) { + if (this.isDuplicateKeyError(error)) this.throwNaturalKeyConflict(); + throw error; + } + } + + /** Replaces an account-scoped binding's dependencies, enabled switch, and targets atomically. */ + async updateBinding( + selfId: string, + id: string, + input: QqbotMessagePublishBindingInput, + ): Promise { + const account = await this.requireAccount(selfId); + const normalizedTargets = this.normalizeTargets(input.targets); + try { + const binding = await this.bindingRepository.manager.transaction( + async (manager) => { + const bindings = manager.getRepository(QqbotMessagePublishBinding); + const snapshot = await bindings.findOne({ + where: { accountId: String(account.id), id, isDeleted: false }, + }); + if (!snapshot) this.throwBindingUnavailable(); + const subscription = + await this.subscriptionService.requireAvailableForBinding( + manager, + input.subscriptionId, + input.enabled, + ); + await this.templateService.requireAvailableForBinding( + manager, + input.templateId, + subscription.sourceKey, + input.enabled, + ); + const current = await this.findBindingForWrite( + bindings, + account.id, + id, + ); + this.assertStableBindingSnapshot(current, snapshot!); + const activeKey = this.bindingActiveKey( + account.id, + input.subscriptionId, + ); + const conflict = await bindings.findOne({ + where: { activeKey, isDeleted: false }, + }); + if (conflict && String(conflict.id) !== String(current.id)) { + this.throwNaturalKeyConflict(); + } + Object.assign( + current, + this.toBindingFields(account, input, activeKey), + ); + const saved = await bindings.save(current); + await this.synchronizeTargets(manager, saved, normalizedTargets); + return saved; + }, + ); + return this.toView(binding); + } catch (error) { + if (this.isDuplicateKeyError(error)) this.throwNaturalKeyConflict(); + throw error; + } + } + + /** Changes a binding's user switch while applying both dependency gates in lock order. */ + async setBindingEnabled( + selfId: string, + id: string, + enabled: boolean, + ): Promise { + const account = await this.requireAccount(selfId); + const binding = await this.bindingRepository.manager.transaction( + async (manager) => { + const bindings = manager.getRepository(QqbotMessagePublishBinding); + const snapshot = await bindings.findOne({ + where: { accountId: String(account.id), id, isDeleted: false }, + }); + if (!snapshot) this.throwBindingUnavailable(); + const subscription = + await this.subscriptionService.requireAvailableForBinding( + manager, + snapshot!.subscriptionId, + enabled, + ); + await this.templateService.requireAvailableForBinding( + manager, + snapshot!.templateId, + subscription.sourceKey, + enabled, + ); + const current = await this.findBindingForWrite( + bindings, + account.id, + id, + ); + this.assertStableBindingSnapshot(current, snapshot!); + current.enabled = enabled; + return bindings.save(current); + }, + ); + return this.toView(binding); + } + + /** Soft-deletes one account-scoped binding and every active target in the same transaction. */ + async removeBinding(selfId: string, id: string): Promise { + const account = await this.requireAccount(selfId); + await this.bindingRepository.manager.transaction(async (manager) => { + const bindings = manager.getRepository(QqbotMessagePublishBinding); + const targets = manager.getRepository(QqbotMessagePublishTarget); + const binding = await this.findBindingForWrite(bindings, account.id, id); + const activeTargets = await targets.find({ + lock: { mode: 'pessimistic_write' }, + where: { bindingId: String(binding.id), isDeleted: false }, + }); + activeTargets.forEach((target) => { + target.activeKey = null; + target.enabled = false; + target.isDeleted = true; + }); + if (activeTargets.length > 0) await targets.save(activeTargets); + binding.activeKey = null; + binding.enabled = false; + binding.isDeleted = true; + await bindings.save(binding); + }); + return true; + } + + /** Validates, trims, and deduplicates targets without converting IDs to numbers. */ + private normalizeTargets( + inputs: QqbotMessagePublishTargetInput[], + ): NormalizedTarget[] { + const targets = new Map(); + inputs.forEach((input) => { + const targetId = String(input.targetId ?? '').trim(); + if (!TARGET_ID_PATTERN.test(targetId)) { + throw new SystemMessageContractError('invalid_target_id'); + } + if (input.targetType !== 'group' && input.targetType !== 'private') { + throw new SystemMessageContractError('invalid_target_type'); + } + const target: NormalizedTarget = { + targetId, + targetName: input.targetName?.trim() || null, + targetType: input.targetType, + }; + targets.set(`${target.targetType}:${target.targetId}`, target); + }); + return [...targets.values()].sort((left, right) => + this.targetActiveKey('0', left).localeCompare( + this.targetActiveKey('0', right), + ), + ); + } + + /** Retains, revives, creates, and soft-deletes targets while holding the binding lock. */ + private async synchronizeTargets( + manager: EntityManager, + binding: QqbotMessagePublishBinding, + selected: NormalizedTarget[], + ): Promise { + const targets = manager.getRepository(QqbotMessagePublishTarget); + const existing = await targets.find({ + lock: { mode: 'pessimistic_write' }, + order: { createTime: 'ASC', id: 'ASC' }, + where: { bindingId: String(binding.id) }, + }); + const selectedByKey = new Map( + selected.map((target) => [ + this.targetActiveKey(binding.id, target), + target, + ]), + ); + const historicalByKey = new Map( + existing + .filter((target) => target.isDeleted) + .map((target) => [this.targetActiveKey(binding.id, target), target]), + ); + const activeByKey = new Map( + existing + .filter((target) => !target.isDeleted) + .map((target) => [this.targetActiveKey(binding.id, target), target]), + ); + const saves: QqbotMessagePublishTarget[] = []; + selectedByKey.forEach((target, activeKey) => { + const current = + activeByKey.get(activeKey) ?? historicalByKey.get(activeKey); + if (current) { + Object.assign(current, { + activeKey, + enabled: true, + isDeleted: false, + targetName: target.targetName, + }); + saves.push(current); + } else { + saves.push( + targets.create({ + activeKey, + bindingId: String(binding.id), + enabled: true, + isDeleted: false, + targetId: target.targetId, + targetName: target.targetName, + targetType: target.targetType, + }), + ); + } + }); + activeByKey.forEach((target, activeKey) => { + if (selectedByKey.has(activeKey)) return; + Object.assign(target, { + activeKey: null, + enabled: false, + isDeleted: true, + }); + saves.push(target); + }); + if (saves.length > 0) await targets.save(saves); + } + + /** Builds a detached binding view using live dependency availability and active targets only. */ + private async toView( + binding: QqbotMessagePublishBinding, + ): Promise { + const [subscription, template, targets] = await Promise.all([ + this.subscriptionRepository.findOne({ + where: { id: binding.subscriptionId }, + }), + this.templateRepository.findOne({ where: { id: binding.templateId } }), + this.targetRepository.find({ + order: { createTime: 'ASC', id: 'ASC' }, + where: { bindingId: String(binding.id), isDeleted: false }, + }), + ]); + const state = await this.inspectAvailability(subscription, template); + return { + available: state.available, + createTime: String(binding.createTime), + enabled: binding.enabled, + id: String(binding.id), + invalidReasonCode: state.invalidReasonCode, + sourceKey: subscription?.sourceKey ?? '', + sourceName: state.sourceName, + subscriptionId: String(binding.subscriptionId), + subscriptionName: subscription?.name ?? '', + targets: targets.map((target) => this.toTargetView(target)), + templateId: String(binding.templateId), + templateName: template?.name ?? '', + updateTime: String(binding.updateTime), + }; + } + + /** Computes live binding eligibility without changing its independent user enabled switch. */ + private async inspectAvailability( + subscription: QqbotMessageSubscription | null, + template: QqbotMessageTemplate | null, + ): Promise<{ + available: boolean; + invalidReasonCode: null | string; + sourceName: string; + }> { + if (!subscription || subscription.isDeleted) { + return { + available: false, + invalidReasonCode: 'invalid_source_config', + sourceName: '', + }; + } + let sourceName = subscription.sourceKey; + try { + const adapter = this.sourceRegistry.get(subscription.sourceKey); + sourceName = adapter.definition.displayName; + const inspection = await adapter.inspectSubscription( + subscription.sourceConfig, + ); + if (!inspection.valid) { + return { + available: false, + invalidReasonCode: + inspection.invalidReasonCode || 'invalid_source_config', + sourceName, + }; + } + } catch { + return { + available: false, + invalidReasonCode: 'invalid_source_config', + sourceName, + }; + } + if (!subscription.enabled) { + return { + available: false, + invalidReasonCode: 'subscription_disabled', + sourceName, + }; + } + if ( + !template || + template.isDeleted || + template.sourceKey !== subscription.sourceKey || + !template.enabled + ) { + return { + available: false, + invalidReasonCode: 'template_invalid', + sourceName, + }; + } + try { + const definition = this.sourceRegistry.get( + subscription.sourceKey, + ).definition; + this.renderer.validate( + template.content, + definition.variables.map((variable) => variable.key), + ); + } catch { + return { + available: false, + invalidReasonCode: 'template_invalid', + sourceName, + }; + } + return { available: true, invalidReasonCode: null, sourceName }; + } + + /** Maps one persisted target to the detached active-target response contract. */ + private toTargetView( + target: QqbotMessagePublishTarget, + ): QqbotMessagePublishTargetView { + return { + enabled: target.enabled, + id: String(target.id), + targetId: String(target.targetId), + targetName: target.targetName?.trim() || null, + targetType: target.targetType, + }; + } + + /** Loads the configured non-deleted account and preserves its string identity. */ + private async requireAccount(selfId: string) { + const account = await this.accountService.findBySelfId(selfId); + if (!account) throw new SystemMessageContractError('account_unavailable'); + return account; + } + + /** Locks one active binding for the resolved account without allowing cross-account access. */ + private async findBindingForWrite( + repository: Repository, + accountId: string, + id: string, + ): Promise { + const binding = await repository.findOne({ + lock: { mode: 'pessimistic_write' }, + where: { accountId: String(accountId), id, isDeleted: false }, + }); + if (!binding) this.throwBindingUnavailable(); + return binding!; + } + + /** Refuses stale snapshots before a binding write can use changed account ownership. */ + private assertStableAccountSnapshot( + binding: QqbotMessagePublishBinding, + snapshot: QqbotMessagePublishBinding, + ): void { + if ( + binding.accountId !== snapshot.accountId || + binding.selfId !== snapshot.selfId + ) { + this.throwBindingUnavailable(); + } + } + + /** Refuses stale snapshots when a toggle's dependency identities changed before its write lock. */ + private assertStableBindingSnapshot( + binding: QqbotMessagePublishBinding, + snapshot: QqbotMessagePublishBinding, + ): void { + this.assertStableAccountSnapshot(binding, snapshot); + if ( + binding.subscriptionId !== snapshot.subscriptionId || + binding.templateId !== snapshot.templateId + ) { + this.throwBindingUnavailable(); + } + } + + /** Builds the active natural key for a single account and subscription. */ + private bindingActiveKey(accountId: string, subscriptionId: string): string { + return `${String(accountId)}:${String(subscriptionId)}`; + } + + /** Builds the active natural key for a single binding target without number coercion. */ + private targetActiveKey( + bindingId: string, + target: Pick, + ): string { + return `${String(bindingId)}:${target.targetType}:${target.targetId}`; + } + + /** Converts complete UI input into the binding fields owned by this service. */ + private toBindingFields( + account: { id: string; selfId: string }, + input: QqbotMessagePublishBindingInput, + activeKey: string, + ): Pick< + QqbotMessagePublishBinding, + | 'accountId' + | 'activeKey' + | 'enabled' + | 'selfId' + | 'subscriptionId' + | 'templateId' + > { + return { + accountId: String(account.id), + activeKey, + enabled: input.enabled, + selfId: String(account.selfId), + subscriptionId: String(input.subscriptionId), + templateId: String(input.templateId), + }; + } + + /** Maps duplicate active binding or target natural keys to the management HTTP conflict. */ + private throwNaturalKeyConflict(): never { + return throwVbenError( + '同一账号订阅的消息发布配置已存在', + HttpStatus.CONFLICT, + ); + } + + /** Signals that a target binding is missing, deleted, or owned by another account. */ + private throwBindingUnavailable(): never { + throw new SystemMessageContractError('binding_invalid'); + } + + /** Recognizes only MySQL's duplicate-key conflict used as final concurrency authority. */ + private isDuplicateKeyError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const value = error as { code?: unknown; errno?: unknown }; + return value.code === 'ER_DUP_ENTRY' || value.errno === 1062; + } +} diff --git a/src/modules/qqbot/core/application/message-push/qqbot-message-target-options.service.ts b/src/modules/qqbot/core/application/message-push/qqbot-message-target-options.service.ts new file mode 100644 index 0000000..ac256c5 --- /dev/null +++ b/src/modules/qqbot/core/application/message-push/qqbot-message-target-options.service.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@nestjs/common'; +import type { + QqbotMessagePushTargetOption, + QqbotMessagePushTargetOptionsResponse, + QqbotMessagePushTargetType, +} from '../../contract/message-push/qqbot-message-push.types'; +import { QqbotAccountService } from '../account/qqbot-account.service'; +import { QqbotReverseWsService } from '../../infrastructure/integration/connection/qqbot-reverse-ws.service'; + +const TARGET_ID_PATTERN = /^[1-9]\d{4,19}$/; + +/** Provides HTTP-safe OneBot group and friend candidates for exactly one configured account. */ +@Injectable() +export class QqbotMessageTargetOptionsService { + /** Initializes strict account lookup and the existing account-keyed OneBot action path. */ + constructor( + private readonly accountService: QqbotAccountService, + private readonly reverseWsService: QqbotReverseWsService, + ) {} + + /** Lists normalized group and friend candidates, returning a safe unavailable result on OneBot failure. */ + async listTargetOptions( + selfId: string, + ): Promise { + const account = await this.accountService.findBySelfId(selfId); + if (!account) return this.unavailable('account_unavailable'); + try { + const [groups, friends] = await Promise.all([ + this.reverseWsService.sendAction(selfId, 'get_group_list', {}), + this.reverseWsService.sendAction(selfId, 'get_friend_list', {}), + ]); + const options = [ + ...this.normalizeResponse(groups, 'group'), + ...this.normalizeResponse(friends, 'private'), + ]; + const unique = new Map(); + options.forEach((option) => { + unique.set(`${option.targetType}:${option.targetId}`, option); + }); + return { + available: true, + options: [...unique.values()].sort((left, right) => + `${left.targetType}:${left.label}:${left.targetId}`.localeCompare( + `${right.targetType}:${right.label}:${right.targetId}`, + ), + ), + reasonCode: null, + }; + } catch { + return this.unavailable('onebot_unavailable'); + } + } + + /** Validates one successful OneBot list response and maps every row to a searchable option. */ + private normalizeResponse( + response: { data?: unknown; retcode?: number; status?: string }, + targetType: QqbotMessagePushTargetType, + ): QqbotMessagePushTargetOption[] { + if ( + response.status !== 'ok' || + (response.retcode !== undefined && response.retcode !== 0) || + !Array.isArray(response.data) + ) { + throw new Error('OneBot candidate response is unavailable'); + } + return response.data.map((item) => + this.normalizeCandidate(item, targetType), + ); + } + + /** Normalizes one OneBot row while preserving its identifier as a string. */ + private normalizeCandidate( + candidate: unknown, + targetType: QqbotMessagePushTargetType, + ): QqbotMessagePushTargetOption { + if (!candidate || typeof candidate !== 'object') { + throw new Error('OneBot candidate is malformed'); + } + const record = candidate as Record; + const rawId = targetType === 'group' ? record.group_id : record.user_id; + const targetId = String(rawId).trim(); + if (!TARGET_ID_PATTERN.test(targetId)) { + throw new Error('OneBot candidate ID is invalid'); + } + const name = + targetType === 'group' + ? this.knownName(record.group_name) + : (this.knownName(record.remark) ?? this.knownName(record.nickname)); + return { + label: name ? `${name} (${targetId})` : targetId, + targetId, + targetType, + }; + } + + /** Trims an optional OneBot display name without inventing a replacement name. */ + private knownName(value: unknown): null | string { + return typeof value === 'string' && value.trim() ? value.trim() : null; + } + + /** Produces the stable HTTP-safe empty candidate response. */ + private unavailable( + reasonCode: string, + ): QqbotMessagePushTargetOptionsResponse { + return { available: false, options: [], reasonCode }; + } +} diff --git a/src/modules/qqbot/core/qqbot-core.module.ts b/src/modules/qqbot/core/qqbot-core.module.ts index 48b6853..7b3e229 100644 --- a/src/modules/qqbot/core/qqbot-core.module.ts +++ b/src/modules/qqbot/core/qqbot-core.module.ts @@ -51,6 +51,8 @@ import { SystemMessageSourceRegistry } from './application/message-push/system-m import { SystemMessageTemplateRendererService } from './application/message-push/system-message-template-renderer.service'; import { QqbotMessageSubscriptionService } from './application/message-push/qqbot-message-subscription.service'; import { QqbotMessageTemplateService } from './application/message-push/qqbot-message-template.service'; +import { QqbotAccountMessagePushService } from './application/message-push/qqbot-account-message-push.service'; +import { QqbotMessageTargetOptionsService } from './application/message-push/qqbot-message-target-options.service'; export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract'; @@ -90,6 +92,8 @@ export const QQBOT_CORE_PROVIDERS = [ SystemMessageTemplateRendererService, QqbotMessageSubscriptionService, QqbotMessageTemplateService, + QqbotAccountMessagePushService, + QqbotMessageTargetOptionsService, QqbotAccountService, QqbotBusService, QqbotCommandEngineService, @@ -114,6 +118,8 @@ export const QQBOT_CORE_EXPORTS = [ SystemMessageTemplateRendererService, QqbotMessageSubscriptionService, QqbotMessageTemplateService, + QqbotAccountMessagePushService, + QqbotMessageTargetOptionsService, QqbotAccountService, QqbotConfigService, QqbotDashboardService, diff --git a/test/modules/qqbot/message-push/qqbot-account-message-push.service.spec.ts b/test/modules/qqbot/message-push/qqbot-account-message-push.service.spec.ts new file mode 100644 index 0000000..6cf859b --- /dev/null +++ b/test/modules/qqbot/message-push/qqbot-account-message-push.service.spec.ts @@ -0,0 +1,163 @@ +import { QqbotAccountMessagePushService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-account-message-push.service'; +import { SystemMessageContractError } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push.types'; +import { QqbotMessagePublishBinding } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.entity'; +import { QqbotMessagePublishTarget } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-target.entity'; + +describe('QqbotAccountMessagePushService', () => { + it('normalizes targets without number coercion and rejects invalid manual values', () => { + const service = new QqbotAccountMessagePushService( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + expect( + (service as any).normalizeTargets([ + { + targetId: ' 20000000000000001 ', + targetName: ' 群 ', + targetType: 'group', + }, + { targetId: '20000000000000001', targetType: 'private' }, + { + targetId: '20000000000000001', + targetName: '覆盖', + targetType: 'group', + }, + ]), + ).toEqual([ + { + targetId: '20000000000000001', + targetName: '覆盖', + targetType: 'group', + }, + { + targetId: '20000000000000001', + targetName: null, + targetType: 'private', + }, + ]); + expect(() => + (service as any).normalizeTargets([ + { targetId: '00001', targetType: 'group' }, + ]), + ).toThrow(new SystemMessageContractError('invalid_target_id')); + expect(() => + (service as any).normalizeTargets([ + { targetId: '10001', targetType: 'channel' }, + ]), + ).toThrow(new SystemMessageContractError('invalid_target_type')); + }); + + it('holds subscription then template gates through multi-target persistence without coercing IDs', async () => { + const events: string[] = []; + const targetRows: any[] = []; + const subscription = { + enabled: true, + id: '2041700000000000001', + isDeleted: false, + name: '订阅', + sourceConfig: {}, + sourceKey: 'network.stun.mapping-port-changed', + }; + const template = { + content: '正文', + enabled: true, + id: '2041700000000000002', + isDeleted: false, + name: '模板', + sourceKey: subscription.sourceKey, + }; + const bindingStore = { + create: (value: any) => ({ + ...value, + createTime: '2026-07-24', + id: '2041700000000000003', + updateTime: '2026-07-24', + }), + findOne: jest.fn().mockResolvedValue(null), + save: async (value: any) => { + events.push('binding'); + return value; + }, + }; + const targetStore = { + create: (value: any) => ({ + ...value, + createTime: '2026-07-24', + id: `target-${targetRows.length + 1}`, + updateTime: '2026-07-24', + }), + find: jest.fn().mockResolvedValue([]), + save: async (values: any[]) => { + events.push('targets'); + targetRows.splice(0, targetRows.length, ...values); + return values; + }, + }; + const manager = { + getRepository: (entity: unknown) => { + if (entity === QqbotMessagePublishBinding) return bindingStore; + if (entity === QqbotMessagePublishTarget) return targetStore; + throw new Error('unexpected repository'); + }, + }; + const service = new QqbotAccountMessagePushService( + { + manager: { transaction: async (callback: any) => callback(manager) }, + } as never, + { find: async () => targetRows } as never, + { findOne: async () => subscription } as never, + { findOne: async () => template } as never, + { + findBySelfId: async () => ({ + id: '2041700000000000000', + selfId: '10000000000000001', + }), + } as never, + { + requireAvailableForBinding: async () => { + events.push('subscription'); + return subscription; + }, + } as never, + { + requireAvailableForBinding: async () => { + events.push('template'); + return template; + }, + } as never, + { + get: () => ({ + definition: { displayName: 'STUN 映射', variables: [] }, + inspectSubscription: async () => ({ valid: true }), + }), + } as never, + { validate: jest.fn() } as never, + ); + + await expect( + service.createBinding('10000000000000001', { + enabled: true, + subscriptionId: '2041700000000000001', + templateId: '2041700000000000002', + targets: [ + { targetId: '20000000000000001', targetType: 'group' }, + { targetId: '30000000000000001', targetType: 'private' }, + ], + }), + ).resolves.toMatchObject({ + id: '2041700000000000003', + targets: [ + { targetId: '20000000000000001', targetType: 'group' }, + { targetId: '30000000000000001', targetType: 'private' }, + ], + }); + expect(events).toEqual(['subscription', 'template', 'binding', 'targets']); + }); +}); diff --git a/test/modules/qqbot/message-push/qqbot-message-target-options.service.spec.ts b/test/modules/qqbot/message-push/qqbot-message-target-options.service.spec.ts new file mode 100644 index 0000000..a02687b --- /dev/null +++ b/test/modules/qqbot/message-push/qqbot-message-target-options.service.spec.ts @@ -0,0 +1,101 @@ +import { QqbotMessageTargetOptionsService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-message-target-options.service'; + +describe('QqbotMessageTargetOptionsService', () => { + it('preserves large IDs, normalizes searchable labels, and keeps group/private IDs independent', async () => { + const accountService = { + findBySelfId: jest.fn().mockResolvedValue({ id: '1' }), + }; + const reverseWsService = { + sendAction: jest + .fn() + .mockResolvedValueOnce({ + data: [ + { group_id: '20000000000000001', group_name: ' KT 群 ' }, + { group_id: '20000000000000001', group_name: 'KT 群' }, + ], + status: 'ok', + }) + .mockResolvedValueOnce({ + data: [ + { nickname: ' 阿雪 ', user_id: '20000000000000001' }, + { remark: ' 备注 ', user_id: '30000000000000001' }, + ], + status: 'ok', + }), + }; + const service = new QqbotMessageTargetOptionsService( + accountService as never, + reverseWsService as never, + ); + + await expect( + service.listTargetOptions('10000000000000001'), + ).resolves.toEqual({ + available: true, + options: [ + { + label: 'KT 群 (20000000000000001)', + targetId: '20000000000000001', + targetType: 'group', + }, + { + label: '备注 (30000000000000001)', + targetId: '30000000000000001', + targetType: 'private', + }, + { + label: '阿雪 (20000000000000001)', + targetId: '20000000000000001', + targetType: 'private', + }, + ], + reasonCode: null, + }); + expect(reverseWsService.sendAction).toHaveBeenNthCalledWith( + 1, + '10000000000000001', + 'get_group_list', + {}, + ); + expect(reverseWsService.sendAction).toHaveBeenNthCalledWith( + 2, + '10000000000000001', + 'get_friend_list', + {}, + ); + }); + + it('returns safe unavailable responses for a missing account, rejected action, and malformed payload', async () => { + const accountService = { findBySelfId: jest.fn() }; + const reverseWsService = { sendAction: jest.fn() }; + const service = new QqbotMessageTargetOptionsService( + accountService as never, + reverseWsService as never, + ); + + accountService.findBySelfId.mockResolvedValueOnce(null); + await expect(service.listTargetOptions('10001')).resolves.toEqual({ + available: false, + options: [], + reasonCode: 'account_unavailable', + }); + expect(reverseWsService.sendAction).not.toHaveBeenCalled(); + + accountService.findBySelfId.mockResolvedValue({ id: '1' }); + reverseWsService.sendAction.mockRejectedValueOnce(new Error('offline')); + await expect(service.listTargetOptions('10001')).resolves.toEqual({ + available: false, + options: [], + reasonCode: 'onebot_unavailable', + }); + + reverseWsService.sendAction + .mockResolvedValueOnce({ data: [{ group_id: 'bad' }], status: 'ok' }) + .mockResolvedValueOnce({ data: [], status: 'ok' }); + await expect(service.listTargetOptions('10001')).resolves.toEqual({ + available: false, + options: [], + reasonCode: 'onebot_unavailable', + }); + }); +});