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

+64
View File
@@ -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();