import { MatrixClient, MatrixError, IPresenceOpts, Method } from 'matrix-js-sdk'; import { BUSY_PRESENCE } from './busyPresence'; /** * Synapse rate-limits `PUT /presence/{user}/status` to ONE request per 10 s per * user by default (`rc_presence.per_user`: 0.1/s, burst 1) — and the limit is * shared across all of a user's devices. Our presence heartbeat (online/away on * visibility and activity changes) can therefore make a manual status save 429. * * `setPresenceWithRetry` waits out `Retry-After` (bounded) instead of failing, * and `PresenceDeduper` lets the heartbeat skip writes that would not change * anything, so it stops spending the budget in the first place. */ export type PresenceWrite = { // [Gitea #283] or MSC3026 busy (Do Not Disturb) presence: 'online' | 'offline' | 'unavailable' | typeof BUSY_PRESENCE; status_msg?: string; }; /** * `mx.setPresence` for a PresenceWrite. The SDK refuses anything but the three * spec presences ("Bad presence value"), so MSC3026 busy (#283) goes straight * to the same endpoint. */ export const writePresence = async ( mx: Pick & Partial>, write: PresenceWrite, ): Promise => { if (write.presence !== BUSY_PRESENCE) { await mx.setPresence(write as IPresenceOpts); return; } if (!mx.getSafeUserId || !mx.http) throw new Error('busy presence needs a full MatrixClient'); const path = `/presence/${encodeURIComponent(mx.getSafeUserId())}/status`; await mx.http.authedRequest(Method.Put, path, undefined, write); }; const sleep = (ms: number) => new Promise((r) => { setTimeout(r, ms); }); export async function setPresenceWithRetry( mx: Pick, write: PresenceWrite, opts: { maxWaitMs?: number; sleepFn?: (ms: number) => Promise } = {}, ): Promise { const maxWaitMs = opts.maxWaitMs ?? 25_000; const sleepFn = opts.sleepFn ?? sleep; let waited = 0; for (;;) { try { // eslint-disable-next-line no-await-in-loop await writePresence(mx, write); return; } catch (e) { if (!(e instanceof MatrixError) || e.httpStatus !== 429) throw e; const wait = Math.min(e.getRetryAfterMs() ?? 10_500, 15_000); if (waited + wait > maxWaitMs) throw e; waited += wait; // eslint-disable-next-line no-await-in-loop await sleepFn(wait); } } } const presenceKey = (write: PresenceWrite): string => `${write.presence}|${write.status_msg ?? ''}`; /** Remembers the last write that succeeded so identical heartbeats are skipped. */ export class PresenceDeduper { private last: string | null = null; shouldSend(write: PresenceWrite): boolean { return presenceKey(write) !== this.last; } sent(write: PresenceWrite): void { this.last = presenceKey(write); } reset(): void { this.last = null; } } /** A user-facing sentence for a failed status save. */ export const describePresenceError = (e: unknown): string => { if (e instanceof MatrixError) { if (e.httpStatus === 429) { return 'The server limits how often presence can change (once every 10 seconds, shared by all your devices). Please wait a moment and try again.'; } const serverText = (e.data as { error?: string } | undefined)?.error; if (serverText) return `Failed to save status: ${serverText}`; } return "Failed to save status — couldn't reach the server. Try again."; };