diff --git a/src/modules/qqbot/core/application/send/qqbot-send.error.ts b/src/modules/qqbot/core/application/send/qqbot-send.error.ts new file mode 100644 index 0000000..a2d90db --- /dev/null +++ b/src/modules/qqbot/core/application/send/qqbot-send.error.ts @@ -0,0 +1,20 @@ +import type { QqbotSendAttemptErrorOptions } from '../../contract/message-push/qqbot-message-push.types'; + +/** Represents a retryable or permanent strict QQBot delivery attempt failure. */ +export class QqbotSendAttemptError extends Error { + readonly code: string; + readonly retryable: boolean; + readonly sendLogId: null | string; + + /** + * Preserves the stable retry classification and optional pending send-log identity. + * @param options - The approved code, non-sensitive message, retry policy, and log ID. + */ + constructor(options: QqbotSendAttemptErrorOptions) { + super(options.message); + this.name = 'QqbotSendAttemptError'; + this.code = options.code; + this.retryable = options.retryable; + this.sendLogId = options.sendLogId; + } +} diff --git a/src/modules/qqbot/core/application/send/qqbot-send.service.ts b/src/modules/qqbot/core/application/send/qqbot-send.service.ts index 98dc242..eb1cc4a 100644 --- a/src/modules/qqbot/core/application/send/qqbot-send.service.ts +++ b/src/modules/qqbot/core/application/send/qqbot-send.service.ts @@ -16,12 +16,26 @@ import { QQBOT_DEFAULT_PAGE_SIZE, } from '../../contract/qqbot.constants'; import { QqbotRateLimitService } from './qqbot-rate-limit.service'; +import { QqbotSendAttemptError } from './qqbot-send.error'; import { QqbotSendLog } from '../../infrastructure/persistence/send/qqbot-send-log.entity'; +import { QqbotReverseWsActionError } from '../../infrastructure/integration/connection/qqbot-reverse-ws.service'; +import type { QqbotAccount } from '../../infrastructure/persistence/account/qqbot-account.entity'; import type { QqbotSendGroupDto, QqbotSendLogQueryDto, QqbotSendPrivateDto, } from '../../contract/send/qqbot-send.dto'; +import type { StrictPlainTextSendInput } from '../../contract/message-push/qqbot-message-push.types'; + +type SendPipelineInput = { + action: string; + actionParams: Record; + audit?: { attemptNumber: number; deliveryId: string }; + message: string; + strict: boolean; + targetId: string; + targetType: QqbotMessageType; +}; @Injectable() export class QqbotSendService { @@ -126,88 +140,65 @@ export class QqbotSendService { throwVbenError('没有可用 QQBot 账号'); } - await this.rateLimitService.waitForSendSlot( - account.selfId, - params.targetId, - ); - const { action, actionParams } = this.buildAction(params); - const storedMessageText = this.toolsService.toStoredMessageText( - params.message, - ); - const storedActionParams = this.toStoredActionParams( + return this.sendWithAccount(account, { + action, actionParams, - storedMessageText, - ); + message: params.message, + strict: false, + targetId: params.targetId, + targetType: params.targetType, + }); + } - const log = await this.sendLogRepository.save( - this.sendLogRepository.create({ - action, - messageText: storedMessageText, - params: storedActionParams, - selfId: account.selfId, - status: 'pending', - targetId: params.targetId, - targetType: params.targetType, - }), - ); - - await this.busService.publish( - QQBOT_MQTT_TOPICS.commandSend(account.selfId), - { - action, - logId: log.id, - params: actionParams, - selfId: account.selfId, - }, - ); - - try { - const reverseWsService = await this.getReverseWsService(); - const response = await reverseWsService.sendAction( - account.selfId, - action, - actionParams, - ); - const success = response.status === 'ok' || response.retcode === 0; - const messageId = response.data?.message_id - ? `${response.data.message_id}` - : null; - await this.sendLogRepository.update( - { id: log.id }, - { - echo: response.echo || null, - errorMessage: success ? null : response.message || 'OneBot 发送失败', - messageId, - response: response as any, - status: success ? 'success' : 'failed', - }, - ); - - if (success) { - await this.messageService.saveOutgoing({ - messageId, - messageText: storedMessageText, - messageType: params.targetType, - selfId: account.selfId, - targetId: params.targetId, - userId: - params.targetType === 'private' ? params.targetId : account.selfId, - }); - } - if (!success) throwVbenError(response.message || 'OneBot 发送失败'); - return { ...response, logId: log.id }; - } catch (err) { - const message = this.toolsService.getErrorMessage(err, 'OneBot 发送失败'); - await this.sendLogRepository.update( - { id: log.id }, - { - errorMessage: message, - status: 'failed', - }, - ); - throwVbenError(message); + /** + * Sends a single text segment through exactly the configured enabled QQBot account. + * @param input - A durable delivery attempt whose account and target must not fall back. + * @returns The OneBot response together with the created send-log ID. + */ + async sendStrictPlainText(input: StrictPlainTextSendInput) { + const account = await this.accountService.findBySelfId(input.selfId); + if (!account || account.isDeleted || !account.enabled) { + throw new QqbotSendAttemptError({ + code: 'account_unavailable', + message: 'Configured QQBot account is unavailable', + retryable: true, + sendLogId: null, + }); } + if (input.targetType !== 'group' && input.targetType !== 'private') { + throw new QqbotSendAttemptError({ + code: 'invalid_target_type', + message: 'Strict QQBot delivery only supports group or private targets', + retryable: false, + sendLogId: null, + }); + } + + const action = + input.targetType === 'group' ? 'send_group_msg' : 'send_private_msg'; + const actionParams = + input.targetType === 'group' + ? { + group_id: input.targetId, + message: this.toTextSegment(input.message), + } + : { + message: this.toTextSegment(input.message), + user_id: input.targetId, + }; + return this.sendWithAccount(account, { + action, + actionParams, + audit: { + attemptNumber: input.attemptNumber, + deliveryId: input.deliveryId, + }, + message: input.message, + strict: true, + targetId: input.targetId, + targetType: input.targetType, + }); } /** @@ -222,6 +213,206 @@ export class QqbotSendService { }); } + /** + * Runs the common QQBot send lifecycle for legacy and strict callers. + * @param account - The already selected QQBot account. + * @param input - The wire action and storage-safe delivery metadata. + * @returns The OneBot response together with the created send-log ID. + */ + private async sendWithAccount( + account: QqbotAccount, + input: SendPipelineInput, + ) { + try { + await this.rateLimitService.waitForSendSlot( + account.selfId, + input.targetId, + ); + } catch (err) { + this.throwSendFailure(input.strict, err, null); + } + + const storedMessageText = this.toolsService.toStoredMessageText( + input.message, + ); + const storedActionParams = { + ...this.toStoredActionParams(input.actionParams, storedMessageText), + ...(input.audit ? { messagePush: input.audit } : {}), + }; + let log: QqbotSendLog; + try { + log = await this.sendLogRepository.save( + this.sendLogRepository.create({ + action: input.action, + messageText: storedMessageText, + params: storedActionParams, + selfId: account.selfId, + status: 'pending', + targetId: input.targetId, + targetType: input.targetType, + }), + ); + } catch (err) { + this.throwSendFailure(input.strict, err, null); + } + + try { + await this.busService.publish( + QQBOT_MQTT_TOPICS.commandSend(account.selfId), + { + action: input.action, + logId: log!.id, + params: input.actionParams, + selfId: account.selfId, + }, + ); + const reverseWsService = await this.getReverseWsService(); + const response = await reverseWsService.sendAction( + account.selfId, + input.action, + input.actionParams, + ); + const success = response.status === 'ok' || response.retcode === 0; + const messageId = response.data?.message_id + ? `${response.data.message_id}` + : null; + if (!success) { + const message = response.message || 'OneBot rejected the send action'; + if (input.strict) { + const error = new QqbotSendAttemptError({ + code: 'onebot_rejected', + message, + retryable: false, + sendLogId: log!.id, + }); + await this.markFailedLog(log!.id, message); + throw error; + } + await this.sendLogRepository.update( + { id: log!.id }, + { + echo: response.echo || null, + errorMessage: message, + messageId, + response: response as any, + status: 'failed', + }, + ); + throwVbenError(message); + } + + await this.sendLogRepository.update( + { id: log!.id }, + { + echo: response.echo || null, + errorMessage: null, + messageId, + response: response as any, + status: 'success', + }, + ); + await this.messageService.saveOutgoing({ + messageId, + messageText: storedMessageText, + messageType: input.targetType, + selfId: account.selfId, + targetId: input.targetId, + userId: + input.targetType === 'private' ? input.targetId : account.selfId, + }); + return { ...response, logId: log!.id }; + } catch (err) { + if (input.strict && err instanceof QqbotSendAttemptError) throw err; + const message = this.toolsService.getErrorMessage( + err, + 'OneBot send failed', + ); + if (input.strict) { + const error = this.toStrictSendError(err, message, log!.id); + await this.markFailedLog(log!.id, error.message); + throw error; + } + await this.sendLogRepository.update( + { id: log!.id }, + { errorMessage: message, status: 'failed' }, + ); + throwVbenError(message); + } + } + + /** + * Converts an arbitrary string into the sole strict OneBot text segment. + * @param message - The literal text that must not be interpreted as CQ code. + * @returns A single OneBot text segment. + */ + private toTextSegment(message: string) { + return [{ data: { text: message }, type: 'text' }]; + } + + /** + * Marks a pending strict send log as failed without masking the original failure. + * @param logId - The pending send-log ID. + * @param message - A non-sensitive failure summary. + */ + private async markFailedLog(logId: string, message: string) { + try { + await this.sendLogRepository.update( + { id: logId }, + { errorMessage: message, status: 'failed' }, + ); + } catch { + // The typed delivery failure is more important than a secondary log-update failure. + } + } + + /** + * Converts strict transport and infrastructure failures to stable delivery classifications. + * @param err - The original failure. + * @param message - Its non-sensitive safe summary. + * @param sendLogId - The pending log ID, if creation succeeded. + * @returns A stable strict delivery error. + */ + private toStrictSendError( + err: unknown, + message: string, + sendLogId: null | string, + ) { + if (err instanceof QqbotReverseWsActionError) { + return new QqbotSendAttemptError({ + code: err.code, + message, + retryable: true, + sendLogId, + }); + } + return new QqbotSendAttemptError({ + code: 'onebot_disconnected', + message, + retryable: true, + sendLogId, + }); + } + + /** + * Throws either the strict typed error or the legacy Vben-compatible error. + * @param strict - Whether the caller requires stable delivery retry metadata. + * @param err - The original failure. + * @param sendLogId - The pending log ID, if creation succeeded. + */ + private throwSendFailure( + strict: boolean, + err: unknown, + sendLogId: null | string, + ): never { + const message = this.toolsService.getErrorMessage( + err, + 'OneBot send failed', + ); + if (strict) throw this.toStrictSendError(err, message, sendLogId); + throwVbenError(message); + throw new Error(message); + } + /** * 创建 QQBot 核心对象或配置。 * @param params - QQBot列表;使用 `targetType`、`targetId`、`message`、`channelId` 字段生成结果。 @@ -265,11 +456,16 @@ export class QqbotSendService { actionParams: Record, storedMessageText: string, ) { + const message = actionParams.message; return { ...actionParams, - ...(actionParams.message === undefined + ...(message === undefined ? {} - : { message: storedMessageText }), + : { + message: Array.isArray(message) + ? this.toTextSegment(storedMessageText) + : storedMessageText, + }), }; } } diff --git a/src/modules/qqbot/core/infrastructure/integration/connection/qqbot-reverse-ws.service.ts b/src/modules/qqbot/core/infrastructure/integration/connection/qqbot-reverse-ws.service.ts index 07230a7..4cf559f 100644 --- a/src/modules/qqbot/core/infrastructure/integration/connection/qqbot-reverse-ws.service.ts +++ b/src/modules/qqbot/core/infrastructure/integration/connection/qqbot-reverse-ws.service.ts @@ -24,6 +24,22 @@ import { QqbotAccountService } from '../../../application/account/qqbot-account. import { QqbotEventService } from '../../../application/event/qqbot-event.service'; import { QqbotBusService } from '../bus/qqbot-bus.service'; +/** Represents a machine-readable reverse WebSocket availability failure. */ +export class QqbotReverseWsActionError extends Error { + /** + * Creates a stable reverse WebSocket action failure. + * @param code - Distinguishes a disconnected socket from an action timeout. + * @param message - A non-sensitive operator-facing error summary. + */ + constructor( + public readonly code: 'onebot_disconnected' | 'onebot_timeout', + message: string, + ) { + super(message); + this.name = 'QqbotReverseWsActionError'; + } +} + @Injectable() export class QqbotReverseWsService implements OnApplicationBootstrap, OnModuleDestroy @@ -111,18 +127,32 @@ export class QqbotReverseWsService params, }; - const responsePromise = new Promise( - (resolve, reject) => { - const timer = setTimeout(() => { - this.pendingActions.delete(echo); - this.closeTimedOutConnection(selfId, ws); - reject(new Error('OneBot action timeout')); - }, this.getActionTimeout()); - this.pendingActions.set(echo, { reject, resolve, timer }); - }, - ); - ws.send(JSON.stringify(payload)); - return responsePromise; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingActions.delete(echo); + this.closeTimedOutConnection(selfId, ws); + reject( + new QqbotReverseWsActionError( + 'onebot_timeout', + 'OneBot action timed out', + ), + ); + }, this.getActionTimeout()); + this.pendingActions.set(echo, { reject, resolve, timer }); + + try { + ws.send(JSON.stringify(payload)); + } catch { + clearTimeout(timer); + this.pendingActions.delete(echo); + reject( + new QqbotReverseWsActionError( + 'onebot_disconnected', + 'OneBot connection is unavailable', + ), + ); + } + }); } /** @@ -376,7 +406,10 @@ export class QqbotReverseWsService const api = this.connections.get(this.getConnectionKey(selfId, 'API')); const ws = api || universal; if (!ws || ws.readyState !== WebSocket.OPEN) { - throw new Error(`QQBot ${selfId} 未连接可用 API WS`); + throw new QqbotReverseWsActionError( + 'onebot_disconnected', + 'OneBot connection is unavailable', + ); } return ws; } diff --git a/test/modules/qqbot/core/qqbot-core-send-contract.spec.ts b/test/modules/qqbot/core/qqbot-core-send-contract.spec.ts index 0b9e9a0..6b4944a 100644 --- a/test/modules/qqbot/core/qqbot-core-send-contract.spec.ts +++ b/test/modules/qqbot/core/qqbot-core-send-contract.spec.ts @@ -1,4 +1,5 @@ import { QQBOT_CORE_DOMAIN_CONTRACT } from '../../../../src/modules/qqbot/core/contract/qqbot-core.contract'; +import { QqbotSendAttemptError } from '../../../../src/modules/qqbot/core/application/send/qqbot-send.error'; import { readRefactorV3SqlSchema } from '../../../helpers/sql-schema.helper'; describe('QQBot core send contract', () => { @@ -64,4 +65,20 @@ describe('QQBot core send contract', () => { 'expires_at', ]); }); + + it('keeps strict delivery error classification explicit', () => { + const error = new QqbotSendAttemptError({ + code: 'onebot_disconnected', + message: 'OneBot unavailable', + retryable: true, + sendLogId: null, + }); + + expect(error).toMatchObject({ + code: 'onebot_disconnected', + name: 'QqbotSendAttemptError', + retryable: true, + sendLogId: null, + }); + }); }); diff --git a/test/modules/qqbot/message-push/qqbot-strict-send.spec.ts b/test/modules/qqbot/message-push/qqbot-strict-send.spec.ts new file mode 100644 index 0000000..ca7ac42 --- /dev/null +++ b/test/modules/qqbot/message-push/qqbot-strict-send.spec.ts @@ -0,0 +1,439 @@ +import { QqbotSendAttemptError } from '../../../../src/modules/qqbot/core/application/send/qqbot-send.error'; +import { QqbotSendService } from '../../../../src/modules/qqbot/core/application/send/qqbot-send.service'; +import { + QqbotReverseWsActionError, + QqbotReverseWsService, +} from '../../../../src/modules/qqbot/core/infrastructure/integration/connection/qqbot-reverse-ws.service'; + +const account = { + enabled: true, + isDeleted: false, + selfId: '10001', +}; + +/** Creates bounded fakes for the strict QQBot send contract. */ +const createHarness = () => { + const accountService = { + findBySelfId: jest.fn().mockResolvedValue(account), + getDefaultAccount: jest.fn().mockResolvedValue(account), + }; + const sendLogRepository = { + create: jest.fn((value) => value), + save: jest.fn().mockResolvedValue({ id: 'log-1' }), + update: jest.fn().mockResolvedValue(undefined), + }; + const busService = { publish: jest.fn().mockResolvedValue(undefined) }; + const messageService = { + saveOutgoing: jest.fn().mockResolvedValue(undefined), + }; + const reverseWs = { + sendAction: jest.fn().mockResolvedValue({ + data: { message_id: 'message-1' }, + echo: 'echo-1', + retcode: 0, + status: 'ok', + }), + }; + const moduleRef = { get: jest.fn().mockReturnValue(reverseWs) }; + const rateLimitService = { + waitForSendSlot: jest.fn().mockResolvedValue(undefined), + }; + const toolsService = { + getErrorMessage: jest.fn((error, fallback) => error?.message || fallback), + getPageParams: jest.fn(), + toStoredMessageText: jest.fn((message) => `stored:${message}`), + }; + const service = new QqbotSendService( + sendLogRepository as any, + accountService as any, + busService as any, + messageService as any, + moduleRef as any, + rateLimitService as any, + toolsService as any, + ); + return { + accountService, + busService, + messageService, + rateLimitService, + reverseWs, + sendLogRepository, + service, + }; +}; + +describe('QQBot strict plain-text sender', () => { + it.each([ + null, + { ...account, enabled: false }, + { ...account, isDeleted: true }, + ])( + 'never falls back when the configured account is unavailable', + async (configuredAccount) => { + const { accountService, service } = createHarness(); + accountService.findBySelfId.mockResolvedValue(configuredAccount); + + await expect( + service.sendStrictPlainText({ + attemptNumber: 1, + deliveryId: 'delivery-1', + message: 'test', + selfId: 'missing', + targetId: '20001', + targetType: 'group', + }), + ).rejects.toMatchObject({ + code: 'account_unavailable', + retryable: true, + sendLogId: null, + }); + expect(accountService.getDefaultAccount).not.toHaveBeenCalled(); + }, + ); + + it.each([ + [ + 'group', + 'send_group_msg', + { + group_id: '20001', + message: [{ data: { text: '[CQ:at,qq=12345]' }, type: 'text' }], + }, + ], + [ + 'private', + 'send_private_msg', + { + message: [{ data: { text: '[CQ:at,qq=12345]' }, type: 'text' }], + user_id: '20001', + }, + ], + ] as const)( + 'sends strict %s targets as one ordinary text segment with string identifiers', + async (targetType, action, params) => { + const { + busService, + rateLimitService, + reverseWs, + sendLogRepository, + service, + } = createHarness(); + + await expect( + service.sendStrictPlainText({ + attemptNumber: 2, + deliveryId: 'delivery-2', + message: '[CQ:at,qq=12345]', + selfId: '10001', + targetId: '20001', + targetType, + }), + ).resolves.toMatchObject({ logId: 'log-1', status: 'ok' }); + + expect(rateLimitService.waitForSendSlot).toHaveBeenCalledWith( + '10001', + '20001', + ); + expect( + rateLimitService.waitForSendSlot.mock.invocationCallOrder[0], + ).toBeLessThan(reverseWs.sendAction.mock.invocationCallOrder[0]); + expect(sendLogRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + action, + messageText: 'stored:[CQ:at,qq=12345]', + params: { + ...params, + message: [ + { data: { text: 'stored:[CQ:at,qq=12345]' }, type: 'text' }, + ], + messagePush: { attemptNumber: 2, deliveryId: 'delivery-2' }, + }, + }), + ); + expect(busService.publish).toHaveBeenCalledWith( + 'qqbot/10001/command/send', + expect.objectContaining({ action, params }), + ); + expect(reverseWs.sendAction).toHaveBeenCalledWith( + '10001', + action, + params, + ); + expect(busService.publish.mock.calls[0][1].params).not.toHaveProperty( + 'messagePush', + ); + expect(reverseWs.sendAction.mock.calls[0][2]).not.toHaveProperty( + 'messagePush', + ); + expect(sendLogRepository.update).toHaveBeenCalledWith( + { id: 'log-1' }, + expect.objectContaining({ status: 'success' }), + ); + }, + ); + + it('persists exactly one outgoing record after a strict success', async () => { + const { messageService, service } = createHarness(); + + await service.sendStrictPlainText({ + attemptNumber: 1, + deliveryId: 'delivery-1', + message: 'plain text', + selfId: '10001', + targetId: '20001', + targetType: 'private', + }); + + expect(messageService.saveOutgoing).toHaveBeenCalledTimes(1); + expect(messageService.saveOutgoing).toHaveBeenCalledWith({ + messageId: 'message-1', + messageText: 'stored:plain text', + messageType: 'private', + selfId: '10001', + targetId: '20001', + userId: '20001', + }); + }); + + it('rejects OneBot non-success responses permanently and retains the pending log', async () => { + const { messageService, reverseWs, sendLogRepository, service } = + createHarness(); + reverseWs.sendAction.mockResolvedValue({ + message: 'bad target', + retcode: 1404, + status: 'failed', + }); + + await expect( + service.sendStrictPlainText({ + attemptNumber: 1, + deliveryId: 'delivery-1', + message: 'plain text', + selfId: '10001', + targetId: '20001', + targetType: 'group', + }), + ).rejects.toMatchObject({ + code: 'onebot_rejected', + retryable: false, + sendLogId: 'log-1', + }); + expect(sendLogRepository.update).toHaveBeenCalledWith( + { id: 'log-1' }, + expect.objectContaining({ errorMessage: 'bad target', status: 'failed' }), + ); + expect(messageService.saveOutgoing).not.toHaveBeenCalled(); + }); + + it.each([ + [ + new QqbotReverseWsActionError('onebot_timeout', 'timeout'), + 'onebot_timeout', + ], + [ + new QqbotReverseWsActionError('onebot_disconnected', 'offline'), + 'onebot_disconnected', + ], + ])( + 'keeps retryable reverse WS classifications and the log identity', + async (error, code) => { + const { reverseWs, sendLogRepository, service } = createHarness(); + reverseWs.sendAction.mockRejectedValue(error); + + await expect( + service.sendStrictPlainText({ + attemptNumber: 1, + deliveryId: 'delivery-1', + message: 'plain text', + selfId: '10001', + targetId: '20001', + targetType: 'group', + }), + ).rejects.toMatchObject({ code, retryable: true, sendLogId: 'log-1' }); + expect(sendLogRepository.update).toHaveBeenCalledWith( + { id: 'log-1' }, + expect.objectContaining({ status: 'failed' }), + ); + }, + ); + + it('retains the original retryable classification when failed-log persistence also fails', async () => { + const { reverseWs, sendLogRepository, service } = createHarness(); + reverseWs.sendAction.mockRejectedValue(new Error('bus unavailable')); + sendLogRepository.update.mockRejectedValue( + new Error('log write unavailable'), + ); + + await expect( + service.sendStrictPlainText({ + attemptNumber: 1, + deliveryId: 'delivery-1', + message: 'plain text', + selfId: '10001', + targetId: '20001', + targetType: 'group', + }), + ).rejects.toMatchObject({ + code: 'onebot_disconnected', + retryable: true, + sendLogId: 'log-1', + }); + }); + + it('rejects an invalid strict target before rate limiting, logs, or transport', async () => { + const { + busService, + rateLimitService, + reverseWs, + sendLogRepository, + service, + } = createHarness(); + + await expect( + service.sendStrictPlainText({ + attemptNumber: 1, + deliveryId: 'delivery-1', + message: 'plain text', + selfId: '10001', + targetId: '20001', + targetType: 'channel' as never, + }), + ).rejects.toMatchObject({ + code: 'invalid_target_type', + retryable: false, + sendLogId: null, + }); + expect(rateLimitService.waitForSendSlot).not.toHaveBeenCalled(); + expect(sendLogRepository.save).not.toHaveBeenCalled(); + expect(busService.publish).not.toHaveBeenCalled(); + expect(reverseWs.sendAction).not.toHaveBeenCalled(); + }); + + it('keeps manual sendText default resolution and legacy CQ string payloads', async () => { + const { + accountService, + busService, + messageService, + rateLimitService, + reverseWs, + sendLogRepository, + service, + } = createHarness(); + + await service.sendText({ + message: '[CQ:at,qq=12345]', + selfId: 'missing', + targetId: '20001', + targetType: 'group', + }); + + expect(accountService.getDefaultAccount).toHaveBeenCalledWith('missing'); + expect( + rateLimitService.waitForSendSlot.mock.invocationCallOrder[0], + ).toBeLessThan(reverseWs.sendAction.mock.invocationCallOrder[0]); + expect(reverseWs.sendAction).toHaveBeenCalledWith( + '10001', + 'send_group_msg', + { + group_id: '20001', + message: '[CQ:at,qq=12345]', + }, + ); + expect(busService.publish).toHaveBeenCalledTimes(1); + expect(sendLogRepository.save).toHaveBeenCalledTimes(1); + expect(messageService.saveOutgoing).toHaveBeenCalledTimes(1); + }); +}); + +describe('QQBot reverse WS action classification', () => { + it('keeps the typed strict send error fields explicit', () => { + const error = new QqbotSendAttemptError({ + code: 'onebot_timeout', + message: 'timeout', + retryable: true, + sendLogId: 'log-1', + }); + + expect(error).toMatchObject({ + code: 'onebot_timeout', + name: 'QqbotSendAttemptError', + retryable: true, + sendLogId: 'log-1', + }); + }); + + /** Creates a reverse WS service with no real server or network dependency. */ + const createReverseWsHarness = () => { + const accountService = { + markOffline: jest.fn().mockResolvedValue(undefined), + }; + const busService = { publish: jest.fn().mockResolvedValue(undefined) }; + const service = new QqbotReverseWsService( + { + get: jest.fn((key) => (key === 'QQBOT_API_TIMEOUT_MS' ? '10' : '')), + } as any, + {} as any, + {} as any, + accountService as any, + busService as any, + { getErrorMessage: jest.fn() } as any, + ); + return { accountService, busService, service }; + }; + + it('signals a disconnected reverse WS without creating a pending action', async () => { + const { service } = createReverseWsHarness(); + + await expect( + service.sendAction('10001', 'send_group_msg', {}), + ).rejects.toMatchObject({ + code: 'onebot_disconnected', + name: 'QqbotReverseWsActionError', + }); + expect((service as any).pendingActions.size).toBe(0); + }); + + it('signals timeout, clears pending state, closes the connection, and keeps successful responses intact', async () => { + jest.useFakeTimers(); + try { + const { accountService, busService, service } = createReverseWsHarness(); + const ws = { close: jest.fn(), readyState: 1, send: jest.fn() }; + (service as any).connections.set('10001:Universal', ws); + + const timeout = service.sendAction('10001', 'send_group_msg', {}); + expect((service as any).pendingActions.size).toBe(1); + jest.advanceTimersByTime(10); + + await expect(timeout).rejects.toMatchObject({ code: 'onebot_timeout' }); + expect((service as any).pendingActions.size).toBe(0); + expect(ws.close).toHaveBeenCalledWith(1011, 'OneBot action timeout'); + expect(accountService.markOffline).toHaveBeenCalledWith( + '10001', + 'OneBot action timeout', + ); + expect(busService.publish).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ status: 'offline' }), + ); + + const successWs = { close: jest.fn(), readyState: 1, send: jest.fn() }; + (service as any).connections.set('10001:Universal', successWs); + const success = service.sendAction('10001', 'send_group_msg', {}); + const { echo } = JSON.parse(successWs.send.mock.calls[0][0]); + await (service as any).resolvePendingAction('10001', { + data: { message_id: 'message-1' }, + echo, + retcode: 0, + status: 'ok', + }); + await expect(success).resolves.toMatchObject({ + data: { message_id: 'message-1' }, + status: 'ok', + }); + expect((service as any).pendingActions.size).toBe(0); + } finally { + jest.useRealTimers(); + } + }); +});