feat: 增加严格账号纯文本发送

This commit is contained in:
sunlei 2026-07-24 09:00:48 +08:00
parent 1feb0c1816
commit 9c1454b16a
5 changed files with 798 additions and 93 deletions

View File

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

View File

@ -16,12 +16,26 @@ import {
QQBOT_DEFAULT_PAGE_SIZE, QQBOT_DEFAULT_PAGE_SIZE,
} from '../../contract/qqbot.constants'; } from '../../contract/qqbot.constants';
import { QqbotRateLimitService } from './qqbot-rate-limit.service'; import { QqbotRateLimitService } from './qqbot-rate-limit.service';
import { QqbotSendAttemptError } from './qqbot-send.error';
import { QqbotSendLog } from '../../infrastructure/persistence/send/qqbot-send-log.entity'; 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 { import type {
QqbotSendGroupDto, QqbotSendGroupDto,
QqbotSendLogQueryDto, QqbotSendLogQueryDto,
QqbotSendPrivateDto, QqbotSendPrivateDto,
} from '../../contract/send/qqbot-send.dto'; } from '../../contract/send/qqbot-send.dto';
import type { StrictPlainTextSendInput } from '../../contract/message-push/qqbot-message-push.types';
type SendPipelineInput = {
action: string;
actionParams: Record<string, any>;
audit?: { attemptNumber: number; deliveryId: string };
message: string;
strict: boolean;
targetId: string;
targetType: QqbotMessageType;
};
@Injectable() @Injectable()
export class QqbotSendService { export class QqbotSendService {
@ -126,88 +140,65 @@ export class QqbotSendService {
throwVbenError('没有可用 QQBot 账号'); throwVbenError('没有可用 QQBot 账号');
} }
await this.rateLimitService.waitForSendSlot(
account.selfId,
params.targetId,
);
const { action, actionParams } = this.buildAction(params); const { action, actionParams } = this.buildAction(params);
const storedMessageText = this.toolsService.toStoredMessageText( return this.sendWithAccount(account, {
params.message, action,
);
const storedActionParams = this.toStoredActionParams(
actionParams, actionParams,
storedMessageText, message: params.message,
); strict: false,
targetId: params.targetId,
targetType: params.targetType,
});
}
const log = await this.sendLogRepository.save( /**
this.sendLogRepository.create({ * Sends a single text segment through exactly the configured enabled QQBot account.
action, * @param input - A durable delivery attempt whose account and target must not fall back.
messageText: storedMessageText, * @returns The OneBot response together with the created send-log ID.
params: storedActionParams, */
selfId: account.selfId, async sendStrictPlainText(input: StrictPlainTextSendInput) {
status: 'pending', const account = await this.accountService.findBySelfId(input.selfId);
targetId: params.targetId, if (!account || account.isDeleted || !account.enabled) {
targetType: params.targetType, throw new QqbotSendAttemptError({
}), code: 'account_unavailable',
); message: 'Configured QQBot account is unavailable',
retryable: true,
await this.busService.publish( sendLogId: null,
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);
} }
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 * QQBot
* @param params - QQBot列表使 `targetType``targetId``message``channelId` * @param params - QQBot列表使 `targetType``targetId``message``channelId`
@ -265,11 +456,16 @@ export class QqbotSendService {
actionParams: Record<string, any>, actionParams: Record<string, any>,
storedMessageText: string, storedMessageText: string,
) { ) {
const message = actionParams.message;
return { return {
...actionParams, ...actionParams,
...(actionParams.message === undefined ...(message === undefined
? {} ? {}
: { message: storedMessageText }), : {
message: Array.isArray(message)
? this.toTextSegment(storedMessageText)
: storedMessageText,
}),
}; };
} }
} }

View File

@ -24,6 +24,22 @@ import { QqbotAccountService } from '../../../application/account/qqbot-account.
import { QqbotEventService } from '../../../application/event/qqbot-event.service'; import { QqbotEventService } from '../../../application/event/qqbot-event.service';
import { QqbotBusService } from '../bus/qqbot-bus.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() @Injectable()
export class QqbotReverseWsService export class QqbotReverseWsService
implements OnApplicationBootstrap, OnModuleDestroy implements OnApplicationBootstrap, OnModuleDestroy
@ -111,18 +127,32 @@ export class QqbotReverseWsService
params, params,
}; };
const responsePromise = new Promise<QqbotOneBotActionResponse>( return new Promise<QqbotOneBotActionResponse>((resolve, reject) => {
(resolve, reject) => { const timer = setTimeout(() => {
const timer = setTimeout(() => { this.pendingActions.delete(echo);
this.pendingActions.delete(echo); this.closeTimedOutConnection(selfId, ws);
this.closeTimedOutConnection(selfId, ws); reject(
reject(new Error('OneBot action timeout')); new QqbotReverseWsActionError(
}, this.getActionTimeout()); 'onebot_timeout',
this.pendingActions.set(echo, { reject, resolve, timer }); 'OneBot action timed out',
}, ),
); );
ws.send(JSON.stringify(payload)); }, this.getActionTimeout());
return responsePromise; 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 api = this.connections.get(this.getConnectionKey(selfId, 'API'));
const ws = api || universal; const ws = api || universal;
if (!ws || ws.readyState !== WebSocket.OPEN) { if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error(`QQBot ${selfId} 未连接可用 API WS`); throw new QqbotReverseWsActionError(
'onebot_disconnected',
'OneBot connection is unavailable',
);
} }
return ws; return ws;
} }

View File

@ -1,4 +1,5 @@
import { QQBOT_CORE_DOMAIN_CONTRACT } from '../../../../src/modules/qqbot/core/contract/qqbot-core.contract'; 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'; import { readRefactorV3SqlSchema } from '../../../helpers/sql-schema.helper';
describe('QQBot core send contract', () => { describe('QQBot core send contract', () => {
@ -64,4 +65,20 @@ describe('QQBot core send contract', () => {
'expires_at', '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,
});
});
}); });

View File

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