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

+23 -2
View File
@@ -15,6 +15,7 @@ import {
} from '../database/repositories.js';
import {
AccountContentRepository,
RedditAccountEstablishmentRepository,
RedditAccountsRepository,
RedditRecipientAccountStatusRepository,
} from '../database/account-repositories.js';
@@ -53,6 +54,7 @@ import { AiUsageRepository } from '../database/ai-usage-repository.js';
import { MediaLibraryRepository } from '../database/media-library-repository.js';
import { MediaLibraryExchangeService } from './media-library-exchange.js';
import { RecipientAccountStatusService } from './recipient-account-status.js';
import { AccountEstablishmentService } from './account-establishment.js';
export interface ConcreteRuntimeFactoryOptions {
config: Config;
@@ -225,6 +227,15 @@ async function buildRuntime(
logger,
accountAuth,
);
const establishmentService = new AccountEstablishmentService(
session.accountId,
session.redditUsername,
redditApi,
new RedditAccountEstablishmentRepository(db),
accountConfig.accountEstablishment,
logger,
accountAuth,
);
const responseChecker = new ResponseCheckerService(
chatPage, messagesRepo, successfulMessagesRepo, postsRepo, logger,
accountAuth, conversationOutcomesRepo,
@@ -277,7 +288,7 @@ async function buildRuntime(
activityLogRepo,
monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService,
mediaSyncService, mediaExchange, rateLimiter,
recipientAccountStatusService,
recipientAccountStatusService, establishmentService,
};
const lifecycle = new AccountRuntimeLoop(
resources, accountConfig, logger, isBotRunning, recipientDeduplicator,
@@ -318,6 +329,7 @@ class AccountRuntimeLoop {
this.controller = new AbortController();
this.resources.mediaSyncService.start();
this.resources.recipientAccountStatusService.start();
this.resources.establishmentService.start();
void this.resources.chatService.runStartupSync()
.then(() => this.resources.chatService.enrichUsernames())
.catch((error) => this.logger.error(`[account ${this.resources.accountAuth.getSnapshot().redditUsername}] startup sync failed:`, error));
@@ -328,6 +340,7 @@ class AccountRuntimeLoop {
this.controller?.abort();
this.resources.mediaSyncService.stop();
this.resources.recipientAccountStatusService.stop();
this.resources.establishmentService.stop();
await this.loop?.catch((error) => {
if (!(error instanceof DOMException && error.name === 'AbortError')) throw error;
});
@@ -415,7 +428,7 @@ class AccountRuntimeLoop {
}
async dispatchPreparedPost(processed: ProcessedPost, signal?: AbortSignal): Promise<PreparedDeliveryResult> {
const { postsRepo, messagesRepo, redditApi, rateLimiter, chatService } = this.resources;
const { postsRepo, messagesRepo, redditApi, rateLimiter, chatService, establishmentService } = this.resources;
const { post } = processed;
if (signal?.aborted) {
return { status: 'retryable', reason: 'Delivery interrupted by shutdown', retryAt: Date.now() + 5_000 };
@@ -446,6 +459,13 @@ class AccountRuntimeLoop {
);
if (start.status !== 'sent') {
this.recipientDeduplicator.release(post.author);
// The rejection reason is the only place Reddit tells us the account is
// too new to invite anyone; the code, when there is one, rides on the
// cooldown the chat service just recorded.
establishmentService.recordSendFailure(
start.reason,
chatService.getInvitationCooldown()?.redditCode ?? null,
);
if (start.status === 'terminal') {
postsRepo.setError(post.id, start.reason);
this.resources.activityLogRepo.insert({
@@ -457,6 +477,7 @@ class AccountRuntimeLoop {
}
postsRepo.markMessageSent(post.id);
establishmentService.recordSendSuccess();
messagesRepo.insert({
postId: post.id, recipient: post.author, subject: post.author,
message: processed.generatedMessage, sentAt: Date.now(),