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