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

+332
View File
@@ -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> = {}): 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<RedditAccountProfile | null>,
overrides: Partial<AccountEstablishmentSettings> = {},
) {
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();
});
});
+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();
+2 -1
View File
@@ -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',
]));
});
+32
View File
@@ -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' },
+59
View File
@@ -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 } };
+96
View File
@@ -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<ReturnType<express.Express['listen']>> = [];
async function get(context: WebContext, path: string): Promise<Response> {
const app = express();
app.use('/api', createAttentionRouter(context));
const server = app.listen(0);
servers.push(server);
await new Promise<void>((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<void>((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']);
});
});