From 910576921b6ae043d5afcd2c7a4302e8fbf61e91 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 9 Aug 2026 16:22:48 +0100 Subject: [PATCH] Allow replies during room creation cooldowns --- src/services/chat/service.ts | 27 +++++++++++++++++++++------ src/services/chat/types.ts | 5 +++++ tests/chat-service-cooldown.test.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/services/chat/service.ts b/src/services/chat/service.ts index 2c0a3a8..d1dc546 100644 --- a/src/services/chat/service.ts +++ b/src/services/chat/service.ts @@ -582,7 +582,7 @@ export class ChatService { async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise { if (!this.accountAuth?.isFullyAuthenticated()) return false; const active = this.getInvitationCooldown(); - if (active) { + if (active && this.blocksExistingRoomSends(active)) { const secondsLeft = Math.ceil((active.until - Date.now()) / 1000); this.logger.warn( `[chat] skipping sendChatMessage — send cooldown active for ${secondsLeft}s (${active.redditCode ?? 'rate-limited'})`, @@ -607,7 +607,7 @@ export class ChatService { return true; } catch (error) { if (error instanceof MatrixRateLimitError) { - this.setInvitationCooldown(error); + this.setInvitationCooldown(error, 'all-sends'); return false; } this.logger.error('[chat] sendChatMessage failed:', error); @@ -690,7 +690,7 @@ export class ChatService { return { status: 'sent', chat }; } catch (error) { if (error instanceof MatrixRateLimitError) { - this.setInvitationCooldown(error); + this.setInvitationCooldown(error, 'room-creation'); const cooldown = this.getInvitationCooldown(); return { status: 'retryable', @@ -758,17 +758,21 @@ export class ChatService { } /** Record a cooldown after a `MatrixRateLimitError`. Test seam + reuse. */ - setInvitationCooldown(error: MatrixRateLimitError): void { + setInvitationCooldown( + error: MatrixRateLimitError, + scope: NonNullable = this.inferCooldownScope(error.redditCode), + ): void { const now = Date.now(); // Add a small safety margin so we don't probe again the very second // the homeserver thinks the window expired. const margin = 5_000; this.invitationCooldown = { until: now + error.retryAfterMs + margin, + scope, redditCode: error.redditCode, reason: - error.redditCode === 'rate.invitation_limit_mature_account' - ? "Reddit is throttling new chat invitations (\"you've created a lot of chats, take a break\")." + scope === 'room-creation' + ? 'Reddit is throttling new chat invitations; replies in existing chats can continue.' : `Matrix rate-limited: ${error.errcode ?? '429'}`, setAt: now, }; @@ -780,6 +784,17 @@ export class ChatService { void this.cache.persistCooldown(); } + private blocksExistingRoomSends(cooldown: InvitationCooldown): boolean { + return (cooldown.scope ?? this.inferCooldownScope(cooldown.redditCode)) === 'all-sends'; + } + + private inferCooldownScope(redditCode: string | null): NonNullable { + return redditCode === 'rate.room_creation_limit' + || redditCode === 'rate.invitation_limit_mature_account' + ? 'room-creation' + : 'all-sends'; + } + // ------------ internals ------------ /** Single delta /sync that updates cache + nextBatch and returns the union. */ diff --git a/src/services/chat/types.ts b/src/services/chat/types.ts index 5c6a607..d550af8 100644 --- a/src/services/chat/types.ts +++ b/src/services/chat/types.ts @@ -37,6 +37,11 @@ export interface ActiveChat { export interface InvitationCooldown { /** Wall-clock ms when sends may resume. */ until: number; + /** + * Operation class affected by the homeserver limit. Optional for caches + * written before cooldowns distinguished new rooms from existing-room sends. + */ + scope?: 'room-creation' | 'all-sends'; /** Reddit-side error code, e.g. `rate.invitation_limit_mature_account`. */ redditCode: string | null; /** Human-readable explanation suitable for the dashboard. */ diff --git a/tests/chat-service-cooldown.test.ts b/tests/chat-service-cooldown.test.ts index 354d045..042b3d0 100644 --- a/tests/chat-service-cooldown.test.ts +++ b/tests/chat-service-cooldown.test.ts @@ -115,6 +115,35 @@ describe('ChatService invitation cooldown', () => { expect(result).toBe(false); }); + it('allows replies in existing rooms during a room-creation cooldown', async () => { + const { svc, matrix } = buildService(); + matrix.sendTextMessage = async () => '$reply'; + svc.setInvitationCooldown( + new MatrixRateLimitError( + '429', + 60_000, + 'M_LIMIT_EXCEEDED', + 'rate.room_creation_limit', + ), + ); + // Simulate a cooldown persisted before operation scopes were recorded. + delete svc.getInvitationCooldown()!.scope; + + await expect(svc.sendChatMessage('!room:example.test', 'hi')).resolves.toBe(true); + }); + + it('treats a generic limit raised while creating a room as room-creation-only', async () => { + const { svc, matrix } = buildService(); + matrix.sendInitialDirectMessage = async () => { + throw new MatrixRateLimitError('429', 60_000, 'M_LIMIT_EXCEEDED', null); + }; + matrix.sendTextMessage = async () => '$reply'; + + await expect(svc.startConversationDetailed('someuser', 'opener', 't2_someuser')) + .resolves.toMatchObject({ status: 'retryable' }); + await expect(svc.sendChatMessage('!room:example.test', 'reply')).resolves.toBe(true); + }); + it('persists the cooldown to disk and restores it after a fresh service load', async () => { const logger = createMockLogger(); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-cooldown-'));