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
@@ -389,6 +389,211 @@ export class RedditRecipientAccountStatusRepository {
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountEstablishmentState = 'unknown' | 'established' | 'unestablished';
|
||||
|
||||
export interface AccountEstablishmentRecord {
|
||||
accountId: string;
|
||||
state: AccountEstablishmentState;
|
||||
/** Why the account is not established yet. Empty when it is. */
|
||||
reasons: string[];
|
||||
accountCreatedAt: number | null;
|
||||
linkKarma: number | null;
|
||||
commentKarma: number | null;
|
||||
totalKarma: number | null;
|
||||
hasVerifiedEmail: boolean | null;
|
||||
profileCheckedAt: number | null;
|
||||
lastProbeAt: number | null;
|
||||
lastProbeFailure: string | null;
|
||||
probeFailureCount: number;
|
||||
/** Earliest time the account may attempt an outbound invitation again. */
|
||||
retryAt: number | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface AccountProfileMetricsInput {
|
||||
accountCreatedAt: number | null;
|
||||
linkKarma: number | null;
|
||||
commentKarma: number | null;
|
||||
totalKarma: number | null;
|
||||
hasVerifiedEmail: boolean | null;
|
||||
}
|
||||
|
||||
type EstablishmentRow = {
|
||||
account_id: string;
|
||||
state: AccountEstablishmentState;
|
||||
reasons_json: string;
|
||||
account_created_at: number | null;
|
||||
link_karma: number | null;
|
||||
comment_karma: number | null;
|
||||
total_karma: number | null;
|
||||
has_verified_email: number | null;
|
||||
profile_checked_at: number | null;
|
||||
last_probe_at: number | null;
|
||||
last_probe_failure: string | null;
|
||||
probe_failure_count: number;
|
||||
retry_at: number | null;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether each of our own Reddit accounts is established enough for Reddit to
|
||||
* accept chat invitations from it, and the profile evidence behind that call.
|
||||
*/
|
||||
export class RedditAccountEstablishmentRepository {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
get(accountId: string): AccountEstablishmentRecord | null {
|
||||
const row = this.db.prepare('SELECT * FROM reddit_account_establishment WHERE account_id = ?')
|
||||
.get(accountId) as EstablishmentRow | undefined;
|
||||
return row ? mapEstablishment(row) : null;
|
||||
}
|
||||
|
||||
list(): AccountEstablishmentRecord[] {
|
||||
const rows = this.db.prepare('SELECT * FROM reddit_account_establishment ORDER BY account_id')
|
||||
.all() as EstablishmentRow[];
|
||||
return rows.map(mapEstablishment);
|
||||
}
|
||||
|
||||
/** Store a profile snapshot and the verdict derived from it. */
|
||||
saveProfileVerdict(input: {
|
||||
accountId: string;
|
||||
state: AccountEstablishmentState;
|
||||
reasons: string[];
|
||||
metrics: AccountProfileMetricsInput;
|
||||
checkedAt?: number;
|
||||
retryAt?: number | null;
|
||||
}): AccountEstablishmentRecord {
|
||||
requireText(input.accountId, 'accountId');
|
||||
const now = input.checkedAt ?? Date.now();
|
||||
this.db.prepare(`
|
||||
INSERT INTO reddit_account_establishment (
|
||||
account_id, state, reasons_json, account_created_at, link_karma, comment_karma,
|
||||
total_karma, has_verified_email, profile_checked_at, probe_failure_count, retry_at, updated_at
|
||||
) VALUES (
|
||||
@accountId, @state, @reasonsJson, @accountCreatedAt, @linkKarma, @commentKarma,
|
||||
@totalKarma, @hasVerifiedEmail, @now, 0, @retryAt, @now
|
||||
)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
state = excluded.state,
|
||||
reasons_json = excluded.reasons_json,
|
||||
account_created_at = excluded.account_created_at,
|
||||
link_karma = excluded.link_karma,
|
||||
comment_karma = excluded.comment_karma,
|
||||
total_karma = excluded.total_karma,
|
||||
has_verified_email = excluded.has_verified_email,
|
||||
profile_checked_at = excluded.profile_checked_at,
|
||||
retry_at = excluded.retry_at,
|
||||
updated_at = excluded.updated_at
|
||||
`).run({
|
||||
accountId: input.accountId,
|
||||
state: input.state,
|
||||
reasonsJson: JSON.stringify(input.reasons),
|
||||
accountCreatedAt: input.metrics.accountCreatedAt,
|
||||
linkKarma: input.metrics.linkKarma,
|
||||
commentKarma: input.metrics.commentKarma,
|
||||
totalKarma: input.metrics.totalKarma,
|
||||
hasVerifiedEmail: boolToInt(input.metrics.hasVerifiedEmail),
|
||||
retryAt: input.retryAt ?? null,
|
||||
now,
|
||||
});
|
||||
return this.getRequired(input.accountId);
|
||||
}
|
||||
|
||||
/** Record that Reddit rejected an outbound invitation for being too new. */
|
||||
recordProbeFailure(input: {
|
||||
accountId: string;
|
||||
reason: string;
|
||||
retryAt: number;
|
||||
at?: number;
|
||||
}): AccountEstablishmentRecord {
|
||||
requireText(input.accountId, 'accountId');
|
||||
const now = input.at ?? Date.now();
|
||||
this.db.prepare(`
|
||||
INSERT INTO reddit_account_establishment (
|
||||
account_id, state, reasons_json, last_probe_at, last_probe_failure,
|
||||
probe_failure_count, retry_at, updated_at
|
||||
) VALUES (
|
||||
@accountId, 'unestablished', @reasonsJson, @now, @reason, 1, @retryAt, @now
|
||||
)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
state = 'unestablished',
|
||||
reasons_json = excluded.reasons_json,
|
||||
last_probe_at = excluded.last_probe_at,
|
||||
last_probe_failure = excluded.last_probe_failure,
|
||||
probe_failure_count = reddit_account_establishment.probe_failure_count + 1,
|
||||
retry_at = excluded.retry_at,
|
||||
updated_at = excluded.updated_at
|
||||
`).run({
|
||||
accountId: input.accountId,
|
||||
reasonsJson: JSON.stringify([input.reason]),
|
||||
reason: input.reason,
|
||||
retryAt: input.retryAt,
|
||||
now,
|
||||
});
|
||||
return this.getRequired(input.accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A delivered invitation is the only positive proof an account is
|
||||
* established, so it clears the flag and the failure backoff outright.
|
||||
*/
|
||||
recordProbeSuccess(accountId: string, at: number = Date.now()): AccountEstablishmentRecord {
|
||||
requireText(accountId, 'accountId');
|
||||
this.db.prepare(`
|
||||
INSERT INTO reddit_account_establishment (
|
||||
account_id, state, reasons_json, last_probe_at, probe_failure_count, retry_at, updated_at
|
||||
) VALUES (@accountId, 'established', '[]', @at, 0, NULL, @at)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
state = 'established',
|
||||
reasons_json = '[]',
|
||||
last_probe_at = excluded.last_probe_at,
|
||||
last_probe_failure = NULL,
|
||||
probe_failure_count = 0,
|
||||
retry_at = NULL,
|
||||
updated_at = excluded.updated_at
|
||||
`).run({ accountId, at });
|
||||
return this.getRequired(accountId);
|
||||
}
|
||||
|
||||
private getRequired(accountId: string): AccountEstablishmentRecord {
|
||||
const record = this.get(accountId);
|
||||
if (!record) throw new Error(`Establishment state for ${accountId} was not persisted`);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
function mapEstablishment(row: EstablishmentRow): AccountEstablishmentRecord {
|
||||
return {
|
||||
accountId: row.account_id,
|
||||
state: row.state,
|
||||
reasons: parseReasons(row.reasons_json),
|
||||
accountCreatedAt: row.account_created_at,
|
||||
linkKarma: row.link_karma,
|
||||
commentKarma: row.comment_karma,
|
||||
totalKarma: row.total_karma,
|
||||
hasVerifiedEmail: row.has_verified_email === null ? null : row.has_verified_email === 1,
|
||||
profileCheckedAt: row.profile_checked_at,
|
||||
lastProbeAt: row.last_probe_at,
|
||||
lastProbeFailure: row.last_probe_failure,
|
||||
probeFailureCount: row.probe_failure_count,
|
||||
retryAt: row.retry_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function parseReasons(json: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(json) as unknown;
|
||||
return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function boolToInt(value: boolean | null): number | null {
|
||||
return value === null ? null : value ? 1 : 0;
|
||||
}
|
||||
|
||||
function mapConversation(row: ConversationRow): AccountConversationRecord {
|
||||
return {
|
||||
accountId: row.account_id,
|
||||
|
||||
@@ -456,6 +456,27 @@ export function runMigrations(db: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_reddit_recipient_account_status_due
|
||||
ON reddit_recipient_account_status(checked_at);
|
||||
`),
|
||||
() => db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reddit_account_establishment (
|
||||
account_id TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL CHECK(state IN ('unknown', 'established', 'unestablished')),
|
||||
reasons_json TEXT NOT NULL DEFAULT '[]',
|
||||
account_created_at INTEGER,
|
||||
link_karma INTEGER,
|
||||
comment_karma INTEGER,
|
||||
total_karma INTEGER,
|
||||
has_verified_email INTEGER,
|
||||
profile_checked_at INTEGER,
|
||||
last_probe_at INTEGER,
|
||||
last_probe_failure TEXT,
|
||||
probe_failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
retry_at INTEGER,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reddit_account_establishment_state
|
||||
ON reddit_account_establishment(state, retry_at);
|
||||
`),
|
||||
];
|
||||
|
||||
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
||||
|
||||
Reference in new issue
Block a user