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:
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[] }) {
|
||||
|
||||
Reference in new issue
Block a user