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

+6 -1
View File
@@ -43,7 +43,11 @@ import { PushNotifier } from './services/push-notifier.js';
import { RedditSessionImporter } from './services/reddit-session-importer.js';
import { AccountAuthContext, authenticateCurrentAccount } from './services/account-auth-context.js';
import { ConfigStore } from './config/persistence.js';
import { RedditAccountsRepository, AccountContentRepository } from './database/account-repositories.js';
import {
RedditAccountsRepository,
AccountContentRepository,
RedditAccountEstablishmentRepository,
} from './database/account-repositories.js';
import { RedditAccountsService } from './services/reddit-accounts.js';
import type { Cookie } from 'playwright';
import { ChatCache } from './services/chat/cache.js';
@@ -371,6 +375,7 @@ export async function bootstrap(
accountAuth,
accountRuntimeManager,
browserClient,
new RedditAccountEstablishmentRepository(db),
);
if (config.web.enabled) {
webServer = new WebServer(
+30
View File
@@ -164,6 +164,36 @@ export const ConfigSchema = z.object({
killOnSellerKeyword: true,
aiReplyScreening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 },
}),
/**
* New Reddit accounts cannot start chats until Reddit considers them
* established. Rather than burning candidates on invitations Reddit will
* reject, an account is flagged and held out of outbound sending, then
* re-tested on an interval.
*/
accountEstablishment: z.object({
enabled: z.boolean().default(true),
/** How often an account's own age/karma profile is refreshed. */
profileCheckIntervalMs: z.number().int().min(60_000).default(6 * 60 * 60_000),
minAccountAgeDays: z.number().min(0).default(7),
minTotalKarma: z.number().int().min(0).default(20),
minCommentKarma: z.number().int().min(0).default(5),
requireVerifiedEmail: z.boolean().default(false),
/**
* How long to hold an account out of sending after Reddit rejects an
* invitation for being too new. Doubles per consecutive rejection.
*/
probeRetryBaseMs: z.number().int().min(60_000).default(4 * 60 * 60_000),
probeRetryMaxMs: z.number().int().min(60_000).default(48 * 60 * 60_000),
}).default({
enabled: true,
profileCheckIntervalMs: 6 * 60 * 60_000,
minAccountAgeDays: 7,
minTotalKarma: 20,
minCommentKarma: 5,
requireVerifiedEmail: false,
probeRetryBaseMs: 4 * 60 * 60_000,
probeRetryMaxMs: 48 * 60 * 60_000,
}),
pushNotifications: z.object({
enabled: z.boolean().default(false),
/** Base URL of the ntfy server, e.g. https://ntfy.example.com. */
+205
View File
@@ -389,6 +389,211 @@ export class RedditRecipientAccountStatusRepository {
}
}
export type AccountEstablishmentState = 'unknown' | 'established' | 'unestablished';
export interface AccountEstablishmentRecord {
accountId: string;
state: AccountEstablishmentState;
/** Why the account is not established yet. Empty when it is. */
reasons: string[];
accountCreatedAt: number | null;
linkKarma: number | null;
commentKarma: number | null;
totalKarma: number | null;
hasVerifiedEmail: boolean | null;
profileCheckedAt: number | null;
lastProbeAt: number | null;
lastProbeFailure: string | null;
probeFailureCount: number;
/** Earliest time the account may attempt an outbound invitation again. */
retryAt: number | null;
updatedAt: number;
}
export interface AccountProfileMetricsInput {
accountCreatedAt: number | null;
linkKarma: number | null;
commentKarma: number | null;
totalKarma: number | null;
hasVerifiedEmail: boolean | null;
}
type EstablishmentRow = {
account_id: string;
state: AccountEstablishmentState;
reasons_json: string;
account_created_at: number | null;
link_karma: number | null;
comment_karma: number | null;
total_karma: number | null;
has_verified_email: number | null;
profile_checked_at: number | null;
last_probe_at: number | null;
last_probe_failure: string | null;
probe_failure_count: number;
retry_at: number | null;
updated_at: number;
};
/**
* Whether each of our own Reddit accounts is established enough for Reddit to
* accept chat invitations from it, and the profile evidence behind that call.
*/
export class RedditAccountEstablishmentRepository {
constructor(private readonly db: Database.Database) {}
get(accountId: string): AccountEstablishmentRecord | null {
const row = this.db.prepare('SELECT * FROM reddit_account_establishment WHERE account_id = ?')
.get(accountId) as EstablishmentRow | undefined;
return row ? mapEstablishment(row) : null;
}
list(): AccountEstablishmentRecord[] {
const rows = this.db.prepare('SELECT * FROM reddit_account_establishment ORDER BY account_id')
.all() as EstablishmentRow[];
return rows.map(mapEstablishment);
}
/** Store a profile snapshot and the verdict derived from it. */
saveProfileVerdict(input: {
accountId: string;
state: AccountEstablishmentState;
reasons: string[];
metrics: AccountProfileMetricsInput;
checkedAt?: number;
retryAt?: number | null;
}): AccountEstablishmentRecord {
requireText(input.accountId, 'accountId');
const now = input.checkedAt ?? Date.now();
this.db.prepare(`
INSERT INTO reddit_account_establishment (
account_id, state, reasons_json, account_created_at, link_karma, comment_karma,
total_karma, has_verified_email, profile_checked_at, probe_failure_count, retry_at, updated_at
) VALUES (
@accountId, @state, @reasonsJson, @accountCreatedAt, @linkKarma, @commentKarma,
@totalKarma, @hasVerifiedEmail, @now, 0, @retryAt, @now
)
ON CONFLICT(account_id) DO UPDATE SET
state = excluded.state,
reasons_json = excluded.reasons_json,
account_created_at = excluded.account_created_at,
link_karma = excluded.link_karma,
comment_karma = excluded.comment_karma,
total_karma = excluded.total_karma,
has_verified_email = excluded.has_verified_email,
profile_checked_at = excluded.profile_checked_at,
retry_at = excluded.retry_at,
updated_at = excluded.updated_at
`).run({
accountId: input.accountId,
state: input.state,
reasonsJson: JSON.stringify(input.reasons),
accountCreatedAt: input.metrics.accountCreatedAt,
linkKarma: input.metrics.linkKarma,
commentKarma: input.metrics.commentKarma,
totalKarma: input.metrics.totalKarma,
hasVerifiedEmail: boolToInt(input.metrics.hasVerifiedEmail),
retryAt: input.retryAt ?? null,
now,
});
return this.getRequired(input.accountId);
}
/** Record that Reddit rejected an outbound invitation for being too new. */
recordProbeFailure(input: {
accountId: string;
reason: string;
retryAt: number;
at?: number;
}): AccountEstablishmentRecord {
requireText(input.accountId, 'accountId');
const now = input.at ?? Date.now();
this.db.prepare(`
INSERT INTO reddit_account_establishment (
account_id, state, reasons_json, last_probe_at, last_probe_failure,
probe_failure_count, retry_at, updated_at
) VALUES (
@accountId, 'unestablished', @reasonsJson, @now, @reason, 1, @retryAt, @now
)
ON CONFLICT(account_id) DO UPDATE SET
state = 'unestablished',
reasons_json = excluded.reasons_json,
last_probe_at = excluded.last_probe_at,
last_probe_failure = excluded.last_probe_failure,
probe_failure_count = reddit_account_establishment.probe_failure_count + 1,
retry_at = excluded.retry_at,
updated_at = excluded.updated_at
`).run({
accountId: input.accountId,
reasonsJson: JSON.stringify([input.reason]),
reason: input.reason,
retryAt: input.retryAt,
now,
});
return this.getRequired(input.accountId);
}
/**
* A delivered invitation is the only positive proof an account is
* established, so it clears the flag and the failure backoff outright.
*/
recordProbeSuccess(accountId: string, at: number = Date.now()): AccountEstablishmentRecord {
requireText(accountId, 'accountId');
this.db.prepare(`
INSERT INTO reddit_account_establishment (
account_id, state, reasons_json, last_probe_at, probe_failure_count, retry_at, updated_at
) VALUES (@accountId, 'established', '[]', @at, 0, NULL, @at)
ON CONFLICT(account_id) DO UPDATE SET
state = 'established',
reasons_json = '[]',
last_probe_at = excluded.last_probe_at,
last_probe_failure = NULL,
probe_failure_count = 0,
retry_at = NULL,
updated_at = excluded.updated_at
`).run({ accountId, at });
return this.getRequired(accountId);
}
private getRequired(accountId: string): AccountEstablishmentRecord {
const record = this.get(accountId);
if (!record) throw new Error(`Establishment state for ${accountId} was not persisted`);
return record;
}
}
function mapEstablishment(row: EstablishmentRow): AccountEstablishmentRecord {
return {
accountId: row.account_id,
state: row.state,
reasons: parseReasons(row.reasons_json),
accountCreatedAt: row.account_created_at,
linkKarma: row.link_karma,
commentKarma: row.comment_karma,
totalKarma: row.total_karma,
hasVerifiedEmail: row.has_verified_email === null ? null : row.has_verified_email === 1,
profileCheckedAt: row.profile_checked_at,
lastProbeAt: row.last_probe_at,
lastProbeFailure: row.last_probe_failure,
probeFailureCount: row.probe_failure_count,
retryAt: row.retry_at,
updatedAt: row.updated_at,
};
}
function parseReasons(json: string): string[] {
try {
const parsed = JSON.parse(json) as unknown;
return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [];
} catch {
return [];
}
}
function boolToInt(value: boolean | null): number | null {
return value === null ? null : value ? 1 : 0;
}
function mapConversation(row: ConversationRow): AccountConversationRecord {
return {
accountId: row.account_id,
+21
View File
@@ -456,6 +456,27 @@ export function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_reddit_recipient_account_status_due
ON reddit_recipient_account_status(checked_at);
`),
() => db.exec(`
CREATE TABLE IF NOT EXISTS reddit_account_establishment (
account_id TEXT PRIMARY KEY,
state TEXT NOT NULL CHECK(state IN ('unknown', 'established', 'unestablished')),
reasons_json TEXT NOT NULL DEFAULT '[]',
account_created_at INTEGER,
link_karma INTEGER,
comment_karma INTEGER,
total_karma INTEGER,
has_verified_email INTEGER,
profile_checked_at INTEGER,
last_probe_at INTEGER,
last_probe_failure TEXT,
probe_failure_count INTEGER NOT NULL DEFAULT 0,
retry_at INTEGER,
updated_at INTEGER NOT NULL,
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_reddit_account_establishment_state
ON reddit_account_establishment(state, retry_at);
`),
];
let currentVersion = db.pragma('user_version', { simple: true }) as number;
+62
View File
@@ -16,8 +16,34 @@ const RedditUserAboutResponseSchema = z.object({
}),
});
/**
* The subset of `/user/<name>/about.json` that says how established an account
* is. `has_verified_email` is only returned for the requesting user's own
* profile, so it stays nullable for anyone else.
*/
const RedditAccountProfileResponseSchema = z.object({
data: z.object({
created_utc: z.number().optional(),
link_karma: z.number().optional(),
comment_karma: z.number().optional(),
total_karma: z.number().optional(),
has_verified_email: z.boolean().nullish(),
is_suspended: z.boolean().optional(),
}),
});
export type RedditUserAccountStatus = 'active' | 'suspended' | 'deleted';
export interface RedditAccountProfile {
/** Account creation time in epoch milliseconds. */
createdAt: number | null;
linkKarma: number | null;
commentKarma: number | null;
totalKarma: number | null;
hasVerifiedEmail: boolean | null;
isSuspended: boolean;
}
export class RedditRateLimitError extends Error {
constructor(public readonly retryAfterMs: number) {
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
@@ -273,6 +299,42 @@ export class RedditApiClient {
}
}
/**
* Age, karma and email-verification for one account. Used to judge whether
* one of our own accounts is established enough for Reddit to let it start
* chats. Returns null when the profile no longer exists.
*/
async getAccountProfile(username: string): Promise<RedditAccountProfile | null> {
const cookieHeader = await this.getCookieHeader();
const headers: Record<string, string> = {};
if (cookieHeader) headers.Cookie = cookieHeader;
try {
const response = await this.scheduleRequest(() => this.client.get(
`/user/${encodeURIComponent(username)}/about.json`,
{ params: { raw_json: 1 }, headers },
));
const { data } = RedditAccountProfileResponseSchema.parse(response.data);
const totalKarma = data.total_karma
?? (data.link_karma === undefined && data.comment_karma === undefined
? undefined
: (data.link_karma ?? 0) + (data.comment_karma ?? 0));
return {
createdAt: data.created_utc === undefined ? null : Math.round(data.created_utc * 1000),
linkKarma: data.link_karma ?? null,
commentKarma: data.comment_karma ?? null,
totalKarma: totalKarma ?? null,
hasVerifiedEmail: data.has_verified_email ?? null,
isSuspended: data.is_suspended === true,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) return null;
const rateLimitError = this.toRateLimitError(error);
if (rateLimitError) throw rateLimitError;
throw error;
}
}
async getNewPosts(subreddit: string, limit = 25, type: 'image' | 'chat' = 'image'): Promise<RedditPost[]> {
try {
const cookieHeader = await this.getCookieHeader();
+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,
);
}
}
+23 -2
View File
@@ -15,6 +15,7 @@ import {
} from '../database/repositories.js';
import {
AccountContentRepository,
RedditAccountEstablishmentRepository,
RedditAccountsRepository,
RedditRecipientAccountStatusRepository,
} from '../database/account-repositories.js';
@@ -53,6 +54,7 @@ import { AiUsageRepository } from '../database/ai-usage-repository.js';
import { MediaLibraryRepository } from '../database/media-library-repository.js';
import { MediaLibraryExchangeService } from './media-library-exchange.js';
import { RecipientAccountStatusService } from './recipient-account-status.js';
import { AccountEstablishmentService } from './account-establishment.js';
export interface ConcreteRuntimeFactoryOptions {
config: Config;
@@ -225,6 +227,15 @@ async function buildRuntime(
logger,
accountAuth,
);
const establishmentService = new AccountEstablishmentService(
session.accountId,
session.redditUsername,
redditApi,
new RedditAccountEstablishmentRepository(db),
accountConfig.accountEstablishment,
logger,
accountAuth,
);
const responseChecker = new ResponseCheckerService(
chatPage, messagesRepo, successfulMessagesRepo, postsRepo, logger,
accountAuth, conversationOutcomesRepo,
@@ -277,7 +288,7 @@ async function buildRuntime(
activityLogRepo,
monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService,
mediaSyncService, mediaExchange, rateLimiter,
recipientAccountStatusService,
recipientAccountStatusService, establishmentService,
};
const lifecycle = new AccountRuntimeLoop(
resources, accountConfig, logger, isBotRunning, recipientDeduplicator,
@@ -318,6 +329,7 @@ class AccountRuntimeLoop {
this.controller = new AbortController();
this.resources.mediaSyncService.start();
this.resources.recipientAccountStatusService.start();
this.resources.establishmentService.start();
void this.resources.chatService.runStartupSync()
.then(() => this.resources.chatService.enrichUsernames())
.catch((error) => this.logger.error(`[account ${this.resources.accountAuth.getSnapshot().redditUsername}] startup sync failed:`, error));
@@ -328,6 +340,7 @@ class AccountRuntimeLoop {
this.controller?.abort();
this.resources.mediaSyncService.stop();
this.resources.recipientAccountStatusService.stop();
this.resources.establishmentService.stop();
await this.loop?.catch((error) => {
if (!(error instanceof DOMException && error.name === 'AbortError')) throw error;
});
@@ -415,7 +428,7 @@ class AccountRuntimeLoop {
}
async dispatchPreparedPost(processed: ProcessedPost, signal?: AbortSignal): Promise<PreparedDeliveryResult> {
const { postsRepo, messagesRepo, redditApi, rateLimiter, chatService } = this.resources;
const { postsRepo, messagesRepo, redditApi, rateLimiter, chatService, establishmentService } = this.resources;
const { post } = processed;
if (signal?.aborted) {
return { status: 'retryable', reason: 'Delivery interrupted by shutdown', retryAt: Date.now() + 5_000 };
@@ -446,6 +459,13 @@ class AccountRuntimeLoop {
);
if (start.status !== 'sent') {
this.recipientDeduplicator.release(post.author);
// The rejection reason is the only place Reddit tells us the account is
// too new to invite anyone; the code, when there is one, rides on the
// cooldown the chat service just recorded.
establishmentService.recordSendFailure(
start.reason,
chatService.getInvitationCooldown()?.redditCode ?? null,
);
if (start.status === 'terminal') {
postsRepo.setError(post.id, start.reason);
this.resources.activityLogRepo.insert({
@@ -457,6 +477,7 @@ class AccountRuntimeLoop {
}
postsRepo.markMessageSent(post.id);
establishmentService.recordSendSuccess();
messagesRepo.insert({
postId: post.id, recipient: post.author, subject: post.author,
message: processed.generatedMessage, sentAt: Date.now(),
+5
View File
@@ -35,6 +35,7 @@ import type { ResponseCheckerService } from './response-checker.js';
import type { RedditPost } from '../reddit/api.js';
import type { ConversationAutomationService } from './conversation-automation.js';
import type { RecipientAccountStatusService } from './recipient-account-status.js';
import type { AccountEstablishmentService } from './account-establishment.js';
import type { ProcessedPost, ProcessResult } from './processor.js';
/** Credentials passed to a factory. They must never be exposed by list/snapshot APIs. */
@@ -83,6 +84,7 @@ export interface RedditAccountRuntimeResources {
mediaService: MediaService;
mediaSyncService: MediaSyncService;
recipientAccountStatusService: RecipientAccountStatusService;
establishmentService: AccountEstablishmentService;
mediaExchange?: MediaLibraryExchangeService;
rateLimiter: RateLimiter;
}
@@ -197,6 +199,9 @@ export class RedditAccountRuntime {
if (this.state !== 'running') return false;
if (!this.resources.accountAuth.isFullyAuthenticated()
&& !(await this.resources.accountAuth.ensureFullyAuthenticated())) return false;
// An account Reddit has refused invitations from would burn the candidate,
// so it takes no new posts until its re-test window opens.
if (!this.resources.establishmentService.isSendable()) return false;
if (await this.resources.conversationAutomation.hasActionableReplies()) return false;
if (this.resources.chatService.getInvitationCooldown()) return false;
return this.resources.rateLimiter.getTimers().nextMessageAvailableAt === null;
+45 -1
View File
@@ -1,6 +1,11 @@
import { z } from 'zod';
import type { Cookie } from 'playwright';
import type { RedditAccountsRepository, RedditAccountPublicRecord } from '../database/account-repositories.js';
import type {
RedditAccountsRepository,
RedditAccountPublicRecord,
RedditAccountEstablishmentRepository,
AccountEstablishmentState,
} from '../database/account-repositories.js';
import type { AccountAuthContext } from './account-auth-context.js';
import type { RedditSessionImporter } from './reddit-session-importer.js';
import type { MatrixCredentials } from './chat/matrix/types.js';
@@ -19,6 +24,21 @@ const StoredMatrixCredentialsSchema = z.object({
capturedAt: z.number(),
});
/** How established the account looks, and the evidence behind that. */
export interface RedditAccountEstablishmentSummary {
state: AccountEstablishmentState;
reasons: string[];
accountAgeDays: number | null;
linkKarma: number | null;
commentKarma: number | null;
totalKarma: number | null;
hasVerifiedEmail: boolean | null;
profileCheckedAt: number | null;
lastProbeAt: number | null;
lastProbeFailure: string | null;
retryAt: number | null;
}
export interface RedditAccountSummary {
accountId: string;
redditUsername: string;
@@ -28,6 +48,8 @@ export interface RedditAccountSummary {
createdAt: number;
updatedAt: number;
lastImportedAt: number;
/** Null until the establishment check has run for this account. */
establishment: RedditAccountEstablishmentSummary | null;
}
export class RedditAccountsService {
@@ -37,6 +59,7 @@ export class RedditAccountsService {
private readonly auth: AccountAuthContext,
private readonly runtimes?: RedditAccountRuntimeManager,
private readonly verificationBrowser?: BrowserClient,
private readonly establishment?: RedditAccountEstablishmentRepository,
) {}
list(): RedditAccountSummary[] {
@@ -91,6 +114,27 @@ export class RedditAccountsService {
createdAt: account.createdAt,
updatedAt: account.updatedAt,
lastImportedAt: account.lastImportedAt ?? account.updatedAt,
establishment: this.toEstablishmentSummary(account.accountId),
};
}
private toEstablishmentSummary(accountId: string): RedditAccountEstablishmentSummary | null {
const record = this.establishment?.get(accountId);
if (!record) return null;
return {
state: record.state,
reasons: record.reasons,
accountAgeDays: record.accountCreatedAt === null
? null
: Math.max(0, Math.floor((Date.now() - record.accountCreatedAt) / 86_400_000)),
linkKarma: record.linkKarma,
commentKarma: record.commentKarma,
totalKarma: record.totalKarma,
hasVerifiedEmail: record.hasVerifiedEmail,
profileCheckedAt: record.profileCheckedAt,
lastProbeAt: record.lastProbeAt,
lastProbeFailure: record.lastProbeFailure,
retryAt: record.retryAt,
};
}
}
+18 -2
View File
@@ -3,7 +3,7 @@ import type { WebContext } from '../context.js';
interface AttentionItem {
id: string;
kind: 'human-required' | 'boundary' | 'failed-delivery' | 'account-login-required' | 'runtime-failed' | 'invitation-cooldown';
kind: 'human-required' | 'boundary' | 'failed-delivery' | 'account-login-required' | 'runtime-failed' | 'invitation-cooldown' | 'account-unestablished';
severity: 'warning' | 'critical';
title: string;
detail: string;
@@ -20,6 +20,22 @@ export function createAttentionRouter(ctx: WebContext): Router {
const items: AttentionItem[] = [];
const accounts = ctx.services.redditAccounts?.list() ?? [];
for (const account of accounts) {
const owner = { accountId: account.accountId, redditUsername: account.redditUsername };
if (account.establishment?.state === 'unestablished') {
const { reasons, retryAt } = account.establishment;
items.push({
id: `establishment:${account.accountId}`,
kind: 'account-unestablished',
severity: 'warning',
title: `u/${account.redditUsername} is not established yet`,
detail: `${reasons.length > 0 ? reasons.join('; ') : 'Reddit will not accept chat invitations from this account yet.'}`
+ ` Outbound sending is on hold${retryAt ? ` until ${new Date(retryAt).toISOString()}` : ''}.`,
occurredAt: account.establishment.profileCheckedAt
?? account.establishment.lastProbeAt
?? account.updatedAt,
owner,
});
}
if (account.status !== 'login-required') continue;
items.push({
id: `account:${account.accountId}`,
@@ -28,7 +44,7 @@ export function createAttentionRouter(ctx: WebContext): Router {
title: `u/${account.redditUsername} needs a new session`,
detail: 'Import a fresh extension session before this account can resume.',
occurredAt: account.updatedAt,
owner: { accountId: account.accountId, redditUsername: account.redditUsername },
owner,
});
}
+3
View File
@@ -59,6 +59,9 @@ export function createStatsRouter(ctx: WebContext): Router {
? 'login-required'
: runtimeState !== 'running'
? 'runtime-stopped'
: account.establishment?.state === 'unestablished'
&& !(account.establishment.retryAt !== null && account.establishment.retryAt <= Date.now())
? 'unestablished'
: accountCooldown
? 'matrix-cooldown'
: !ctx.getBotRunning()
+1 -1
View File
@@ -15,7 +15,7 @@ export interface BotStats {
redditUsername: string;
runtimeState: 'created' | 'starting' | 'running' | 'stopping' | 'stopped' | 'failed';
authenticated: boolean;
sendingState: 'ready' | 'bot-stopped' | 'runtime-stopped' | 'runtime-failed' | 'login-required' | 'matrix-cooldown' | 'replies-prioritized' | 'pacing';
sendingState: 'ready' | 'bot-stopped' | 'runtime-stopped' | 'runtime-failed' | 'login-required' | 'matrix-cooldown' | 'replies-prioritized' | 'pacing' | 'unestablished';
messagesThisHour: number;
maxMessagesPerHour: number;
nextMessageAvailableAt: number | null;