Allow replies during room creation cooldowns

This commit is contained in:
jjbrunton committed 2026-08-09 16:22:48 +01:00
1 parent c630303b47
commit 910576921b
3 files changed
+55 -6

No files matched your search

+21 -6
View File
@@ -582,7 +582,7 @@ export class ChatService {
async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise<boolean> {
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<InvitationCooldown['scope']> = 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<InvitationCooldown['scope']> {
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. */
+5
View File
@@ -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. */
+29
View File
@@ -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-'));