import type { Logger } from '../utils/logger.js'; import { RedditRateLimitError, type RedditAccountProfile } from '../reddit/api.js'; import type { AccountEstablishmentRecord, AccountProfileMetricsInput, RedditAccountEstablishmentRepository, } from '../database/account-repositories.js'; import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js'; const DAY_MS = 24 * 60 * 60_000; const INITIAL_CHECK_DELAY_MS = 30_000; export interface AccountEstablishmentThresholds { minAccountAgeDays: number; minTotalKarma: number; minCommentKarma: number; requireVerifiedEmail: boolean; } export interface AccountEstablishmentSettings extends AccountEstablishmentThresholds { enabled: boolean; profileCheckIntervalMs: number; probeRetryBaseMs: number; probeRetryMaxMs: number; } export interface AccountProfileLookup { getAccountProfile(username: string): Promise; } /** Reddit's own codes for "this account may not create chats yet". */ const UNESTABLISHED_REDDIT_CODES: readonly RegExp[] = [ /new_account/i, /immature_account/i, /young_account/i, /account_age/i, /minimum_karma/i, ]; const UNESTABLISHED_MESSAGE_PATTERNS: readonly RegExp[] = [ /account (?:is )?too new/i, /\bnot old enough\b/i, /\baccount age\b/i, /\b(?:more|insufficient|not enough|low) karma\b/i, /\bkarma (?:requirement|threshold)\b/i, /\bverify your email\b/i, /\bemail (?:is )?not verified\b/i, /\bnot eligible to (?:send|start|create|chat)/i, /\bnew accounts? (?:cannot|can't|are not allowed)/i, ]; /** * Whether a failed invitation is Reddit refusing an unestablished account * rather than an ordinary throttle. `rate.invitation_limit_mature_account` is * deliberately excluded: it is the throttle applied to accounts that *are* * established. */ export function isUnestablishedFailure( reason: string | null | undefined, redditCode: string | null | undefined, ): boolean { if (redditCode && /mature_account/i.test(redditCode) && !/immature_account/i.test(redditCode)) { return false; } if (redditCode && UNESTABLISHED_REDDIT_CODES.some((pattern) => pattern.test(redditCode))) return true; return Boolean(reason) && UNESTABLISHED_MESSAGE_PATTERNS.some((pattern) => pattern.test(reason!)); } export interface ProfileVerdict { established: boolean; reasons: string[]; } /** * Judge an account's own profile against the configured thresholds. Missing * values never count against the account — Reddit omits fields for reasons * unrelated to how established the account is. */ export function evaluateAccountProfile( metrics: AccountProfileMetricsInput, thresholds: AccountEstablishmentThresholds, now: number = Date.now(), ): ProfileVerdict { const reasons: string[] = []; if (metrics.accountCreatedAt !== null) { const ageDays = (now - metrics.accountCreatedAt) / DAY_MS; if (ageDays < thresholds.minAccountAgeDays) { reasons.push( `Account is ${formatDays(ageDays)} old` + ` (below the configured floor of ${formatDays(thresholds.minAccountAgeDays)})`, ); } } if (metrics.totalKarma !== null && metrics.totalKarma < thresholds.minTotalKarma) { reasons.push(`Total karma is ${metrics.totalKarma} (below the configured floor of ${thresholds.minTotalKarma})`); } if (metrics.commentKarma !== null && metrics.commentKarma < thresholds.minCommentKarma) { reasons.push(`Comment karma is ${metrics.commentKarma} (below the configured floor of ${thresholds.minCommentKarma})`); } if (thresholds.requireVerifiedEmail && metrics.hasVerifiedEmail === false) { reasons.push('Email address is not verified'); } return { established: reasons.length === 0, reasons }; } function formatDays(days: number): string { if (days < 1) { const hours = Math.max(0, Math.floor(days * 24)); return `${hours} hour${hours === 1 ? '' : 's'}`; } const whole = Math.floor(days); return `${whole} day${whole === 1 ? '' : 's'}`; } /** * Tracks whether one account is established enough for Reddit to accept chat * invitations from it. * * Only one of the two signals may stop an account from sending. A rejected * invitation is authoritative — Reddit has told us directly — so it holds the * account with an escalating backoff and re-tests when the backoff expires. * * The profile check (age, karma, verified email) is advisory only. Reddit does * not publish the thresholds its chat gate actually applies, so ours are a * guess; letting a guess hold an account back would also stop it ever * attempting the invitation that would prove the guess wrong. It reports * shortfalls and nothing more. */ export class AccountEstablishmentService { private intervalHandle: NodeJS.Timeout | null = null; private initialHandle: NodeJS.Timeout | null = null; private checking = false; constructor( private readonly accountId: string, private readonly redditUsername: string, private readonly reddit: AccountProfileLookup, private readonly repository: RedditAccountEstablishmentRepository, private readonly settings: AccountEstablishmentSettings, private readonly logger: Logger, private readonly accountAuth: AccountAuthGate = denyAccountAuth, private readonly now: () => number = Date.now, ) {} start(): void { if (!this.settings.enabled || this.intervalHandle) return; const interval = this.settings.profileCheckIntervalMs; this.initialHandle = setTimeout(() => { this.initialHandle = null; void this.checkNow(); }, Math.min(INITIAL_CHECK_DELAY_MS, interval)); this.intervalHandle = setInterval(() => void this.checkNow(), interval); this.logger.info( `Account establishment check for u/${this.redditUsername}:` + ` scheduled every ${Math.round(interval / 3_600_000)} hours`, ); } stop(): void { if (this.initialHandle) clearTimeout(this.initialHandle); if (this.intervalHandle) clearInterval(this.intervalHandle); this.initialHandle = null; this.intervalHandle = null; } snapshot(): AccountEstablishmentRecord | null { return this.repository.get(this.accountId); } /** * Whether the account may attempt an outbound invitation. Only a live Reddit * rejection inside its backoff window holds it back. A profile shortfall * never does: the account has to be allowed to try, or the authoritative * signal can never arrive. */ isSendable(): boolean { if (!this.settings.enabled) return true; return !this.heldByProbe(this.repository.get(this.accountId)); } /** Refresh the profile snapshot and re-derive the verdict from it. */ async checkNow(): Promise { if (!this.settings.enabled || this.checking) return null; if (!this.accountAuth.isFullyAuthenticated()) return null; this.checking = true; try { const profile = await this.reddit.getAccountProfile(this.redditUsername); if (!profile) { this.logger.warn(`Account establishment check: u/${this.redditUsername} profile is unavailable`); return null; } const metrics: AccountProfileMetricsInput = { accountCreatedAt: profile.createdAt, linkKarma: profile.linkKarma, commentKarma: profile.commentKarma, totalKarma: profile.totalKarma, hasVerifiedEmail: profile.hasVerifiedEmail, }; const previous = this.repository.get(this.accountId); const verdict = evaluateAccountProfile(metrics, this.settings, this.now()); const held = this.heldByProbe(previous); // A live rejection outranks the profile thresholds: keep the account held // until its backoff expires even if the numbers now look acceptable. if (held) { const record = this.repository.saveProfileVerdict({ accountId: this.accountId, state: 'unestablished', reasons: [...previous!.reasons, ...verdict.reasons.filter((reason) => !previous!.reasons.includes(reason))], metrics, checkedAt: this.now(), retryAt: previous!.retryAt, }); return record; } // retryAt belongs to the probe backoff alone. A profile shortfall has no // hold to expire, so it leaves the field null. const record = this.repository.saveProfileVerdict({ accountId: this.accountId, state: verdict.established ? 'established' : 'unestablished', reasons: verdict.reasons, metrics, checkedAt: this.now(), retryAt: null, }); if (!verdict.established && previous?.state !== 'unestablished') { this.logger.info( `u/${this.redditUsername} is below the configured establishment thresholds` + ` but is still allowed to send: ${verdict.reasons.join('; ')}`, ); } if (verdict.established && previous && previous.state !== 'established') { this.logger.info(`u/${this.redditUsername} now meets the establishment thresholds`); } return record; } catch (error) { if (error instanceof RedditRateLimitError) { this.logger.warn( `Account establishment check paused for u/${this.redditUsername}: Reddit rate-limited profile lookups`, ); return null; } const message = error instanceof Error ? error.message : String(error); this.logger.warn(`Account establishment check failed for u/${this.redditUsername}: ${message}`); return null; } finally { this.checking = false; } } /** * Classify a failed invitation. Returns the recorded state when the failure * was Reddit refusing an unestablished account, and null otherwise so * ordinary throttles and recipient-side refusals are left alone. */ recordSendFailure( reason: string | null | undefined, redditCode: string | null | undefined = null, ): AccountEstablishmentRecord | null { if (!this.settings.enabled) return null; if (!isUnestablishedFailure(reason, redditCode)) return null; const previous = this.repository.get(this.accountId); const failures = (previous?.probeFailureCount ?? 0) + 1; const backoff = Math.min( this.settings.probeRetryBaseMs * 2 ** (failures - 1), this.settings.probeRetryMaxMs, ); const detail = redditCode ? `${reason ?? 'Invitation rejected'} (${redditCode})` : reason!; const record = this.repository.recordProbeFailure({ accountId: this.accountId, reason: `Reddit rejected an invitation from this account: ${detail}`, retryAt: this.now() + backoff, at: this.now(), }); this.logger.warn( `u/${this.redditUsername} held out of sending — Reddit rejected the invitation as an unestablished account` + ` (${detail}); retrying in ${Math.round(backoff / 3_600_000)}h`, ); return record; } /** A delivered invitation proves the account is established. */ recordSendSuccess(): AccountEstablishmentRecord | null { if (!this.settings.enabled) return null; const previous = this.repository.get(this.accountId); if (previous?.state === 'established' && previous.probeFailureCount === 0) return previous; const record = this.repository.recordProbeSuccess(this.accountId, this.now()); if (previous?.state === 'unestablished') { this.logger.info(`u/${this.redditUsername} delivered an invitation — clearing the unestablished flag`); } return record; } private heldByProbe(record: AccountEstablishmentRecord | null): boolean { return Boolean( record && record.state === 'unestablished' && record.probeFailureCount > 0 && record.retryAt !== null && this.now() < record.retryAt, ); } }