import type { Logger } from '../utils/logger.js'; import type { RedditUserAccountStatus } from '../reddit/api.js'; import { RedditRateLimitError } from '../reddit/api.js'; import type { RedditRecipientAccountStatusRepository } from '../database/account-repositories.js'; import type { ChatService } from './chat.js'; import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js'; export const RECIPIENT_ACCOUNT_CHECK_INTERVAL_MS = 4 * 60 * 60_000; const INITIAL_CHECK_DELAY_MS = 60_000; export interface RedditAccountStatusLookup { getUserAccountStatus(username: string): Promise; } /** Periodically records whether the Reddit users behind cached conversations still exist. */ export class RecipientAccountStatusService { private intervalHandle: NodeJS.Timeout | null = null; private initialHandle: NodeJS.Timeout | null = null; private checking = false; constructor( private readonly chatService: Pick, private readonly reddit: RedditAccountStatusLookup, private readonly repository: RedditRecipientAccountStatusRepository, private readonly logger: Logger, private readonly accountAuth: AccountAuthGate = denyAccountAuth, private readonly intervalMs = RECIPIENT_ACCOUNT_CHECK_INTERVAL_MS, private readonly now: () => number = Date.now, ) {} start(): void { if (this.intervalHandle) return; this.initialHandle = setTimeout(() => { this.initialHandle = null; void this.checkNow(); }, Math.min(INITIAL_CHECK_DELAY_MS, this.intervalMs)); this.intervalHandle = setInterval(() => void this.checkNow(), this.intervalMs); this.logger.info(`Recipient account check: scheduled every ${Math.round(this.intervalMs / 3_600_000)} hours`); } stop(): void { if (this.initialHandle) clearTimeout(this.initialHandle); if (this.intervalHandle) clearInterval(this.intervalHandle); this.initialHandle = null; this.intervalHandle = null; } async checkNow(): Promise { if (this.checking || !this.accountAuth.isFullyAuthenticated()) return 0; this.checking = true; let checked = 0; const startedAt = this.now(); try { const chats = await this.chatService.getCachedConversations(); const uniqueUsernames = new Map(); for (const chat of chats) { const username = chat.username.trim(); if (isCheckableUsername(username) && !uniqueUsernames.has(username.toLowerCase())) { uniqueUsernames.set(username.toLowerCase(), username); } } const usernames = [...uniqueUsernames.values()]; for (const username of usernames) { const previous = this.repository.get(username); if (previous && previous.checkedAt > startedAt - this.intervalMs) continue; try { const status = await this.reddit.getUserAccountStatus(username); this.repository.upsert(username, status, this.now()); checked += 1; if (status === 'deleted' && previous?.status !== 'deleted') { this.logger.info(`Recipient account check: u/${username} is deleted`); } } catch (error) { if (error instanceof RedditRateLimitError) { this.logger.warn('Recipient account check paused because Reddit rate-limited profile lookups'); break; } const message = error instanceof Error ? error.message : String(error); this.logger.warn(`Recipient account check failed for u/${username}: ${message}`); } } return checked; } finally { this.checking = false; } } } function isCheckableUsername(username: string): boolean { const normalized = username.toLowerCase(); return username.length > 0 && normalized !== 'unknown' && normalized !== '[deleted]' && !normalized.startsWith('t2_') && !username.startsWith('@'); }