feat: replace browser login with imported Reddit sessions

This commit is contained in:
jjbrunton committed 2026-08-08 16:48:59 +01:00
1 parent 2b0545afcf
commit c483c7b8be
76 files changed
+2048 -2828

No files matched your search

+99 -30
View File
@@ -4,52 +4,82 @@ import type { Logger } from './logger.js';
const DEFAULT_HOUR_WINDOW_MS = 3600000;
/** Loads timestamps of successful sends made since the supplied epoch time. */
export type SentMessageHistoryProvider = (since: number) => Promise<readonly number[]>;
export interface RateLimiterDependencies {
/**
* Optional durable history used to restore the rolling hourly limit after a
* process restart. The database adapter deliberately lives outside this
* utility so the limiter remains independently testable.
*/
sentMessageHistory?: SentMessageHistoryProvider;
now?: () => number;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
random?: () => number;
}
export class RateLimiter {
private queue: PQueue;
private lastMessageTime: number = 0;
private messagesThisHour: number = 0;
private hourStartTime: number = Date.now();
private lastMessageTime = 0;
private successfulMessageTimes: number[] = [];
private initialization: Promise<void> | null = null;
private readonly hourWindowMs: number;
private readonly now: () => number;
private readonly sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
private readonly random: () => number;
constructor(
private config: Config,
private logger: Logger,
hourWindowMs: number = DEFAULT_HOUR_WINDOW_MS,
dependencies: RateLimiterDependencies = {},
) {
this.queue = new PQueue({ concurrency: 1 });
this.hourWindowMs = hourWindowMs;
this.now = dependencies.now ?? Date.now;
this.sleep = dependencies.sleep ?? abortableSleep;
this.random = dependencies.random ?? Math.random;
if (dependencies.sentMessageHistory) {
this.initialization = this.loadHistory(dependencies.sentMessageHistory);
}
}
async throttle<T>(fn: () => Promise<T>, shouldCount: (result: T) => boolean = () => true): Promise<T> {
/** Wait for durable rate-limit history to be loaded before accepting work. */
async initialize(): Promise<void> {
await this.initialization;
}
async throttle<T>(
fn: () => Promise<T>,
shouldCount: (result: T) => boolean = () => true,
signal?: AbortSignal,
): Promise<T> {
return this.queue.add(async () => {
const now = Date.now();
await this.initialize();
throwIfAborted(signal);
const now = this.now();
this.pruneHistory(now);
// Reset hourly counter if needed
if (now - this.hourStartTime > this.hourWindowMs) {
this.messagesThisHour = 0;
this.hourStartTime = now;
this.logger.debug('Hourly rate limit counter reset');
}
// Check hourly limit
if (this.messagesThisHour >= this.config.rateLimit.maxMessagesPerHour) {
const waitTime = this.hourWindowMs - (now - this.hourStartTime);
// Enforce the limit over a rolling hour so restarts cannot reset it.
if (this.successfulMessageTimes.length >= this.config.rateLimit.maxMessagesPerHour) {
const oldestSend = this.successfulMessageTimes[0];
const waitTime = Math.max(0, oldestSend + this.hourWindowMs - now);
this.logger.info(`Hourly limit reached, waiting ${Math.round(waitTime / 60000)} minutes`);
await this.sleep(waitTime);
this.messagesThisHour = 0;
this.hourStartTime = Date.now();
await this.sleep(waitTime, signal);
this.pruneHistory(this.now());
}
// Enforce minimum delay with jitter
// We check against the previously completed message time
const timeSinceLastMessage = now - this.lastMessageTime;
const minDelay = this.config.rateLimit.minDelayBetweenMessagesMs;
const jitter = Math.random() * this.config.rateLimit.jitterMs;
const jitter = this.random() * this.config.rateLimit.jitterMs;
if (this.lastMessageTime > 0 && timeSinceLastMessage < minDelay) {
const waitTime = minDelay - timeSinceLastMessage + jitter;
this.logger.debug(`Rate limiting: waiting ${Math.round(waitTime / 1000)} seconds`);
await this.sleep(waitTime);
await this.sleep(waitTime, signal);
}
// Execute function
@@ -57,22 +87,37 @@ export class RateLimiter {
// Update stats only if shouldCount returns true
if (shouldCount(result)) {
this.lastMessageTime = Date.now();
this.messagesThisHour++;
this.lastMessageTime = this.now();
this.successfulMessageTimes.push(this.lastMessageTime);
}
return result;
}) as Promise<T>;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
private async loadHistory(provider: SentMessageHistoryProvider): Promise<void> {
const now = this.now();
const timestamps = await provider(now - this.hourWindowMs);
this.successfulMessageTimes = timestamps
.filter((timestamp) => Number.isFinite(timestamp) && timestamp > now - this.hourWindowMs && timestamp <= now)
.sort((a, b) => a - b);
this.lastMessageTime = this.successfulMessageTimes.at(-1) ?? 0;
}
private pruneHistory(now: number): void {
const cutoff = now - this.hourWindowMs;
const before = this.successfulMessageTimes.length;
this.successfulMessageTimes = this.successfulMessageTimes.filter((timestamp) => timestamp > cutoff);
if (before !== this.successfulMessageTimes.length) {
this.logger.debug('Hourly rate limit counter reset');
}
}
getStats(): { messagesThisHour: number; minutesSinceLastMessage: number } {
this.pruneHistory(this.now());
return {
messagesThisHour: this.messagesThisHour,
minutesSinceLastMessage: Math.round((Date.now() - this.lastMessageTime) / 60000),
messagesThisHour: this.successfulMessageTimes.length,
minutesSinceLastMessage: Math.round((this.now() - this.lastMessageTime) / 60000),
};
}
@@ -85,7 +130,8 @@ export class RateLimiter {
isHourlyLimitReached: boolean;
messagesRemaining: number;
} {
const now = Date.now();
const now = this.now();
this.pruneHistory(now);
const minDelay = this.config.rateLimit.minDelayBetweenMessagesMs;
const maxMessages = this.config.rateLimit.maxMessagesPerHour;
@@ -99,10 +145,10 @@ export class RateLimiter {
}
// Calculate when the hourly counter resets
const hourlyResetAt = this.hourStartTime + this.hourWindowMs;
const hourlyResetAt = (this.successfulMessageTimes[0] ?? now) + this.hourWindowMs;
// Check if hourly limit is reached
const isHourlyLimitReached = this.messagesThisHour >= maxMessages;
const isHourlyLimitReached = this.successfulMessageTimes.length >= maxMessages;
// If hourly limit is reached, next message is available when the hour resets
if (isHourlyLimitReached) {
@@ -113,7 +159,30 @@ export class RateLimiter {
nextMessageAvailableAt,
hourlyResetAt,
isHourlyLimitReached,
messagesRemaining: Math.max(0, maxMessages - this.messagesThisHour),
messagesRemaining: Math.max(0, maxMessages - this.successfulMessageTimes.length),
};
}
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw new DOMException('Operation aborted', 'AbortError');
}
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(new DOMException('Operation aborted', 'AbortError'));
return new Promise((resolve, reject) => {
let timer: NodeJS.Timeout;
const onAbort = (): void => {
clearTimeout(timer);
cleanup();
reject(new DOMException('Operation aborted', 'AbortError'));
};
const cleanup = (): void => signal?.removeEventListener('abort', onAbort);
const cleanupAndResolve = (): void => {
cleanup();
resolve();
};
timer = setTimeout(cleanupAndResolve, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}