Allow replies during room creation cooldowns
This commit is contained in:
1 parent
c630303b47
commit
910576921b
3 files changed
+55
-6
No files matched your search
@@ -582,7 +582,7 @@ export class ChatService {
|
|||||||
async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise<boolean> {
|
async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise<boolean> {
|
||||||
if (!this.accountAuth?.isFullyAuthenticated()) return false;
|
if (!this.accountAuth?.isFullyAuthenticated()) return false;
|
||||||
const active = this.getInvitationCooldown();
|
const active = this.getInvitationCooldown();
|
||||||
if (active) {
|
if (active && this.blocksExistingRoomSends(active)) {
|
||||||
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
|
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`[chat] skipping sendChatMessage — send cooldown active for ${secondsLeft}s (${active.redditCode ?? 'rate-limited'})`,
|
`[chat] skipping sendChatMessage — send cooldown active for ${secondsLeft}s (${active.redditCode ?? 'rate-limited'})`,
|
||||||
@@ -607,7 +607,7 @@ export class ChatService {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof MatrixRateLimitError) {
|
if (error instanceof MatrixRateLimitError) {
|
||||||
this.setInvitationCooldown(error);
|
this.setInvitationCooldown(error, 'all-sends');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.logger.error('[chat] sendChatMessage failed:', error);
|
this.logger.error('[chat] sendChatMessage failed:', error);
|
||||||
@@ -690,7 +690,7 @@ export class ChatService {
|
|||||||
return { status: 'sent', chat };
|
return { status: 'sent', chat };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof MatrixRateLimitError) {
|
if (error instanceof MatrixRateLimitError) {
|
||||||
this.setInvitationCooldown(error);
|
this.setInvitationCooldown(error, 'room-creation');
|
||||||
const cooldown = this.getInvitationCooldown();
|
const cooldown = this.getInvitationCooldown();
|
||||||
return {
|
return {
|
||||||
status: 'retryable',
|
status: 'retryable',
|
||||||
@@ -758,17 +758,21 @@ export class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Record a cooldown after a `MatrixRateLimitError`. Test seam + reuse. */
|
/** 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();
|
const now = Date.now();
|
||||||
// Add a small safety margin so we don't probe again the very second
|
// Add a small safety margin so we don't probe again the very second
|
||||||
// the homeserver thinks the window expired.
|
// the homeserver thinks the window expired.
|
||||||
const margin = 5_000;
|
const margin = 5_000;
|
||||||
this.invitationCooldown = {
|
this.invitationCooldown = {
|
||||||
until: now + error.retryAfterMs + margin,
|
until: now + error.retryAfterMs + margin,
|
||||||
|
scope,
|
||||||
redditCode: error.redditCode,
|
redditCode: error.redditCode,
|
||||||
reason:
|
reason:
|
||||||
error.redditCode === 'rate.invitation_limit_mature_account'
|
scope === 'room-creation'
|
||||||
? "Reddit is throttling new chat invitations (\"you've created a lot of chats, take a break\")."
|
? 'Reddit is throttling new chat invitations; replies in existing chats can continue.'
|
||||||
: `Matrix rate-limited: ${error.errcode ?? '429'}`,
|
: `Matrix rate-limited: ${error.errcode ?? '429'}`,
|
||||||
setAt: now,
|
setAt: now,
|
||||||
};
|
};
|
||||||
@@ -780,6 +784,17 @@ export class ChatService {
|
|||||||
void this.cache.persistCooldown();
|
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 ------------
|
// ------------ internals ------------
|
||||||
|
|
||||||
/** Single delta /sync that updates cache + nextBatch and returns the union. */
|
/** Single delta /sync that updates cache + nextBatch and returns the union. */
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export interface ActiveChat {
|
|||||||
export interface InvitationCooldown {
|
export interface InvitationCooldown {
|
||||||
/** Wall-clock ms when sends may resume. */
|
/** Wall-clock ms when sends may resume. */
|
||||||
until: number;
|
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`. */
|
/** Reddit-side error code, e.g. `rate.invitation_limit_mature_account`. */
|
||||||
redditCode: string | null;
|
redditCode: string | null;
|
||||||
/** Human-readable explanation suitable for the dashboard. */
|
/** Human-readable explanation suitable for the dashboard. */
|
||||||
|
|||||||
@@ -115,6 +115,35 @@ describe('ChatService invitation cooldown', () => {
|
|||||||
expect(result).toBe(false);
|
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 () => {
|
it('persists the cooldown to disk and restores it after a fresh service load', async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-cooldown-'));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-cooldown-'));
|
||||||
|
|||||||
Reference in new issue
Block a user