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>
333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|