The profile check was allowed to set state 'unestablished', which made isSendable() false, which made canAcceptPost() false — so a flagged account never attempted an invitation and the functional probe could never run. The one signal with real authority was gated behind the one that is guessing, and because each check pushed retryAt forward by another interval, accounts that send perfectly well were held indefinitely. Reddit does not publish the thresholds its chat gate applies, so ours are a guess and are now advisory only. Sending is held solely by a live rejection, tracked as sendingHeld on the account summary and keyed off by the attention feed, the sending state and the Accounts page. Reason strings now say "below the configured floor of N" rather than "needs N", and the Accounts page distinguishes "Sending on hold" from "Below thresholds", stating outright that the latter is our own number and the account is still sending. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
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`
|
|
+ ` (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<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;
|
|
}
|
|
|
|
// 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,
|
|
);
|
|
}
|
|
}
|