fix: 收紧系统消息投递边界
This commit is contained in:
parent
d2fd59dcd4
commit
d74545d17b
@ -392,8 +392,10 @@ export class QqbotAccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新数据。
|
* Updates the account and ability identity atomically, cancelling its claimable deliveries
|
||||||
* @param body - 请求体 DTO;承载 QQBot新增、更新、导入或执行字段。
|
* in the same transaction when an administrator disables it or replaces its self ID.
|
||||||
|
* @param body - Complete administrative account update with the target account ID.
|
||||||
|
* @returns `true` after the account, ability, and required delivery cancellations commit.
|
||||||
*/
|
*/
|
||||||
async update(body: QqbotAccountUpdateDto) {
|
async update(body: QqbotAccountUpdateDto) {
|
||||||
if (body.selfId) {
|
if (body.selfId) {
|
||||||
@ -429,8 +431,10 @@ export class QqbotAccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除数据。
|
* Removes NapCat containers first, then soft-deletes the account and ability and cancels
|
||||||
* @param id - QQBot记录 ID;定位本次读取、更新、删除或关联的QQBot记录。
|
* claimable deliveries in one database transaction; a later DB failure cannot restore containers.
|
||||||
|
* @param id - QQBot account ID to remove.
|
||||||
|
* @returns `{ deletedContainers: number }` for containers removed before the transaction.
|
||||||
*/
|
*/
|
||||||
async remove(id: string) {
|
async remove(id: string) {
|
||||||
const account = await this.accountRepository.findOne({
|
const account = await this.accountRepository.findOne({
|
||||||
|
|||||||
@ -172,9 +172,13 @@ export class QqbotMessageSubscriptionService {
|
|||||||
Object.assign(current, normalized);
|
Object.assign(current, normalized);
|
||||||
const saved = await repository.save(current);
|
const saved = await repository.save(current);
|
||||||
if (!saved.enabled || sourceIdentityChanged) {
|
if (!saved.enabled || sourceIdentityChanged) {
|
||||||
await this.cancelUnfinishedDeliveries(manager, {
|
await this.cancelUnfinishedDeliveries(
|
||||||
subscriptionId: saved.id,
|
manager,
|
||||||
});
|
{
|
||||||
|
subscriptionId: saved.id,
|
||||||
|
},
|
||||||
|
sourceIdentityChanged,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return saved;
|
return saved;
|
||||||
},
|
},
|
||||||
@ -365,13 +369,27 @@ export class QqbotMessageSubscriptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancels only claimable historical deliveries in the caller's configuration transaction. */
|
/**
|
||||||
|
* Cancels historical deliveries in the caller's configuration transaction.
|
||||||
|
* @param manager - Transaction manager that already owns the subscription mutation.
|
||||||
|
* @param where - Exact subscription identity whose historical rows are invalidated.
|
||||||
|
* @param includeProcessing - Whether a canonical identity change must revoke active owners too.
|
||||||
|
*/
|
||||||
private async cancelUnfinishedDeliveries(
|
private async cancelUnfinishedDeliveries(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
where: Pick<QqbotMessageDelivery, 'subscriptionId'>,
|
where: Pick<QqbotMessageDelivery, 'subscriptionId'>,
|
||||||
|
includeProcessing = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await manager.getRepository(QqbotMessageDelivery).update(
|
await manager.getRepository(QqbotMessageDelivery).update(
|
||||||
{ ...where, status: In(['waiting_ddns', 'pending', 'retry']) },
|
{
|
||||||
|
...where,
|
||||||
|
status: In([
|
||||||
|
'waiting_ddns',
|
||||||
|
'pending',
|
||||||
|
'retry',
|
||||||
|
...(includeProcessing ? (['processing'] as const) : []),
|
||||||
|
]),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
nextAttemptAt: null,
|
nextAttemptAt: null,
|
||||||
|
|||||||
@ -11,7 +11,10 @@ import { QqbotMessageEvent } from '../../infrastructure/persistence/message-push
|
|||||||
import { QqbotMessagePublishBinding } from '../../infrastructure/persistence/message-push/qqbot-message-publish-binding.entity';
|
import { QqbotMessagePublishBinding } from '../../infrastructure/persistence/message-push/qqbot-message-publish-binding.entity';
|
||||||
import { QqbotMessagePublishTarget } from '../../infrastructure/persistence/message-push/qqbot-message-publish-target.entity';
|
import { QqbotMessagePublishTarget } from '../../infrastructure/persistence/message-push/qqbot-message-publish-target.entity';
|
||||||
import { QqbotMessageSubscription } from '../../infrastructure/persistence/message-push/qqbot-message-subscription.entity';
|
import { QqbotMessageSubscription } from '../../infrastructure/persistence/message-push/qqbot-message-subscription.entity';
|
||||||
import { QqbotSendAttemptError } from '../send/qqbot-send.error';
|
import {
|
||||||
|
QqbotSendAttemptError,
|
||||||
|
strictSendErrorSummary,
|
||||||
|
} from '../send/qqbot-send.error';
|
||||||
import { QqbotSendService } from '../send/qqbot-send.service';
|
import { QqbotSendService } from '../send/qqbot-send.service';
|
||||||
import {
|
import {
|
||||||
SYSTEM_MESSAGE_BATCH_SIZE,
|
SYSTEM_MESSAGE_BATCH_SIZE,
|
||||||
@ -61,16 +64,16 @@ export class SystemMessageDeliveryRunnerService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Processes up to one bounded batch of due deliveries and returns its claim count. */
|
/** Processes up to one bounded batch of due deliveries and returns its claim count. */
|
||||||
async runOnce(now: Date = new Date()): Promise<number> {
|
async runOnce(now?: Date): Promise<number> {
|
||||||
let claimed = 0;
|
let claimed = 0;
|
||||||
for (let index = 0; index < SYSTEM_MESSAGE_BATCH_SIZE; index += 1) {
|
for (let index = 0; index < SYSTEM_MESSAGE_BATCH_SIZE; index += 1) {
|
||||||
const token = await this.claimOne(now);
|
const token = await this.claimOne(now ?? new Date());
|
||||||
if (!token) break;
|
if (!token) break;
|
||||||
claimed += 1;
|
claimed += 1;
|
||||||
try {
|
try {
|
||||||
await this.processClaim(token, now);
|
await this.processClaim(token, now);
|
||||||
} catch {
|
} catch {
|
||||||
await this.handleUnexpectedClaimFailure(token, now);
|
await this.handleUnexpectedClaimFailure(token, now ?? new Date());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return claimed;
|
return claimed;
|
||||||
@ -115,8 +118,12 @@ export class SystemMessageDeliveryRunnerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Rechecks one claimed delivery before doing external I/O and then performs its final CAS. */
|
/** Rechecks one claimed delivery before doing external I/O and then performs its final CAS. */
|
||||||
private async processClaim(token: ClaimToken, now: Date): Promise<void> {
|
private async processClaim(
|
||||||
if (now.getTime() >= token.delivery.expiresAt.getTime()) {
|
token: ClaimToken,
|
||||||
|
fixedNow?: Date,
|
||||||
|
): Promise<void> {
|
||||||
|
const preparationNow = fixedNow ?? new Date();
|
||||||
|
if (preparationNow.getTime() >= token.delivery.expiresAt.getTime()) {
|
||||||
await this.finish(
|
await this.finish(
|
||||||
token,
|
token,
|
||||||
'failed',
|
'failed',
|
||||||
@ -126,17 +133,33 @@ export class SystemMessageDeliveryRunnerService {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const prepared = await this.prepare(token, now);
|
const prepared = await this.prepare(token);
|
||||||
if (prepared.kind === 'stale') return;
|
if (prepared.kind === 'stale') return;
|
||||||
if (prepared.kind === 'finish') {
|
if (prepared.kind === 'finish') {
|
||||||
if (prepared.status === 'waiting_ddns') {
|
if (prepared.status === 'waiting_ddns') {
|
||||||
|
const schedulingNow = fixedNow ?? new Date();
|
||||||
|
if (
|
||||||
|
schedulingNow.getTime() + SYSTEM_MESSAGE_DDNS_RECHECK_MS >=
|
||||||
|
token.delivery.expiresAt.getTime()
|
||||||
|
) {
|
||||||
|
await this.finish(
|
||||||
|
token,
|
||||||
|
'failed',
|
||||||
|
DELIVERY_EXPIRED,
|
||||||
|
'delivery deadline reached',
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.finish(
|
await this.finish(
|
||||||
token,
|
token,
|
||||||
'waiting_ddns',
|
'waiting_ddns',
|
||||||
prepared.code,
|
prepared.code,
|
||||||
'DDNS is not synchronized',
|
'DDNS is not synchronized',
|
||||||
null,
|
null,
|
||||||
new KtDateTime(now.getTime() + SYSTEM_MESSAGE_DDNS_RECHECK_MS),
|
new KtDateTime(
|
||||||
|
schedulingNow.getTime() + SYSTEM_MESSAGE_DDNS_RECHECK_MS,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await this.finish(
|
await this.finish(
|
||||||
@ -149,6 +172,17 @@ export class SystemMessageDeliveryRunnerService {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const sendNow = fixedNow ?? new Date();
|
||||||
|
if (sendNow.getTime() >= token.delivery.expiresAt.getTime()) {
|
||||||
|
await this.finish(
|
||||||
|
token,
|
||||||
|
'failed',
|
||||||
|
DELIVERY_EXPIRED,
|
||||||
|
'delivery deadline reached',
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const result = await this.sendService.sendStrictPlainText({
|
const result = await this.sendService.sendStrictPlainText({
|
||||||
attemptNumber: token.attempt,
|
attemptNumber: token.attempt,
|
||||||
@ -166,23 +200,23 @@ export class SystemMessageDeliveryRunnerService {
|
|||||||
token,
|
token,
|
||||||
'failed',
|
'failed',
|
||||||
error.code,
|
error.code,
|
||||||
this.safeMessage(error),
|
strictSendErrorSummary(error.code),
|
||||||
error.sendLogId,
|
error.sendLogId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.retryOrFail(
|
await this.retryOrFail(
|
||||||
token,
|
token,
|
||||||
now,
|
fixedNow ?? new Date(),
|
||||||
error.code,
|
error.code,
|
||||||
this.safeMessage(error),
|
strictSendErrorSummary(error.code),
|
||||||
error.sendLogId,
|
error.sendLogId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.retryOrFail(
|
await this.retryOrFail(
|
||||||
token,
|
token,
|
||||||
now,
|
fixedNow ?? new Date(),
|
||||||
TRANSIENT_ERROR,
|
TRANSIENT_ERROR,
|
||||||
'delivery transport unavailable',
|
'delivery transport unavailable',
|
||||||
null,
|
null,
|
||||||
@ -191,10 +225,7 @@ export class SystemMessageDeliveryRunnerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Locks current configuration before locking the delivery and returns a safe next action. */
|
/** Locks current configuration before locking the delivery and returns a safe next action. */
|
||||||
private async prepare(
|
private async prepare(token: ClaimToken): Promise<PreparedDelivery> {
|
||||||
token: ClaimToken,
|
|
||||||
now: Date,
|
|
||||||
): Promise<PreparedDelivery> {
|
|
||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
const event = await manager.getRepository(QqbotMessageEvent).findOne({
|
const event = await manager.getRepository(QqbotMessageEvent).findOne({
|
||||||
where: { id: token.delivery.messageEventId },
|
where: { id: token.delivery.messageEventId },
|
||||||
@ -285,11 +316,6 @@ export class SystemMessageDeliveryRunnerService {
|
|||||||
subscriptionConfig: subscription.sourceConfig,
|
subscriptionConfig: subscription.sourceConfig,
|
||||||
});
|
});
|
||||||
if (readiness.status === 'waiting_ddns') {
|
if (readiness.status === 'waiting_ddns') {
|
||||||
if (
|
|
||||||
now.getTime() + SYSTEM_MESSAGE_DDNS_RECHECK_MS >=
|
|
||||||
delivery.expiresAt.getTime()
|
|
||||||
)
|
|
||||||
return { code: DELIVERY_EXPIRED, kind: 'finish', status: 'failed' };
|
|
||||||
return {
|
return {
|
||||||
code: readiness.reasonCode,
|
code: readiness.reasonCode,
|
||||||
kind: 'finish',
|
kind: 'finish',
|
||||||
@ -428,11 +454,4 @@ export class SystemMessageDeliveryRunnerService {
|
|||||||
delivery.processingLeaseUntil?.getTime() === token.leaseUntil.getTime()
|
delivery.processingLeaseUntil?.getTime() === token.leaseUntil.getTime()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Converts unexpected error text into a bounded persistence-safe summary. */
|
|
||||||
private safeMessage(error: Error): string {
|
|
||||||
return String(error.message || 'delivery failed')
|
|
||||||
.replace(/[\r\n]/g, ' ')
|
|
||||||
.slice(0, 500);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,18 @@
|
|||||||
import type { QqbotSendAttemptErrorOptions } from '../../contract/message-push/qqbot-message-push.types';
|
import type { QqbotSendAttemptErrorOptions } from '../../contract/message-push/qqbot-message-push.types';
|
||||||
|
|
||||||
|
const STRICT_SEND_ERROR_SUMMARIES: Readonly<Record<string, string>> = {
|
||||||
|
account_unavailable: 'Configured QQBot account is unavailable',
|
||||||
|
invalid_target_type: 'Strict QQBot delivery target type is invalid',
|
||||||
|
onebot_disconnected: 'OneBot connection unavailable',
|
||||||
|
onebot_rejected: 'OneBot rejected the send action',
|
||||||
|
onebot_timeout: 'OneBot send timed out',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Maps a strict-send classification to its allowlisted non-sensitive summary. */
|
||||||
|
export function strictSendErrorSummary(code: string): string {
|
||||||
|
return STRICT_SEND_ERROR_SUMMARIES[code] ?? 'QQBot delivery failed';
|
||||||
|
}
|
||||||
|
|
||||||
/** Represents a retryable or permanent strict QQBot delivery attempt failure. */
|
/** Represents a retryable or permanent strict QQBot delivery attempt failure. */
|
||||||
export class QqbotSendAttemptError extends Error {
|
export class QqbotSendAttemptError extends Error {
|
||||||
readonly code: string;
|
readonly code: string;
|
||||||
@ -7,11 +20,11 @@ export class QqbotSendAttemptError extends Error {
|
|||||||
readonly sendLogId: null | string;
|
readonly sendLogId: null | string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preserves the stable retry classification and optional pending send-log identity.
|
* Preserves stable retry metadata while replacing all caller text with an allowlisted summary.
|
||||||
* @param options - The approved code, non-sensitive message, retry policy, and log ID.
|
* @param options - The stable code, ignored raw message, retry policy, and optional log ID.
|
||||||
*/
|
*/
|
||||||
constructor(options: QqbotSendAttemptErrorOptions) {
|
constructor(options: QqbotSendAttemptErrorOptions) {
|
||||||
super(options.message);
|
super(strictSendErrorSummary(options.code));
|
||||||
this.name = 'QqbotSendAttemptError';
|
this.name = 'QqbotSendAttemptError';
|
||||||
this.code = options.code;
|
this.code = options.code;
|
||||||
this.retryable = options.retryable;
|
this.retryable = options.retryable;
|
||||||
|
|||||||
@ -287,7 +287,7 @@ export class QqbotSendService {
|
|||||||
retryable: false,
|
retryable: false,
|
||||||
sendLogId: log!.id,
|
sendLogId: log!.id,
|
||||||
});
|
});
|
||||||
await this.markFailedLog(log!.id, message);
|
await this.markFailedLog(log!.id, error.message);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
await this.sendLogRepository.update(
|
await this.sendLogRepository.update(
|
||||||
@ -330,7 +330,7 @@ export class QqbotSendService {
|
|||||||
'OneBot send failed',
|
'OneBot send failed',
|
||||||
);
|
);
|
||||||
if (input.strict) {
|
if (input.strict) {
|
||||||
const error = this.toStrictSendError(err, message, log!.id);
|
const error = this.toStrictSendError(err, log!.id);
|
||||||
await this.markFailedLog(log!.id, error.message);
|
await this.markFailedLog(log!.id, error.message);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -370,26 +370,21 @@ export class QqbotSendService {
|
|||||||
/**
|
/**
|
||||||
* Converts strict transport and infrastructure failures to stable delivery classifications.
|
* Converts strict transport and infrastructure failures to stable delivery classifications.
|
||||||
* @param err - The original failure.
|
* @param err - The original failure.
|
||||||
* @param message - Its non-sensitive safe summary.
|
|
||||||
* @param sendLogId - The pending log ID, if creation succeeded.
|
* @param sendLogId - The pending log ID, if creation succeeded.
|
||||||
* @returns A stable strict delivery error.
|
* @returns A stable strict delivery error.
|
||||||
*/
|
*/
|
||||||
private toStrictSendError(
|
private toStrictSendError(err: unknown, sendLogId: null | string) {
|
||||||
err: unknown,
|
|
||||||
message: string,
|
|
||||||
sendLogId: null | string,
|
|
||||||
) {
|
|
||||||
if (err instanceof QqbotReverseWsActionError) {
|
if (err instanceof QqbotReverseWsActionError) {
|
||||||
return new QqbotSendAttemptError({
|
return new QqbotSendAttemptError({
|
||||||
code: err.code,
|
code: err.code,
|
||||||
message,
|
message: err.message,
|
||||||
retryable: true,
|
retryable: true,
|
||||||
sendLogId,
|
sendLogId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return new QqbotSendAttemptError({
|
return new QqbotSendAttemptError({
|
||||||
code: 'onebot_disconnected',
|
code: 'onebot_disconnected',
|
||||||
message,
|
message: 'OneBot send failed',
|
||||||
retryable: true,
|
retryable: true,
|
||||||
sendLogId,
|
sendLogId,
|
||||||
});
|
});
|
||||||
@ -406,11 +401,11 @@ export class QqbotSendService {
|
|||||||
err: unknown,
|
err: unknown,
|
||||||
sendLogId: null | string,
|
sendLogId: null | string,
|
||||||
): never {
|
): never {
|
||||||
|
if (strict) throw this.toStrictSendError(err, sendLogId);
|
||||||
const message = this.toolsService.getErrorMessage(
|
const message = this.toolsService.getErrorMessage(
|
||||||
err,
|
err,
|
||||||
'OneBot send failed',
|
'OneBot send failed',
|
||||||
);
|
);
|
||||||
if (strict) throw this.toStrictSendError(err, message, sendLogId);
|
|
||||||
throwVbenError(message);
|
throwVbenError(message);
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -873,11 +873,13 @@ describe('QqbotMessageSubscriptionService', () => {
|
|||||||
'disable',
|
'disable',
|
||||||
async (service: QqbotMessageSubscriptionService) =>
|
async (service: QqbotMessageSubscriptionService) =>
|
||||||
service.setEnabled('100', false),
|
service.setEnabled('100', false),
|
||||||
|
false,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'soft delete',
|
'soft delete',
|
||||||
async (service: QqbotMessageSubscriptionService) =>
|
async (service: QqbotMessageSubscriptionService) =>
|
||||||
service.remove('100'),
|
service.remove('100'),
|
||||||
|
false,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'canonical config change',
|
'canonical config change',
|
||||||
@ -892,19 +894,26 @@ describe('QqbotMessageSubscriptionService', () => {
|
|||||||
},
|
},
|
||||||
sourceKey: SOURCE_KEY,
|
sourceKey: SOURCE_KEY,
|
||||||
}),
|
}),
|
||||||
|
true,
|
||||||
],
|
],
|
||||||
])(
|
])(
|
||||||
'%s commits config and cancels only exact claimable rows without rewriting snapshots',
|
'%s commits config and applies the exact identity-sensitive cancellation fence',
|
||||||
async (_name, mutate) => {
|
async (_name, mutate, cancelsProcessing) => {
|
||||||
const harness = cancellationHarness();
|
const harness = cancellationHarness();
|
||||||
const before = structuredClone(harness.deliveries);
|
const before = structuredClone(harness.deliveries);
|
||||||
|
const cancellableStatuses = [
|
||||||
|
'waiting_ddns',
|
||||||
|
'pending',
|
||||||
|
'retry',
|
||||||
|
...(cancelsProcessing ? ['processing'] : []),
|
||||||
|
];
|
||||||
|
|
||||||
await mutate(harness.service);
|
await mutate(harness.service);
|
||||||
|
|
||||||
expect(harness.deliveries).toEqual(
|
expect(harness.deliveries).toEqual(
|
||||||
before.map((delivery) =>
|
before.map((delivery) =>
|
||||||
delivery.subscriptionId === '100' &&
|
delivery.subscriptionId === '100' &&
|
||||||
['waiting_ddns', 'pending', 'retry'].includes(delivery.status)
|
cancellableStatuses.includes(delivery.status)
|
||||||
? {
|
? {
|
||||||
...delivery,
|
...delivery,
|
||||||
nextAttemptAt: null,
|
nextAttemptAt: null,
|
||||||
@ -916,7 +925,7 @@ describe('QqbotMessageSubscriptionService', () => {
|
|||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
(harness.deliveryUpdates[0].status as { _value: string[] })._value,
|
(harness.deliveryUpdates[0].status as { _value: string[] })._value,
|
||||||
).toEqual(['waiting_ddns', 'pending', 'retry']);
|
).toEqual(cancellableStatuses);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -228,7 +228,7 @@ describe('QQBot strict plain-text sender', () => {
|
|||||||
expect(sendLogRepository.update).toHaveBeenCalledWith(
|
expect(sendLogRepository.update).toHaveBeenCalledWith(
|
||||||
{ id: 'log-1' },
|
{ id: 'log-1' },
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
errorMessage: 'bad target',
|
errorMessage: 'OneBot rejected the send action',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -291,6 +291,41 @@ describe('QQBot strict plain-text sender', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('redacts raw infrastructure details from both the typed error and failed send log', async () => {
|
||||||
|
const { busService, sendLogRepository, service } = createHarness();
|
||||||
|
busService.publish.mockRejectedValue(
|
||||||
|
new Error(
|
||||||
|
'password=broker-secret SELECT * FROM qqbot_private mqtt://admin@broker',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendStrictPlainText({
|
||||||
|
attemptNumber: 1,
|
||||||
|
deliveryId: 'delivery-1',
|
||||||
|
message: 'plain text',
|
||||||
|
selfId: '10001',
|
||||||
|
targetId: '20001',
|
||||||
|
targetType: 'group',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'onebot_disconnected',
|
||||||
|
message: 'OneBot connection unavailable',
|
||||||
|
retryable: true,
|
||||||
|
sendLogId: 'log-1',
|
||||||
|
});
|
||||||
|
expect(sendLogRepository.update).toHaveBeenCalledWith(
|
||||||
|
{ id: 'log-1' },
|
||||||
|
{
|
||||||
|
errorMessage: 'OneBot connection unavailable',
|
||||||
|
status: 'failed',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(sendLogRepository.update.mock.calls)).not.toContain(
|
||||||
|
'broker-secret',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects an invalid strict target before rate limiting, logs, or transport', async () => {
|
it('rejects an invalid strict target before rate limiting, logs, or transport', async () => {
|
||||||
const {
|
const {
|
||||||
busService,
|
busService,
|
||||||
@ -366,6 +401,25 @@ describe('QQBot strict plain-text sender', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('QQBot reverse WS action classification', () => {
|
describe('QQBot reverse WS action classification', () => {
|
||||||
|
it.each([
|
||||||
|
['account_unavailable', 'Configured QQBot account is unavailable'],
|
||||||
|
['invalid_target_type', 'Strict QQBot delivery target type is invalid'],
|
||||||
|
['onebot_rejected', 'OneBot rejected the send action'],
|
||||||
|
['onebot_timeout', 'OneBot send timed out'],
|
||||||
|
['onebot_disconnected', 'OneBot connection unavailable'],
|
||||||
|
['future_unknown_code', 'QQBot delivery failed'],
|
||||||
|
])('maps strict-send code %s to a stable safe summary', (code, message) => {
|
||||||
|
const error = new QqbotSendAttemptError({
|
||||||
|
code,
|
||||||
|
message: 'password=must-not-survive',
|
||||||
|
retryable: true,
|
||||||
|
sendLogId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(error.message).toBe(message);
|
||||||
|
expect(error.message).not.toContain('must-not-survive');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the typed strict send error fields explicit', () => {
|
it('keeps the typed strict send error fields explicit', () => {
|
||||||
const error = new QqbotSendAttemptError({
|
const error = new QqbotSendAttemptError({
|
||||||
code: 'onebot_timeout',
|
code: 'onebot_timeout',
|
||||||
|
|||||||
@ -279,6 +279,7 @@ function setup(seed: Partial<Store> = {}) {
|
|||||||
| null
|
| null
|
||||||
| ((authoritative: Store, where: Record<string, unknown>) => void) = null;
|
| ((authoritative: Store, where: Record<string, unknown>) => void) = null;
|
||||||
let beforePreparationDeliveryRead: null | ((draft: Store) => void) = null;
|
let beforePreparationDeliveryRead: null | ((draft: Store) => void) = null;
|
||||||
|
let afterClaimCommit: null | ((authoritative: Store) => void) = null;
|
||||||
let pauseNextClaim: null | { entered: () => void; resume: Promise<void> } =
|
let pauseNextClaim: null | { entered: () => void; resume: Promise<void> } =
|
||||||
null;
|
null;
|
||||||
|
|
||||||
@ -518,6 +519,11 @@ function setup(seed: Partial<Store> = {}) {
|
|||||||
repository(entity, draft, context, transactionLocks),
|
repository(entity, draft, context, transactionLocks),
|
||||||
});
|
});
|
||||||
mergeCommitted(draft, baseline);
|
mergeCommitted(draft, baseline);
|
||||||
|
if (context.claim && afterClaimCommit) {
|
||||||
|
const callback = afterClaimCommit;
|
||||||
|
afterClaimCommit = null;
|
||||||
|
callback(state);
|
||||||
|
}
|
||||||
operations.push(
|
operations.push(
|
||||||
context.claim
|
context.claim
|
||||||
? 'claim:commit'
|
? 'claim:commit'
|
||||||
@ -543,6 +549,9 @@ function setup(seed: Partial<Store> = {}) {
|
|||||||
sender as never,
|
sender as never,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
afterClaimCommit: (callback: null | ((authoritative: Store) => void)) => {
|
||||||
|
afterClaimCommit = callback;
|
||||||
|
},
|
||||||
adapter,
|
adapter,
|
||||||
beforeExternalUpdate: (
|
beforeExternalUpdate: (
|
||||||
callback:
|
callback:
|
||||||
@ -800,6 +809,32 @@ describe('System message delivery runner direct preflight contracts', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('5 loses ownership when a canonical config change cancels the claimed old-identity row', async () => {
|
||||||
|
const harness = setup();
|
||||||
|
harness.afterClaimCommit((state) => {
|
||||||
|
state.subscriptions[0].sourceConfig = {
|
||||||
|
ddnsRecordId: 'new-ddns-record',
|
||||||
|
portForwardId: RESOURCE_KEY,
|
||||||
|
};
|
||||||
|
Object.assign(state.deliveries[0], {
|
||||||
|
nextAttemptAt: null,
|
||||||
|
processingLeaseUntil: null,
|
||||||
|
status: 'cancelled',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.runner.runOnce(NOW);
|
||||||
|
|
||||||
|
expect(harness.adapter.resolveDelivery).not.toHaveBeenCalled();
|
||||||
|
expect(harness.sender.sendStrictPlainText).not.toHaveBeenCalled();
|
||||||
|
expect(harness.getState().deliveries[0]).toMatchObject({
|
||||||
|
attemptCount: 1,
|
||||||
|
lastErrorCode: 'old_error',
|
||||||
|
processingLeaseUntil: null,
|
||||||
|
status: 'cancelled',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('6-8 sends exact frozen input after both transactions commit and ignores current template identity', async () => {
|
it('6-8 sends exact frozen input after both transactions commit and ignores current template identity', async () => {
|
||||||
const harness = setup({
|
const harness = setup({
|
||||||
bindings: [binding({ templateId: 'new-template' })],
|
bindings: [binding({ templateId: 'new-template' })],
|
||||||
@ -1066,7 +1101,7 @@ describe('System message delivery runner direct preflight contracts', () => {
|
|||||||
expect(harness.getState().deliveries[0]).toMatchObject({
|
expect(harness.getState().deliveries[0]).toMatchObject({
|
||||||
attemptCount: 1,
|
attemptCount: 1,
|
||||||
lastErrorCode: 'onebot_timeout',
|
lastErrorCode: 'onebot_timeout',
|
||||||
lastErrorMessage: 'OneBot action timeout',
|
lastErrorMessage: 'OneBot send timed out',
|
||||||
nextAttemptAt: new KtDateTime(NOW.getTime() + 10_000),
|
nextAttemptAt: new KtDateTime(NOW.getTime() + 10_000),
|
||||||
processingLeaseUntil: null,
|
processingLeaseUntil: null,
|
||||||
sendLogId: '90001',
|
sendLogId: '90001',
|
||||||
@ -1074,6 +1109,31 @@ describe('System message delivery runner direct preflight contracts', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('17 never persists arbitrary text carried by an explicitly typed strict-send error', async () => {
|
||||||
|
const harness = setup();
|
||||||
|
harness.sender.sendStrictPlainText.mockRejectedValueOnce(
|
||||||
|
new QqbotSendAttemptError({
|
||||||
|
code: 'onebot_disconnected',
|
||||||
|
message:
|
||||||
|
'password=delivery-secret SELECT * FROM qqbot_private connection=mysql://root@db',
|
||||||
|
retryable: true,
|
||||||
|
sendLogId: '90002',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await harness.runner.runOnce(NOW);
|
||||||
|
|
||||||
|
expect(harness.getState().deliveries[0]).toMatchObject({
|
||||||
|
lastErrorCode: 'onebot_disconnected',
|
||||||
|
lastErrorMessage: 'OneBot connection unavailable',
|
||||||
|
sendLogId: '90002',
|
||||||
|
status: 'retry',
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(harness.getState().deliveries[0])).not.toContain(
|
||||||
|
'delivery-secret',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('18 caps exponential backoff at fifteen minutes', async () => {
|
it('18 caps exponential backoff at fifteen minutes', async () => {
|
||||||
const harness = setup({
|
const harness = setup({
|
||||||
deliveries: [delivery({ attemptCount: 19 })],
|
deliveries: [delivery({ attemptCount: 19 })],
|
||||||
@ -1232,6 +1292,65 @@ describe('System message delivery runner direct preflight contracts', () => {
|
|||||||
);
|
);
|
||||||
expect(harness.getState().deliveries[0].processingLeaseUntil).toBeNull();
|
expect(harness.getState().deliveries[0].processingLeaseUntil).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('samples a fresh production clock for each claim after a slow prior send', async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(NOW);
|
||||||
|
try {
|
||||||
|
const harness = setup({
|
||||||
|
deliveries: [
|
||||||
|
delivery({ id: '801', publishTargetId: '701' }),
|
||||||
|
delivery({ id: '802', publishTargetId: '702' }),
|
||||||
|
],
|
||||||
|
targets: [target(), target({ id: '702' })],
|
||||||
|
});
|
||||||
|
const observedLeases: Array<Date | null> = [];
|
||||||
|
harness.beforeExternalUpdate((state) => {
|
||||||
|
const processing = state.deliveries.find(
|
||||||
|
(item) => item.status === 'processing',
|
||||||
|
);
|
||||||
|
observedLeases.push(processing?.processingLeaseUntil ?? null);
|
||||||
|
});
|
||||||
|
harness.sender.sendStrictPlainText.mockImplementation(async () => {
|
||||||
|
if (harness.sender.sendStrictPlainText.mock.calls.length === 1) {
|
||||||
|
jest.setSystemTime(NOW.getTime() + 31_000);
|
||||||
|
}
|
||||||
|
return { logId: 'send-log' };
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.runner.runOnce();
|
||||||
|
|
||||||
|
expect(observedLeases).toEqual([
|
||||||
|
new KtDateTime(NOW.getTime() + SYSTEM_MESSAGE_LEASE_MS),
|
||||||
|
new KtDateTime(NOW.getTime() + 31_000 + SYSTEM_MESSAGE_LEASE_MS),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules a production retry from the fresh post-I/O clock', async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(NOW);
|
||||||
|
try {
|
||||||
|
const harness = setup();
|
||||||
|
harness.sender.sendStrictPlainText.mockImplementationOnce(async () => {
|
||||||
|
jest.setSystemTime(NOW.getTime() + 31_000);
|
||||||
|
throw new QqbotSendAttemptError({
|
||||||
|
code: 'onebot_timeout',
|
||||||
|
message: 'raw timeout detail',
|
||||||
|
retryable: true,
|
||||||
|
sendLogId: 'slow-log',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.runner.runOnce();
|
||||||
|
|
||||||
|
expect(harness.getState().deliveries[0].nextAttemptAt).toEqual(
|
||||||
|
new KtDateTime(NOW.getTime() + 41_000),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('System message coordinator direct lifecycle contracts', () => {
|
describe('System message coordinator direct lifecycle contracts', () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user