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

+1
View File
@@ -8,6 +8,7 @@ export const AttentionKindSchema = z.enum([
'account-login-required',
'runtime-failed',
'invitation-cooldown',
'account-unestablished',
]);
export const AttentionItemSchema = z.strictObject({
+22
View File
@@ -6,6 +6,24 @@ export const RedditSessionStateSchema = z.union([
z.literal('login-required'),
z.literal('unreachable'),
]);
export const AccountEstablishmentStateSchema = z.union([
z.literal('unknown'),
z.literal('established'),
z.literal('unestablished'),
]);
export const AccountEstablishmentSummarySchema = z.strictObject({
state: AccountEstablishmentStateSchema,
reasons: z.array(z.string()),
accountAgeDays: z.number().nullable(),
linkKarma: z.number().nullable(),
commentKarma: z.number().nullable(),
totalKarma: z.number().nullable(),
hasVerifiedEmail: z.boolean().nullable(),
profileCheckedAt: z.number().nullable(),
lastProbeAt: z.number().nullable(),
lastProbeFailure: z.string().nullable(),
retryAt: z.number().nullable(),
});
export const RedditAccountSummarySchema = z.strictObject({
accountId: z.string().min(1),
redditUsername: z.string().min(1),
@@ -15,6 +33,8 @@ export const RedditAccountSummarySchema = z.strictObject({
createdAt: z.number(),
updatedAt: z.number(),
lastImportedAt: z.number(),
/** Absent on responses from a build that predates establishment tracking. */
establishment: AccountEstablishmentSummarySchema.nullable().optional(),
});
export const RedditAccountOwnerSchema = RedditAccountSummarySchema.pick({
accountId: true,
@@ -71,4 +91,6 @@ export type RedditSessionState = z.infer<typeof RedditSessionStateSchema>;
export type LoginStatusResponse = z.infer<typeof LoginStatusResponseSchema>;
export type RedditCookieImportRequest = z.infer<typeof RedditCookieImportRequestSchema>;
export type RedditAccountSummary = z.infer<typeof RedditAccountSummarySchema>;
export type AccountEstablishmentState = z.infer<typeof AccountEstablishmentStateSchema>;
export type AccountEstablishmentSummary = z.infer<typeof AccountEstablishmentSummarySchema>;
export type RedditAccountOwner = z.infer<typeof RedditAccountOwnerSchema>;
+1
View File
@@ -10,6 +10,7 @@ export const AccountSendingStateSchema = z.enum([
'matrix-cooldown',
'replies-prioritized',
'pacing',
'unestablished',
]);
export const AccountStatusSchema = z.strictObject({