fix: 收紧系统消息投递边界

This commit is contained in:
sunlei 2026-07-24 13:32:11 +08:00
parent d2fd59dcd4
commit d74545d17b
8 changed files with 288 additions and 57 deletions

View File

@ -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记录 IDQQBot记录 * 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({

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@ -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',

View File

@ -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', () => {