From 89eb4583f8ba2eb77d0a5673b76215efc0450fe0 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Tue, 11 Aug 2026 09:14:47 +0100 Subject: [PATCH] feat: flag accounts Reddit considers unestablished and re-test them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/features/accounts/accounts-page.tsx | 90 ++++- .../src/features/accounts/accounts.css | 10 + .../src/features/attention/attention-page.tsx | 2 + .../src/features/overview/overview-page.tsx | 1 + packages/api-contracts/src/attention.ts | 1 + packages/api-contracts/src/session.ts | 22 ++ packages/api-contracts/src/stats.ts | 1 + src/bootstrap.ts | 7 +- src/config/index.ts | 30 ++ src/database/account-repositories.ts | 205 +++++++++++ src/database/client.ts | 21 ++ src/reddit/api.ts | 62 ++++ src/services/account-establishment.ts | 297 ++++++++++++++++ .../reddit-account-runtime-builder.ts | 25 +- src/services/reddit-account-runtime.ts | 5 + src/services/reddit-accounts.ts | 46 ++- src/web/routes/attention.ts | 20 +- src/web/routes/stats.ts | 3 + src/web/types.ts | 2 +- tests/account-establishment.test.ts | 332 ++++++++++++++++++ tests/dashboard/accounts.test.tsx | 64 ++++ tests/database.test.ts | 3 +- tests/reddit-account-runtime.test.ts | 32 ++ tests/reddit-api.test.ts | 59 ++++ tests/web/attention-route.test.ts | 96 +++++ 25 files changed, 1425 insertions(+), 11 deletions(-) create mode 100644 src/services/account-establishment.ts create mode 100644 tests/account-establishment.test.ts create mode 100644 tests/web/attention-route.test.ts diff --git a/apps/dashboard/src/features/accounts/accounts-page.tsx b/apps/dashboard/src/features/accounts/accounts-page.tsx index 6f459b3..0e6178b 100644 --- a/apps/dashboard/src/features/accounts/accounts-page.tsx +++ b/apps/dashboard/src/features/accounts/accounts-page.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import type { AccountEstablishmentSummary } from '../../../../../packages/api-contracts/src/index.js'; import { RedditUsernameLink } from '../../components/reddit-username-link.js'; import { useImportRedditSessionMutation, @@ -11,6 +12,68 @@ function errorMessage(error: Error | null): string { return error?.message ?? 'Something went wrong.'; } +function karmaLabel(value: number | null): string { + return value === null ? 'unknown' : value.toLocaleString(); +} + +function ageLabel(days: number | null): string { + if (days === null) return 'unknown'; + if (days < 1) return 'less than a day'; + return `${days.toLocaleString()} day${days === 1 ? '' : 's'}`; +} + +function retryLabel(retryAt: number | null): string { + if (retryAt === null) return 'Will be re-tested on the next profile check.'; + const minutes = Math.round((retryAt - Date.now()) / 60_000); + if (minutes <= 0) return 'Due for a re-test now.'; + if (minutes < 60) return `Next re-test in ${minutes} minute${minutes === 1 ? '' : 's'}.`; + const hours = Math.round(minutes / 60); + return `Next re-test in about ${hours} hour${hours === 1 ? '' : 's'}.`; +} + +/** + * A new account cannot start chats until Reddit considers it established. The + * bot holds it out of sending and re-tests it, so this panel exists to explain + * why an account looks idle. + */ +function EstablishmentDetail({ establishment }: { establishment: AccountEstablishmentSummary }) { + const isUnestablished = establishment.state === 'unestablished'; + return ( +
+ {isUnestablished ? ( + <> +

+ {establishment.reasons.length > 0 + ? establishment.reasons.join('; ') + : 'Reddit will not accept chat invitations from this account yet.'} +

+

Outbound sending is on hold. {retryLabel(establishment.retryAt)}

+ + ) : null} + {establishment.lastProbeFailure && !isUnestablished ? ( +

Last rejection: {establishment.lastProbeFailure}

+ ) : null} +
+
Age
{ageLabel(establishment.accountAgeDays)}
+
Post karma
{karmaLabel(establishment.linkKarma)}
+
Comment karma
{karmaLabel(establishment.commentKarma)}
+
Total karma
{karmaLabel(establishment.totalKarma)}
+
+
Verified email
+
{establishment.hasVerifiedEmail === null ? 'unknown' : establishment.hasVerifiedEmail ? 'Yes' : 'No'}
+
+
+
+ ); +} + +function establishmentPill(establishment: AccountEstablishmentSummary | null | undefined) { + if (!establishment || establishment.state === 'unknown') return null; + return establishment.state === 'unestablished' + ? Not established + : Established; +} + function sessionLabel(status: ReturnType['data']): string { if (!status) return 'Unknown'; if (!status.ready) return 'Browser unavailable'; @@ -57,6 +120,7 @@ export function AccountsPage() { : null; const accounts = accountsQuery.data ?? []; const pausedCount = accounts.filter((account) => !account.isRunning).length; + const unestablished = accounts.filter((account) => account.establishment?.state === 'unestablished'); return (
@@ -82,6 +146,20 @@ export function AccountsPage() {
) : null} + {unestablished.length > 0 ? ( +
+ + {unestablished.length === 1 + ? `u/${unestablished[0].redditUsername} is not established yet.` + : `${unestablished.length} accounts are not established yet.`} + + + Reddit will not accept chat invitations from a new account, so outbound sending is on hold + for it. The bot re-tests periodically and resumes on its own. + +
+ ) : null} + {statusQuery.isPending ? (
Loading Reddit access… @@ -127,9 +205,15 @@ export function AccountsPage() { {account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}
- - {account.isRunning ? 'Running' : 'Paused'} - +
+ {establishmentPill(account.establishment)} + + {account.isRunning ? 'Running' : 'Paused'} + +
+ {account.establishment && account.establishment.state !== 'unknown' ? ( + + ) : null} ))} {accountsQuery.isSuccess && accounts.length === 0 ? ( diff --git a/apps/dashboard/src/features/accounts/accounts.css b/apps/dashboard/src/features/accounts/accounts.css index 962a2a0..3dd1fac 100644 --- a/apps/dashboard/src/features/accounts/accounts.css +++ b/apps/dashboard/src/features/accounts/accounts.css @@ -8,3 +8,13 @@ .account-row > div { display: grid; gap: .2rem; min-width: 0; } .account-row span { color: var(--muted); font-size: .82rem; } .account-row .pill { font-size: .7rem; } +.account-row > .account-row-pills { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; } + +/* Establishment evidence sits on its own row beneath the account summary. */ +.account-establishment { flex-basis: 100%; display: grid; gap: .4rem; padding-top: .7rem; border-top: 1px solid var(--border); } +.account-establishment .account-establishment-reason { margin: 0; font-size: .82rem; } +.account-establishment .field-hint { margin: 0; } +.account-establishment-metrics { display: flex; flex-wrap: wrap; gap: .35rem 1.25rem; margin: 0; } +.account-establishment-metrics > div { display: grid; gap: .1rem; } +.account-establishment-metrics dt { color: var(--muted); font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; } +.account-establishment-metrics dd { margin: 0; font-size: .82rem; font-variant-numeric: tabular-nums; } diff --git a/apps/dashboard/src/features/attention/attention-page.tsx b/apps/dashboard/src/features/attention/attention-page.tsx index 9901aa9..430e09b 100644 --- a/apps/dashboard/src/features/attention/attention-page.tsx +++ b/apps/dashboard/src/features/attention/attention-page.tsx @@ -18,6 +18,8 @@ function actionHint(kind: AttentionItem['kind']): string { switch (kind) { case 'account-login-required': return 'No linked conversation — check Settings.'; + case 'account-unestablished': + return 'No linked conversation — check Accounts.'; case 'runtime-failed': case 'invitation-cooldown': default: diff --git a/apps/dashboard/src/features/overview/overview-page.tsx b/apps/dashboard/src/features/overview/overview-page.tsx index da2f43c..d09e4f6 100644 --- a/apps/dashboard/src/features/overview/overview-page.tsx +++ b/apps/dashboard/src/features/overview/overview-page.tsx @@ -89,6 +89,7 @@ const sendingStateCopy: Record; export type LoginStatusResponse = z.infer; export type RedditCookieImportRequest = z.infer; export type RedditAccountSummary = z.infer; +export type AccountEstablishmentState = z.infer; +export type AccountEstablishmentSummary = z.infer; export type RedditAccountOwner = z.infer; diff --git a/packages/api-contracts/src/stats.ts b/packages/api-contracts/src/stats.ts index 580b446..31f9bbf 100644 --- a/packages/api-contracts/src/stats.ts +++ b/packages/api-contracts/src/stats.ts @@ -10,6 +10,7 @@ export const AccountSendingStateSchema = z.enum([ 'matrix-cooldown', 'replies-prioritized', 'pacing', + 'unestablished', ]); export const AccountStatusSchema = z.strictObject({ diff --git a/src/bootstrap.ts b/src/bootstrap.ts index bb5c86c..492525d 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -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( diff --git a/src/config/index.ts b/src/config/index.ts index 0a4b7a0..5ec1322 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -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. */ diff --git a/src/database/account-repositories.ts b/src/database/account-repositories.ts index 0ae0132..f9f0f28 100644 --- a/src/database/account-repositories.ts +++ b/src/database/account-repositories.ts @@ -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, diff --git a/src/database/client.ts b/src/database/client.ts index 251bf39..d3f7cde 100644 --- a/src/database/client.ts +++ b/src/database/client.ts @@ -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; diff --git a/src/reddit/api.ts b/src/reddit/api.ts index 06202df..28e86b3 100644 --- a/src/reddit/api.ts +++ b/src/reddit/api.ts @@ -16,8 +16,34 @@ const RedditUserAboutResponseSchema = z.object({ }), }); +/** + * The subset of `/user//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 { + const cookieHeader = await this.getCookieHeader(); + const headers: Record = {}; + 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 { try { const cookieHeader = await this.getCookieHeader(); diff --git a/src/services/account-establishment.ts b/src/services/account-establishment.ts new file mode 100644 index 0000000..e4d23d3 --- /dev/null +++ b/src/services/account-establishment.ts @@ -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; +} + +/** 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 { + 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, + ); + } +} diff --git a/src/services/reddit-account-runtime-builder.ts b/src/services/reddit-account-runtime-builder.ts index 8be748d..2017bd1 100644 --- a/src/services/reddit-account-runtime-builder.ts +++ b/src/services/reddit-account-runtime-builder.ts @@ -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 { - 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(), diff --git a/src/services/reddit-account-runtime.ts b/src/services/reddit-account-runtime.ts index 8bf8610..269d1da 100644 --- a/src/services/reddit-account-runtime.ts +++ b/src/services/reddit-account-runtime.ts @@ -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; diff --git a/src/services/reddit-accounts.ts b/src/services/reddit-accounts.ts index 747d868..4cb45aa 100644 --- a/src/services/reddit-accounts.ts +++ b/src/services/reddit-accounts.ts @@ -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, }; } } diff --git a/src/web/routes/attention.ts b/src/web/routes/attention.ts index 348b1f6..6695940 100644 --- a/src/web/routes/attention.ts +++ b/src/web/routes/attention.ts @@ -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, }); } diff --git a/src/web/routes/stats.ts b/src/web/routes/stats.ts index 9d73cd1..75f14e0 100644 --- a/src/web/routes/stats.ts +++ b/src/web/routes/stats.ts @@ -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() diff --git a/src/web/types.ts b/src/web/types.ts index 2952e09..f391ce6 100644 --- a/src/web/types.ts +++ b/src/web/types.ts @@ -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; diff --git a/tests/account-establishment.test.ts b/tests/account-establishment.test.ts new file mode 100644 index 0000000..ff50a15 --- /dev/null +++ b/tests/account-establishment.test.ts @@ -0,0 +1,332 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { + RedditAccountEstablishmentRepository, + RedditAccountsRepository, +} from '../src/database/account-repositories.js'; +import { runMigrations } from '../src/database/client.js'; +import { + AccountEstablishmentService, + evaluateAccountProfile, + isUnestablishedFailure, + type AccountEstablishmentSettings, +} from '../src/services/account-establishment.js'; +import { RedditAccountsService } from '../src/services/reddit-accounts.js'; +import type { RedditAccountProfile } from '../src/reddit/api.js'; +import { RedditRateLimitError } from '../src/reddit/api.js'; +import { createMockLogger } from './mockLogger.js'; + +const DAY_MS = 24 * 60 * 60_000; +const NOW = 1_700_000_000_000; +const ACCOUNT_ID = '@t2_new:reddit.com'; + +const settings: AccountEstablishmentSettings = { + 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, +}; + +function profile(overrides: Partial = {}): RedditAccountProfile { + return { + createdAt: NOW - 30 * DAY_MS, + linkKarma: 100, + commentKarma: 50, + totalKarma: 150, + hasVerifiedEmail: true, + isSuspended: false, + ...overrides, + }; +} + +describe('unestablished failure classification', () => { + it('recognises the Reddit codes and messages that mean the account is too new', () => { + expect(isUnestablishedFailure(null, 'rate.invitation_limit_new_account')).toBe(true); + expect(isUnestablishedFailure(null, 'rate.invitation_limit_immature_account')).toBe(true); + expect(isUnestablishedFailure('Your account is too new to start chats', null)).toBe(true); + expect(isUnestablishedFailure('You need more karma to message this user', null)).toBe(true); + expect(isUnestablishedFailure('Please verify your email address first', null)).toBe(true); + expect(isUnestablishedFailure('New accounts cannot start chats', null)).toBe(true); + }); + + it('leaves ordinary throttles and recipient-side refusals alone', () => { + // This code is the throttle applied to accounts that *are* established. + expect(isUnestablishedFailure( + 'Reddit is throttling new chat invitations; replies in existing chats can continue.', + 'rate.invitation_limit_mature_account', + )).toBe(false); + expect(isUnestablishedFailure('Recipient does not accept chat requests', null)).toBe(false); + expect(isUnestablishedFailure('Reddit user is unavailable', null)).toBe(false); + expect(isUnestablishedFailure('Reddit chat invitation failed temporarily', 'rate.room_creation_limit')).toBe(false); + expect(isUnestablishedFailure(null, null)).toBe(false); + }); +}); + +describe('profile evaluation', () => { + it('reports every threshold the account falls short of', () => { + const verdict = evaluateAccountProfile({ + accountCreatedAt: NOW - 2 * DAY_MS, + linkKarma: 1, + commentKarma: 1, + totalKarma: 2, + hasVerifiedEmail: false, + }, { ...settings, requireVerifiedEmail: true }, NOW); + + expect(verdict.established).toBe(false); + expect(verdict.reasons).toEqual([ + 'Account is 2 days old (needs 7 days)', + 'Total karma is 2 (needs 20)', + 'Comment karma is 1 (needs 5)', + 'Email address is not verified', + ]); + }); + + it('passes an account that clears every threshold', () => { + const verdict = evaluateAccountProfile({ + accountCreatedAt: NOW - 30 * DAY_MS, + linkKarma: 100, + commentKarma: 50, + totalKarma: 150, + hasVerifiedEmail: true, + }, settings, NOW); + + expect(verdict).toEqual({ established: true, reasons: [] }); + }); + + it('never counts a missing value against the account', () => { + const verdict = evaluateAccountProfile({ + accountCreatedAt: null, + linkKarma: null, + commentKarma: null, + totalKarma: null, + hasVerifiedEmail: null, + }, { ...settings, requireVerifiedEmail: true }, NOW); + + expect(verdict).toEqual({ established: true, reasons: [] }); + }); +}); + +describe('AccountEstablishmentService', () => { + let db: Database.Database; + let repository: RedditAccountEstablishmentRepository; + let now: number; + + function createService( + getAccountProfile: (username: string) => Promise, + overrides: Partial = {}, + ) { + return new AccountEstablishmentService( + ACCOUNT_ID, + 'new-account', + { getAccountProfile }, + repository, + { ...settings, ...overrides }, + createMockLogger(), + { isFullyAuthenticated: () => true, invalidate: () => undefined }, + () => now, + ); + } + + beforeEach(() => { + now = NOW; + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + new RedditAccountsRepository(db).upsertVerified({ + accountId: ACCOUNT_ID, + redditUsername: 'new-account', + matrixUserId: ACCOUNT_ID, + cookiesJson: '[]', + matrixCredentialsJson: '{}', + now: NOW, + }); + repository = new RedditAccountEstablishmentRepository(db); + }); + + afterEach(() => db.close()); + + it('allows sending until something says otherwise', () => { + const service = createService(async () => profile()); + + expect(service.isSendable()).toBe(true); + expect(service.snapshot()).toBeNull(); + }); + + it('flags a brand new account from its profile and stores the evidence', async () => { + const service = createService(async () => profile({ + createdAt: NOW - DAY_MS, + linkKarma: 0, + commentKarma: 0, + totalKarma: 0, + })); + + const record = await service.checkNow(); + + expect(record).toMatchObject({ + state: 'unestablished', + accountCreatedAt: NOW - DAY_MS, + linkKarma: 0, + commentKarma: 0, + totalKarma: 0, + hasVerifiedEmail: true, + profileCheckedAt: NOW, + probeFailureCount: 0, + }); + expect(record!.reasons).toContain('Account is 1 day old (needs 7 days)'); + expect(service.isSendable()).toBe(false); + }); + + it('clears the flag once the account has aged and earned karma', async () => { + let current = profile({ createdAt: NOW - DAY_MS, linkKarma: 0, commentKarma: 0, totalKarma: 0 }); + const service = createService(async () => current); + + await service.checkNow(); + expect(service.isSendable()).toBe(false); + + now = NOW + 10 * DAY_MS; + current = profile({ createdAt: NOW - DAY_MS, commentKarma: 30, totalKarma: 60 }); + const record = await service.checkNow(); + + expect(record).toMatchObject({ state: 'established', reasons: [] }); + expect(service.isSendable()).toBe(true); + }); + + it('holds the account out of sending when Reddit rejects an invitation as too new', () => { + const service = createService(async () => profile()); + + const record = service.recordSendFailure( + 'Reddit chat invitation failed temporarily', + 'rate.invitation_limit_new_account', + ); + + expect(record).toMatchObject({ state: 'unestablished', probeFailureCount: 1 }); + expect(record!.retryAt).toBe(NOW + settings.probeRetryBaseMs); + expect(record!.lastProbeFailure).toContain('rate.invitation_limit_new_account'); + expect(service.isSendable()).toBe(false); + + now = NOW + settings.probeRetryBaseMs; + expect(service.isSendable()).toBe(true); + }); + + it('ignores failures that are not about how established the account is', () => { + const service = createService(async () => profile()); + + expect(service.recordSendFailure('Recipient does not accept chat requests', null)).toBeNull(); + expect(service.snapshot()).toBeNull(); + expect(service.isSendable()).toBe(true); + }); + + it('backs off further on each repeated rejection, up to the configured ceiling', () => { + const service = createService(async () => profile(), { probeRetryMaxMs: 6 * 60 * 60_000 }); + + expect(service.recordSendFailure('account is too new')!.retryAt).toBe(NOW + 4 * 60 * 60_000); + expect(service.recordSendFailure('account is too new')!.retryAt).toBe(NOW + 6 * 60 * 60_000); + expect(service.recordSendFailure('account is too new')).toMatchObject({ + probeFailureCount: 3, + retryAt: NOW + 6 * 60 * 60_000, + }); + }); + + it('treats a delivered invitation as proof the account is established', () => { + const service = createService(async () => profile()); + service.recordSendFailure('account is too new'); + + const record = service.recordSendSuccess(); + + expect(record).toMatchObject({ + state: 'established', + reasons: [], + probeFailureCount: 0, + retryAt: null, + lastProbeFailure: null, + }); + expect(service.isSendable()).toBe(true); + }); + + it('keeps a rejected account held even when its profile now looks acceptable', async () => { + const service = createService(async () => profile()); + service.recordSendFailure('account is too new'); + + now = NOW + 60_000; + const record = await service.checkNow(); + + expect(record).toMatchObject({ state: 'unestablished', retryAt: NOW + settings.probeRetryBaseMs }); + expect(record!.totalKarma).toBe(150); + expect(service.isSendable()).toBe(false); + }); + + it('leaves the stored verdict untouched when Reddit rate-limits the profile lookup', async () => { + const service = createService(async () => { throw new RedditRateLimitError(60_000); }); + + expect(await service.checkNow()).toBeNull(); + expect(service.snapshot()).toBeNull(); + expect(service.isSendable()).toBe(true); + }); + + it('does nothing at all when establishment tracking is disabled', async () => { + const getAccountProfile = vi.fn(async () => profile({ createdAt: NOW })); + const service = createService(getAccountProfile, { enabled: false }); + + service.start(); + expect(await service.checkNow()).toBeNull(); + expect(service.recordSendFailure('account is too new')).toBeNull(); + expect(getAccountProfile).not.toHaveBeenCalled(); + expect(service.isSendable()).toBe(true); + service.stop(); + }); + + it('publishes the stored evidence through the account listing the dashboard reads', async () => { + const accounts = new RedditAccountsService( + new RedditAccountsRepository(db), + {} as never, + { getSnapshot: () => ({ accountId: ACCOUNT_ID }) } as never, + undefined, + undefined, + repository, + ); + + expect(accounts.list()[0].establishment).toBeNull(); + + // Age is reported relative to the wall clock the dashboard renders in. + await createService(async () => profile({ + createdAt: Date.now() - 3 * DAY_MS, + linkKarma: 1, + commentKarma: 2, + totalKarma: 3, + hasVerifiedEmail: false, + })).checkNow(); + + expect(accounts.list()[0].establishment).toMatchObject({ + state: 'unestablished', + accountAgeDays: 3, + linkKarma: 1, + commentKarma: 2, + totalKarma: 3, + hasVerifiedEmail: false, + profileCheckedAt: NOW, + retryAt: NOW + settings.profileCheckIntervalMs, + }); + expect(accounts.list()[0].establishment!.reasons.length).toBeGreaterThan(0); + }); + + it('skips the check while the account session is not authenticated', async () => { + const getAccountProfile = vi.fn(async () => profile()); + const service = new AccountEstablishmentService( + ACCOUNT_ID, + 'new-account', + { getAccountProfile }, + repository, + settings, + createMockLogger(), + { isFullyAuthenticated: () => false, invalidate: () => undefined }, + () => now, + ); + + expect(await service.checkNow()).toBeNull(); + expect(getAccountProfile).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/dashboard/accounts.test.tsx b/tests/dashboard/accounts.test.tsx index 4dc1402..9604ac3 100644 --- a/tests/dashboard/accounts.test.tsx +++ b/tests/dashboard/accounts.test.tsx @@ -21,6 +21,20 @@ const account = { lastImportedAt: 2, }; +const establishment = { + state: 'unestablished' as const, + reasons: ['Account is 2 days old (needs 7 days)', 'Total karma is 4 (needs 20)'], + accountAgeDays: 2, + linkKarma: 1, + commentKarma: 3, + totalKarma: 4, + hasVerifiedEmail: false, + profileCheckedAt: 1_700_000_000_000, + lastProbeAt: null, + lastProbeFailure: null, + retryAt: Date.now() + 4 * 60 * 60_000, +}; + const server = setupServer( http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' })), http.get('/api/accounts', () => HttpResponse.json([])), @@ -81,6 +95,56 @@ describe('accounts page', () => { expect(screen.getByText('Waiting for a refreshed session')).toBeInTheDocument(); }); + it('explains why an unestablished account is not sending, with the profile evidence', async () => { + server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment }]))); + renderAccounts(); + + expect(await screen.findByText('u/example-account is not established yet.')).toBeInTheDocument(); + expect(screen.getByText(/Reddit will not accept chat invitations from a new account/)).toBeInTheDocument(); + expect(screen.getByText('Not established')).toBeInTheDocument(); + expect(screen.getByText( + 'Account is 2 days old (needs 7 days); Total karma is 4 (needs 20)', + )).toBeInTheDocument(); + expect(screen.getByText(/Next re-test in about 4 hours/)).toBeInTheDocument(); + + const metrics = screen.getByText('Comment karma').closest('dl')!; + expect(within(metrics).getByText('2 days')).toBeInTheDocument(); + expect(within(metrics).getByText('3')).toBeInTheDocument(); + expect(within(metrics).getByText('No')).toBeInTheDocument(); + }); + + it('marks an account that has cleared the thresholds as established', async () => { + server.use(http.get('/api/accounts', () => HttpResponse.json([{ + ...account, + establishment: { + ...establishment, + state: 'established', + reasons: [], + accountAgeDays: 400, + commentKarma: 900, + totalKarma: 1_200, + hasVerifiedEmail: true, + retryAt: null, + }, + }]))); + renderAccounts(); + + expect(await screen.findByText('Established')).toBeInTheDocument(); + expect(screen.queryByText(/not established yet/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Outbound sending is on hold/)).not.toBeInTheDocument(); + expect(screen.getByText('1,200')).toBeInTheDocument(); + }); + + it('shows no establishment detail before the first check has run', async () => { + server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment: null }]))); + renderAccounts(); + + expect(await screen.findByRole('link', { name: 'u/example-account' })).toBeInTheDocument(); + expect(screen.queryByText('Not established')).not.toBeInTheDocument(); + expect(screen.queryByText('Established')).not.toBeInTheDocument(); + expect(screen.queryByText('Comment karma')).not.toBeInTheDocument(); + }); + it('says plainly when no accounts have been added', async () => { renderAccounts(); diff --git a/tests/database.test.ts b/tests/database.test.ts index 8675d84..ca94147 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -107,7 +107,7 @@ describe('Database Client', () => { .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") .all() as { name: string }[]; // Existing domain/config tables plus account, conversation, sync and media ownership stores. - expect(tables.length).toBe(22); + expect(tables.length).toBe(23); expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([ 'candidate_queue', 'runtime_state', @@ -115,6 +115,7 @@ describe('Database Client', () => { 'media_library_sends', 'conversation_read_state', 'reddit_recipient_account_status', + 'reddit_account_establishment', ])); }); diff --git a/tests/reddit-account-runtime.test.ts b/tests/reddit-account-runtime.test.ts index bb82095..5dd3007 100644 --- a/tests/reddit-account-runtime.test.ts +++ b/tests/reddit-account-runtime.test.ts @@ -240,6 +240,7 @@ describe('account runtime isolation', () => { conversationAutomation: { hasActionableReplies }, chatService: { getInvitationCooldown: () => null }, rateLimiter: { getTimers: () => ({ nextMessageAvailableAt: null }) }, + establishmentService: { isSendable: () => true }, } as unknown as RedditAccountRuntimeResources, { start: vi.fn(), stop: vi.fn() }, ); @@ -250,6 +251,37 @@ describe('account runtime isolation', () => { expect(await runtime.canAcceptPost()).toBe(true); }); + it('does not accept new outreach while the account is not established yet', async () => { + const stored = account('too_new'); + const isSendable = vi.fn().mockReturnValue(false); + const runtime = new RedditAccountRuntime( + { + accountId: stored.accountId, + redditUsername: stored.redditUsername, + matrixUserId: stored.matrixUserId!, + cookies: [], + matrixCredentials: JSON.parse(stored.matrixCredentialsJson), + updatedAt: stored.updatedAt, + }, + { + accountAuth: { + isFullyAuthenticated: () => true, + ensureFullyAuthenticated: async () => true, + }, + conversationAutomation: { hasActionableReplies: vi.fn().mockResolvedValue(false) }, + chatService: { getInvitationCooldown: () => null }, + rateLimiter: { getTimers: () => ({ nextMessageAvailableAt: null }) }, + establishmentService: { isSendable }, + } as unknown as RedditAccountRuntimeResources, + { start: vi.fn(), stop: vi.fn() }, + ); + await runtime.start(); + + expect(await runtime.canAcceptPost()).toBe(false); + isSendable.mockReturnValue(true); + expect(await runtime.canAcceptPost()).toBe(true); + }); + it('uses the Reddit fullname for auth while retaining the Matrix runtime owner', () => { expect(runtimeAuthenticatedAccount( { matrixUserId: '@t2_1u57iqxxbv:reddit.com' }, diff --git a/tests/reddit-api.test.ts b/tests/reddit-api.test.ts index 521c194..72617a1 100644 --- a/tests/reddit-api.test.ts +++ b/tests/reddit-api.test.ts @@ -622,6 +622,65 @@ describe('RedditApiClient', () => { }); }); + describe('getAccountProfile', () => { + it('reads age, karma and email verification for one of our own accounts', async () => { + const mockAxios = { get: vi.fn() }; + vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance); + const client2 = new RedditApiClient(getTestConfig(), logger); + + mockAxios.get.mockResolvedValueOnce({ + data: { + data: { + created_utc: 1_700_000_000, + link_karma: 12, + comment_karma: 30, + total_karma: 42, + has_verified_email: true, + }, + }, + }); + + await expect(client2.getAccountProfile('new-account')).resolves.toEqual({ + createdAt: 1_700_000_000_000, + linkKarma: 12, + commentKarma: 30, + totalKarma: 42, + hasVerifiedEmail: true, + isSuspended: false, + }); + }); + + it('derives total karma when Reddit omits it and leaves absent fields null', async () => { + const mockAxios = { get: vi.fn() }; + vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance); + const client2 = new RedditApiClient(getTestConfig(), logger); + + mockAxios.get.mockResolvedValueOnce({ data: { data: { link_karma: 3, comment_karma: 4 } } }); + + await expect(client2.getAccountProfile('new-account')).resolves.toEqual({ + createdAt: null, + linkKarma: 3, + commentKarma: 4, + totalKarma: 7, + hasVerifiedEmail: null, + isSuspended: false, + }); + }); + + it('reports a missing profile as null and still propagates real failures', async () => { + const mockAxios = { get: vi.fn() }; + vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance); + const client2 = new RedditApiClient(getTestConfig(), logger); + + mockAxios.get.mockRejectedValueOnce({ isAxiosError: true, response: { status: 404 } }); + await expect(client2.getAccountProfile('gone')).resolves.toBeNull(); + + const temporary = { isAxiosError: true, response: { status: 503 } }; + mockAxios.get.mockRejectedValueOnce(temporary); + await expect(client2.getAccountProfile('new-account')).rejects.toBe(temporary); + }); + }); + describe('getNewPosts', () => { it('throws a permanent-disable signal for a plain 404 subreddit', async () => { const error = { isAxiosError: true, response: { status: 404 } }; diff --git a/tests/web/attention-route.test.ts b/tests/web/attention-route.test.ts new file mode 100644 index 0000000..3297201 --- /dev/null +++ b/tests/web/attention-route.test.ts @@ -0,0 +1,96 @@ +import express from 'express'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; +import { AttentionResponseSchema } from '../../packages/api-contracts/src/index.js'; +import type { WebContext } from '../../src/web/context.js'; +import { createAttentionRouter } from '../../src/web/routes/attention.js'; + +const servers: Array> = []; + +async function get(context: WebContext, path: string): Promise { + const app = express(); + app.use('/api', createAttentionRouter(context)); + const server = app.listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + return fetch(`http://127.0.0.1:${port}${path}`); +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); +}); + +function contextWith(accounts: unknown[]): WebContext { + return { + services: { + redditAccounts: { list: () => accounts }, + accountRuntimes: { list: () => [], get: () => null }, + }, + } as unknown as WebContext; +} + +const NOW = 1_700_000_000_000; +const account = { + accountId: '@t2_new:reddit.com', + redditUsername: 'new-account', + status: 'authenticated', + updatedAt: NOW, +}; + +describe('attention route establishment items', () => { + it('raises an unestablished account with its reasons and hold window', async () => { + const response = await get(contextWith([{ + ...account, + establishment: { + state: 'unestablished', + reasons: ['Account is 2 days old (needs 7 days)'], + retryAt: NOW + 4 * 60 * 60_000, + profileCheckedAt: NOW, + lastProbeAt: null, + }, + }]), '/api/attention'); + + expect(response.status).toBe(200); + const body = AttentionResponseSchema.parse(await response.json()); + expect(body.items).toHaveLength(1); + expect(body.items[0]).toMatchObject({ + id: 'establishment:@t2_new:reddit.com', + kind: 'account-unestablished', + severity: 'warning', + title: 'u/new-account is not established yet', + occurredAt: NOW, + owner: { accountId: account.accountId, redditUsername: 'new-account' }, + }); + expect(body.items[0].detail).toContain('Account is 2 days old (needs 7 days)'); + expect(body.items[0].detail).toContain('Outbound sending is on hold'); + }); + + it('stays quiet for established accounts and for accounts never checked', async () => { + const response = await get(contextWith([ + { ...account, establishment: { state: 'established', reasons: [], retryAt: null, profileCheckedAt: NOW, lastProbeAt: null } }, + { ...account, accountId: '@t2_other:reddit.com', redditUsername: 'other', establishment: null }, + ]), '/api/attention'); + + const body = AttentionResponseSchema.parse(await response.json()); + expect(body.items).toEqual([]); + }); + + it('reports an unestablished account that also needs a new session as two separate items', async () => { + const response = await get(contextWith([{ + ...account, + status: 'login-required', + establishment: { + state: 'unestablished', + reasons: [], + retryAt: null, + profileCheckedAt: NOW, + lastProbeAt: null, + }, + }]), '/api/attention'); + + const body = AttentionResponseSchema.parse(await response.json()); + expect(body.items.map((item) => item.kind).sort()) + .toEqual(['account-login-required', 'account-unestablished']); + }); +});