feat: 增加严格账号纯文本发送
This commit is contained in:
parent
1feb0c1816
commit
9c1454b16a
20
src/modules/qqbot/core/application/send/qqbot-send.error.ts
Normal file
20
src/modules/qqbot/core/application/send/qqbot-send.error.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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<string, any>;
|
||||
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<string, any>,
|
||||
storedMessageText: string,
|
||||
) {
|
||||
const message = actionParams.message;
|
||||
return {
|
||||
...actionParams,
|
||||
...(actionParams.message === undefined
|
||||
...(message === undefined
|
||||
? {}
|
||||
: { message: storedMessageText }),
|
||||
: {
|
||||
message: Array.isArray(message)
|
||||
? this.toTextSegment(storedMessageText)
|
||||
: storedMessageText,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<QqbotOneBotActionResponse>(
|
||||
(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<QqbotOneBotActionResponse>((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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
439
test/modules/qqbot/message-push/qqbot-strict-send.spec.ts
Normal file
439
test/modules/qqbot/message-push/qqbot-strict-send.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user