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

@@ -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 (
<div className="account-establishment">
{isUnestablished ? (
<>
<p className="account-establishment-reason">
{establishment.reasons.length > 0
? establishment.reasons.join('; ')
: 'Reddit will not accept chat invitations from this account yet.'}
</p>
<p className="field-hint">Outbound sending is on hold. {retryLabel(establishment.retryAt)}</p>
</>
) : null}
{establishment.lastProbeFailure && !isUnestablished ? (
<p className="field-hint">Last rejection: {establishment.lastProbeFailure}</p>
) : null}
<dl className="account-establishment-metrics">
<div><dt>Age</dt><dd>{ageLabel(establishment.accountAgeDays)}</dd></div>
<div><dt>Post karma</dt><dd>{karmaLabel(establishment.linkKarma)}</dd></div>
<div><dt>Comment karma</dt><dd>{karmaLabel(establishment.commentKarma)}</dd></div>
<div><dt>Total karma</dt><dd>{karmaLabel(establishment.totalKarma)}</dd></div>
<div>
<dt>Verified email</dt>
<dd>{establishment.hasVerifiedEmail === null ? 'unknown' : establishment.hasVerifiedEmail ? 'Yes' : 'No'}</dd>
</div>
</dl>
</div>
);
}
function establishmentPill(establishment: AccountEstablishmentSummary | null | undefined) {
if (!establishment || establishment.state === 'unknown') return null;
return establishment.state === 'unestablished'
? <span className="pill warning">Not established</span>
: <span className="pill success">Established</span>;
}
function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['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 (
<div className="page accounts-page">
@@ -82,6 +146,20 @@ export function AccountsPage() {
</div>
) : null}
{unestablished.length > 0 ? (
<div className="state-banner is-warning" role="status">
<strong>
{unestablished.length === 1
? `u/${unestablished[0].redditUsername} is not established yet.`
: `${unestablished.length} accounts are not established yet.`}
</strong>
<span>
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.
</span>
</div>
) : null}
{statusQuery.isPending ? (
<div aria-label="Loading access" className="page-state" role="status">
<span className="spinner" />Loading Reddit access…
@@ -127,9 +205,15 @@ export function AccountsPage() {
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
<span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span>
</div>
<span className={account.isRunning ? 'pill success' : 'pill warning'}>
{account.isRunning ? 'Running' : 'Paused'}
</span>
<div className="account-row-pills">
{establishmentPill(account.establishment)}
<span className={account.isRunning ? 'pill success' : 'pill warning'}>
{account.isRunning ? 'Running' : 'Paused'}
</span>
</div>
{account.establishment && account.establishment.state !== 'unknown' ? (
<EstablishmentDetail establishment={account.establishment} />
) : null}
</div>
))}
{accountsQuery.isSuccess && accounts.length === 0 ? (
@@ -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; }
@@ -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:
@@ -89,6 +89,7 @@ const sendingStateCopy: Record<AccountStatus['sendingState'], { label: string; t
'matrix-cooldown': { label: 'Matrix cooldown', tone: 'warning', detail: 'Only this account is paused by Reddit chat throttling.' },
'replies-prioritized': { label: 'Replies first', tone: 'neutral', detail: 'Inbound replies are being handled before new outreach.' },
pacing: { label: 'Pacing', tone: 'neutral', detail: 'Waiting for the account sending window.' },
unestablished: { label: 'Not established', tone: 'warning', detail: 'Reddit will not accept chat invitations from this account yet.' },
};
function AccountStatusPanel({ accounts }: { accounts: readonly AccountStatus[] }) {