fix: 收紧严格发送响应判定

This commit is contained in:
sunlei 2026-07-24 09:13:51 +08:00
parent 9c1454b16a
commit 0517586c53
3 changed files with 112 additions and 33 deletions

View File

@ -272,7 +272,9 @@ export class QqbotSendService {
input.action, input.action,
input.actionParams, input.actionParams,
); );
const success = response.status === 'ok' || response.retcode === 0; const success = input.strict
? response.status === 'ok' && response.retcode === 0
: response.status === 'ok' || response.retcode === 0;
const messageId = response.data?.message_id const messageId = response.data?.message_id
? `${response.data.message_id}` ? `${response.data.message_id}`
: null; : null;

View File

@ -145,6 +145,11 @@ export class QqbotReverseWsService
} catch { } catch {
clearTimeout(timer); clearTimeout(timer);
this.pendingActions.delete(echo); this.pendingActions.delete(echo);
this.closeCurrentConnection(
selfId,
ws,
'OneBot connection send failed',
);
reject( reject(
new QqbotReverseWsActionError( new QqbotReverseWsActionError(
'onebot_disconnected', 'onebot_disconnected',
@ -314,12 +319,25 @@ export class QqbotReverseWsService
} }
/** /**
* QQBot * Retires the current connection after an action timeout using the stable timeout reason.
* @param selfId - ID * @param selfId - The account owning the timed-out OneBot action.
* @param ws - QQBot列表 closeTimedOutConnection * @param ws - The exact socket that timed out.
*/ */
private closeTimedOutConnection(selfId: string, ws: WebSocket) { private closeTimedOutConnection(selfId: string, ws: WebSocket) {
const reason = 'OneBot action timeout'; this.closeCurrentConnection(selfId, ws, 'OneBot action timeout');
}
/**
* Retires exactly the currently registered failing connection and publishes its offline state.
* @param selfId - The account owning the attempted OneBot action.
* @param ws - The exact socket that failed; replaced sockets must remain registered.
* @param reason - A non-sensitive reason used for socket close and offline observation.
*/
private closeCurrentConnection(
selfId: string,
ws: WebSocket,
reason: string,
) {
let closedCurrentConnection = false; let closedCurrentConnection = false;
[...this.connections.entries()].forEach(([key, connection]) => { [...this.connections.entries()].forEach(([key, connection]) => {
if (!key.startsWith(`${selfId}:`) || connection !== ws) return; if (!key.startsWith(`${selfId}:`) || connection !== ws) return;

View File

@ -196,35 +196,45 @@ describe('QQBot strict plain-text sender', () => {
}); });
}); });
it('rejects OneBot non-success responses permanently and retains the pending log', async () => { it.each([
const { messageService, reverseWs, sendLogRepository, service } = ['failed status despite zero retcode', { retcode: 0, status: 'failed' }],
createHarness(); ['ok status with nonzero retcode', { retcode: 1404, status: 'ok' }],
reverseWs.sendAction.mockResolvedValue({ ['missing status', { retcode: 0 }],
message: 'bad target', ['missing retcode', { status: 'ok' }],
retcode: 1404, ])(
status: 'failed', 'rejects strict OneBot responses with %s permanently and retains the pending log',
}); async (_caseName, response) => {
const { messageService, reverseWs, sendLogRepository, service } =
createHarness();
reverseWs.sendAction.mockResolvedValue({
message: 'bad target',
...response,
});
await expect( await expect(
service.sendStrictPlainText({ service.sendStrictPlainText({
attemptNumber: 1, attemptNumber: 1,
deliveryId: 'delivery-1', deliveryId: 'delivery-1',
message: 'plain text', message: 'plain text',
selfId: '10001', selfId: '10001',
targetId: '20001', targetId: '20001',
targetType: 'group', targetType: 'group',
}), }),
).rejects.toMatchObject({ ).rejects.toMatchObject({
code: 'onebot_rejected', code: 'onebot_rejected',
retryable: false, retryable: false,
sendLogId: 'log-1', sendLogId: 'log-1',
}); });
expect(sendLogRepository.update).toHaveBeenCalledWith( expect(sendLogRepository.update).toHaveBeenCalledWith(
{ id: 'log-1' }, { id: 'log-1' },
expect.objectContaining({ errorMessage: 'bad target', status: 'failed' }), expect.objectContaining({
); errorMessage: 'bad target',
expect(messageService.saveOutgoing).not.toHaveBeenCalled(); status: 'failed',
}); }),
);
expect(messageService.saveOutgoing).not.toHaveBeenCalled();
},
);
it.each([ it.each([
[ [
@ -320,6 +330,11 @@ describe('QQBot strict plain-text sender', () => {
sendLogRepository, sendLogRepository,
service, service,
} = createHarness(); } = createHarness();
reverseWs.sendAction.mockResolvedValue({
data: { message_id: 'message-1' },
retcode: 0,
status: 'failed',
});
await service.sendText({ await service.sendText({
message: '[CQ:at,qq=12345]', message: '[CQ:at,qq=12345]',
@ -343,6 +358,10 @@ describe('QQBot strict plain-text sender', () => {
expect(busService.publish).toHaveBeenCalledTimes(1); expect(busService.publish).toHaveBeenCalledTimes(1);
expect(sendLogRepository.save).toHaveBeenCalledTimes(1); expect(sendLogRepository.save).toHaveBeenCalledTimes(1);
expect(messageService.saveOutgoing).toHaveBeenCalledTimes(1); expect(messageService.saveOutgoing).toHaveBeenCalledTimes(1);
expect(sendLogRepository.update).toHaveBeenCalledWith(
{ id: 'log-1' },
expect.objectContaining({ status: 'success' }),
);
}); });
}); });
@ -436,4 +455,44 @@ describe('QQBot reverse WS action classification', () => {
jest.useRealTimers(); jest.useRealTimers();
} }
}); });
it('retires an OPEN socket whose send throws without leaving a timeout behind', async () => {
jest.useFakeTimers();
try {
const { accountService, busService, service } = createReverseWsHarness();
const ws = {
close: jest.fn(),
readyState: 1,
send: jest.fn(() => {
throw new Error('send failed');
}),
};
(service as any).connections.set('10001:Universal', ws);
await expect(
service.sendAction('10001', 'send_group_msg', {}),
).rejects.toMatchObject({ code: 'onebot_disconnected' });
expect((service as any).pendingActions.size).toBe(0);
expect((service as any).connections.has('10001:Universal')).toBe(false);
expect(ws.close).toHaveBeenCalledWith(
1011,
'OneBot connection send failed',
);
expect(accountService.markOffline).toHaveBeenCalledWith(
'10001',
'OneBot connection send failed',
);
expect(busService.publish).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ selfId: '10001', status: 'offline' }),
);
jest.advanceTimersByTime(10);
expect(ws.close).toHaveBeenCalledTimes(1);
expect(accountService.markOffline).toHaveBeenCalledTimes(1);
expect(busService.publish).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
});
}); });