feat: flag accounts Reddit considers unestablished and re-test them

New accounts cannot start chats until Reddit considers them established.
Until now the bot would burn a candidate on every invitation Reddit was
always going to reject, with nothing in the dashboard explaining why the
account looked idle.

Two signals feed the verdict. A periodic profile check reads the
account's own age, karma and email verification from about.json and
compares them against configurable thresholds — that is what flags a
brand new account before it wastes anything. A rejected invitation is
authoritative: Reddit has told us directly, so the account is held out of
sending with a backoff that doubles per rejection, and a delivered
invitation clears the flag outright.

An unestablished account takes no new posts (canAcceptPost), raises an
attention item, reports an `unestablished` sending state, and shows its
age/karma evidence on the Accounts page. An unchecked account is always
allowed through, so a first run is never blocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-11 09:14:47 +01:00
1 parent c43e534cf1
commit 89eb4583f8
25 files changed
+1425 -11

No files matched your search

+297
View File
@@ -0,0 +1,297 @@
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<RedditAccountProfile | null>;
}
/** 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 (needs ${formatDays(thresholds.minAccountAgeDays)})`,
);
}
}
if (metrics.totalKarma !== null && metrics.totalKarma < thresholds.minTotalKarma) {
reasons.push(`Total karma is ${metrics.totalKarma} (needs ${thresholds.minTotalKarma})`);
}
if (metrics.commentKarma !== null && metrics.commentKarma < thresholds.minCommentKarma) {
reasons.push(`Comment karma is ${metrics.commentKarma} (needs ${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.
*
* Two signals feed the verdict. The profile check (age, karma, verified email)
* runs on an interval and is advisory — it is what flags a brand new account
* before it wastes a candidate. A rejected invitation is authoritative: Reddit
* has told us directly, so the account is held out of sending with an
* escalating backoff and re-tested when the backoff expires.
*/
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 an explicit
* unestablished verdict inside its backoff window holds it back — an
* unchecked account is allowed through so a first run is never blocked.
*/
isSendable(): boolean {
if (!this.settings.enabled) return true;
const record = this.repository.get(this.accountId);
if (!record || record.state !== 'unestablished') return true;
return record.retryAt !== null && this.now() >= record.retryAt;
}
/** Refresh the profile snapshot and re-derive the verdict from it. */
async checkNow(): Promise<AccountEstablishmentRecord | null> {
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;
}
const record = this.repository.saveProfileVerdict({
accountId: this.accountId,
state: verdict.established ? 'established' : 'unestablished',
reasons: verdict.reasons,
metrics,
checkedAt: this.now(),
retryAt: verdict.established ? null : this.now() + this.settings.profileCheckIntervalMs,
});
if (!verdict.established && previous?.state !== 'unestablished') {
this.logger.warn(
`u/${this.redditUsername} is not established yet: ${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,
);
}
}