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

+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 } };