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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user