import type { GrowthCommentScreening, GrowthCommentThread } from '../ai/openrouter.js'; import type { AccountGrowthRepository, GrowthCommentRecord, GrowthOutcomeSample, GrowthSettingsRecord, GrowthSubredditRecord, } from '../database/growth-repository.js'; import { RedditAccessDeniedError, RedditRateLimitError, RedditSubredditUnavailableError, type RedditPost, } from '../reddit/api.js'; import type { RedditCommentClient } from '../reddit/comment-client.js'; import type { Logger } from '../utils/logger.js'; import { screenGrowthComment } from './growth-comment-safety.js'; import { assessTrust, dailyAllowance, evaluateGrowthWindow, } from './growth-pacer.js'; import { DEFAULT_GROWTH_THREAD_POLICY, screenGrowthThread, type GrowthThreadPolicy, } from './growth-thread-screening.js'; const DAY_MS = 24 * 60 * 60_000; const OUTCOME_HISTORY_MS = 7 * DAY_MS; const LEASE_MS = 5 * 60_000; /** Sweep offsets from the moment a comment was posted. */ const VERIFY_SCHEDULE_MS: readonly number[] = [15 * 60_000, 2 * 60 * 60_000, 24 * 60 * 60_000]; export interface GrowthAiClient { generateThreadComment( thread: GrowthCommentThread, existingComments?: readonly string[], guidance?: string, ): Promise; screenThreadComment(thread: GrowthCommentThread, comment: string): Promise; } export interface GrowthRedditReader { getHotPosts(subreddit: string, limit?: number): Promise; getThreadTopComments(subreddit: string, postId: string, limit?: number): Promise; } export interface GrowthAuthContext { isFullyAuthenticated(): boolean; ensureFullyAuthenticated(): Promise; invalidate?(reason: string): void; } export type GrowthActivityEvent = | { kind: 'posted'; subreddit: string; postId: string; permalink: string | null; comment: string } | { kind: 'rejected'; subreddit: string; postId: string; reason: string } | { kind: 'removed'; subreddit: string; postId: string } | { kind: 'paused'; reason: string } | { kind: 'trust-changed'; from: number; to: number; rationale: string }; export type GrowthTickOutcome = | { kind: 'idle'; reason: string } | { kind: 'posted'; postId: string; subreddit: string; permalink: string | null } | { kind: 'rejected'; postId: string; subreddit: string; reason: string } | { kind: 'deferred'; postId: string; subreddit: string; reason: string } | { kind: 'paused'; reason: string } | { kind: 'verified'; checked: number; removed: number }; export interface GrowthVerificationSummary { checked: number; removed: number; pausedReason: string | null; } export interface AccountGrowthServiceOptions { accountId: string; redditUsername: string; repository: AccountGrowthRepository; reddit: GrowthRedditReader; commentClient: RedditCommentClient; ai: GrowthAiClient; auth: GrowthAuthContext; logger: Logger; dryRun?: boolean; /** Minimum classifier certainty required before a comment may be posted. */ screeningMinConfidence?: number; threadPolicy?: Partial; /** Whether to spend a request reading a thread's existing comments for context. */ useExistingCommentContext?: boolean; now?: () => number; hourOfDay?: (at: number) => number; random?: () => number; onActivity?: (event: GrowthActivityEvent) => void; } /** * Builds one Reddit account's comment history so Reddit stops treating it as * new. * * The account grower is deliberately the lowest-priority work an account does: * it is account-scoped rather than part of the shared outreach funnel, it runs * even while the account is held out of outbound messaging (that is the point), * and every posting path fails closed. Nothing here retries a comment that may * already exist, because a duplicate comment cannot be taken back. */ export class AccountGrowthService { private readonly accountId: string; private readonly repository: AccountGrowthRepository; private readonly logger: Logger; private readonly now: () => number; private readonly hourOfDay: ((at: number) => number) | undefined; private readonly random: () => number; private readonly threadPolicy: GrowthThreadPolicy; private readonly screeningMinConfidence: number; private readonly workerId: string; constructor(private readonly options: AccountGrowthServiceOptions) { this.accountId = options.accountId; this.repository = options.repository; this.logger = options.logger; this.now = options.now ?? Date.now; this.hourOfDay = options.hourOfDay; this.random = options.random ?? Math.random; this.threadPolicy = { ...DEFAULT_GROWTH_THREAD_POLICY, ...options.threadPolicy }; this.screeningMinConfidence = options.screeningMinConfidence ?? 0.6; this.workerId = `growth:${options.accountId}`; } /** * Clears leases left by a process that died mid-post. Those rows are rejected * rather than retried, so this must run before the first tick. */ recoverAfterRestart(): number { const recovered = this.repository.recoverLeases(this.accountId, this.now()); if (recovered > 0) { this.logger.warn( `Growth: abandoned ${recovered} comment(s) interrupted mid-post for ` + `u/${this.options.redditUsername}; they will not be retried`, ); } return recovered; } /** * One unit of growth work. * * `allowPosting` is false in scan mode: verification still runs, so removals * and shadowbans are still detected, but nothing new is published. */ async tick( { allowPosting }: { allowPosting: boolean }, signal?: AbortSignal, ): Promise { const settings = this.repository.getSettings(this.accountId, this.now()); if (!settings.enabled) { return { kind: 'idle', reason: 'Growth is disabled for this account' }; } // Verification first: it is cheap, and it is what discovers that this // account should stop commenting altogether. const verification = await this.verifyPostedComments(signal); if (verification?.pausedReason) { return { kind: 'paused', reason: verification.pausedReason }; } if (!allowPosting) { return verification ? { kind: 'verified', checked: verification.checked, removed: verification.removed } : { kind: 'idle', reason: 'Scan mode: growth is verifying only' }; } const current = this.repository.getSettings(this.accountId, this.now()); if (current.pausedReason) return { kind: 'paused', reason: current.pausedReason }; if (!this.options.auth.isFullyAuthenticated() && !(await this.options.auth.ensureFullyAuthenticated())) { return { kind: 'idle', reason: 'Account session is not authenticated' }; } const now = this.now(); const outcomes = this.repository.listOutcomesSince(this.accountId, now - OUTCOME_HISTORY_MS); const inWindow = outcomes.filter((outcome) => outcome.postedAt >= now - DAY_MS); const decision = evaluateGrowthWindow({ settings: current, postedLast24h: inWindow.length, oldestPostInWindowAt: inWindow[0]?.postedAt ?? null, lastPostedAt: this.repository.getLastPostedAt(this.accountId), now, hourOfDay: this.hourOfDay, }); if (!decision.allowed) return { kind: 'idle', reason: decision.reason }; this.repository.expireStale(this.accountId, now - this.threadPolicy.maxAgeMs, now); let claimed = this.repository.claimNext(this.accountId, this.workerId, LEASE_MS, now); if (!claimed) { if (this.repository.hasPendingQueueWork(this.accountId)) { return { kind: 'idle', reason: 'Existing growth queue is waiting for its retry time' }; } const discovery = await this.discover(signal); if (discovery.kind === 'idle') return discovery; claimed = this.repository.claimNext(this.accountId, this.workerId, LEASE_MS, this.now()); if (!claimed) return { kind: 'idle', reason: 'No thread is ready to comment on yet' }; } return await this.prepareAndPost(claimed, current, signal); } /** Polls one eligible community and queues the threads worth commenting on. */ private async discover(signal?: AbortSignal): Promise { const now = this.now(); const configured = this.repository.listActiveSubreddits(this.accountId, now); if (configured.length === 0) { return { kind: 'idle', reason: 'No subreddits are configured for this account' }; } const withCapacity = configured.filter((entry) => ( this.repository.countPostedInSubredditSince(this.accountId, entry.subreddit, now - DAY_MS) < entry.dailyCap )); if (withCapacity.length === 0) { return { kind: 'idle', reason: 'Every configured subreddit has hit its daily cap' }; } const subreddit = this.weightedPick(withCapacity); let posts: RedditPost[]; try { posts = await this.options.reddit.getHotPosts(subreddit.subreddit, 25); } catch (error) { return this.handleDiscoveryError(error, subreddit); } if (signal?.aborted) return { kind: 'idle', reason: 'Shutting down' }; const eligible: RedditPost[] = []; const rejections = new Map(); for (const post of posts) { const screening = screenGrowthThread(post, { ...this.threadPolicy, allowNsfw: this.repository.getSettings(this.accountId, now).allowNsfw, }, now); if (screening.eligible) eligible.push(post); else rejections.set(screening.reason, (rejections.get(screening.reason) ?? 0) + 1); } // Queue one thread at a time. Subreddit selection is randomized on every // new unit of work, rather than one random community producing a long, // predictable run of comments from a ten-thread batch. const intake = this.repository.enqueueDiscovered(this.accountId, eligible, now, 1); this.logger.info( `Growth intake r/${subreddit.subreddit} for u/${this.options.redditUsername}: ` + `${posts.length} fetched; ${eligible.length} eligible; ${intake.discovered} queued; ` + `${intake.duplicate} already seen; ${intake.ownedByOtherAccount} held by another account` + (rejections.size > 0 ? `; rejected — ${[...rejections].map(([reason, count]) => `${count} ${reason.toLowerCase()}`).join(', ')}` : ''), ); return intake.discovered > 0 ? { kind: 'verified', checked: 0, removed: 0 } : { kind: 'idle', reason: `No eligible threads in r/${subreddit.subreddit} right now` }; } private handleDiscoveryError(error: unknown, subreddit: GrowthSubredditRecord): GrowthTickOutcome { const now = this.now(); if (error instanceof RedditSubredditUnavailableError) { this.repository.disableSubreddit( this.accountId, subreddit.subreddit, 'Reddit reports this community is unavailable', now, ); return { kind: 'idle', reason: `Disabled r/${subreddit.subreddit}: Reddit returned HTTP 404` }; } if (error instanceof RedditAccessDeniedError) { this.repository.cooldownSubreddit(this.accountId, subreddit.subreddit, now + 6 * 60 * 60_000, now); return { kind: 'idle', reason: `r/${subreddit.subreddit} denied access; cooling it down for 6h` }; } if (error instanceof RedditRateLimitError) { return { kind: 'idle', reason: `Reddit rate limit; retrying in ${Math.ceil(error.retryAfterMs / 1_000)}s` }; } const message = error instanceof Error ? error.message : String(error); this.logger.warn(`Growth discovery failed for r/${subreddit.subreddit}: ${message}`); return { kind: 'idle', reason: `Could not read r/${subreddit.subreddit}: ${message}` }; } /** * Generates, screens and posts a comment for one claimed thread. * * Both screening stages fail closed: a deterministic refusal rejects the * candidate outright, and an unavailable classifier defers it rather than * letting unreviewed text reach Reddit. */ private async prepareAndPost( claimed: GrowthCommentRecord, settings: GrowthSettingsRecord, signal?: AbortSignal, ): Promise { const { postId, subreddit } = claimed; const thread: GrowthCommentThread = { subreddit: claimed.subreddit, title: claimed.threadTitle, selftext: claimed.post.selftext ?? '', flair: claimed.post.link_flair_text ?? null, }; const cap = this.repository.listSubreddits(this.accountId) .find((entry) => entry.subreddit.toLowerCase() === subreddit.toLowerCase()); if (cap) { const postedHere = this.repository.countPostedInSubredditSince( this.accountId, subreddit, this.now() - DAY_MS, ); if (postedHere >= cap.dailyCap) { return this.deferred(claimed, `Daily cap for r/${subreddit} is already used`, this.now() + DAY_MS); } } let existingComments: readonly string[] = []; if (this.options.useExistingCommentContext) { try { existingComments = await this.options.reddit.getThreadTopComments(subreddit, postId, 8); } catch { existingComments = []; } } if (signal?.aborted) return this.deferred(claimed, 'Shutting down', this.now() + 60_000); let comment: string; try { comment = await this.options.ai.generateThreadComment(thread, existingComments); } catch (error) { const reason = `Comment generation failed: ${errorMessage(error)}`; return this.deferred(claimed, reason, this.now() + 15 * 60_000); } const gate = screenGrowthComment(comment); if (!gate.safe) { this.logger.info(`Growth: discarded a generated comment for ${postId} — ${gate.reason}`); return this.rejected(claimed, gate.reason, comment); } let screening: GrowthCommentScreening; try { screening = await this.options.ai.screenThreadComment(thread, comment); } catch (error) { // Fail closed. Nothing reviews these comments before they go public, so an // unavailable classifier has to stall growth rather than wave text through. return this.deferred( claimed, `Comment screening unavailable: ${errorMessage(error)}`, this.now() + 10 * 60_000, ); } if (!screening.approved || screening.confidence < this.screeningMinConfidence) { const reason = screening.approved ? `Screening confidence ${screening.confidence.toFixed(2)} below ${this.screeningMinConfidence}` : `Screening rejected the comment: ${screening.reason}`; return this.rejected(claimed, reason, comment); } if (this.options.dryRun) { return this.rejected(claimed, 'Dry run: comment prepared but not posted', comment); } const threadFullname = claimed.post.name && /^t3_/i.test(claimed.post.name) ? claimed.post.name : `t3_${postId}`; const result = await this.options.commentClient.postComment(threadFullname, subreddit, comment); const now = this.now(); switch (result.status) { case 'posted': { const recorded = this.repository.markPosted(this.accountId, postId, this.workerId, { generatedComment: comment, modelId: 'chat', commentFullname: result.fullname, commentPermalink: result.permalink, nextVerifyAt: now + VERIFY_SCHEDULE_MS[0], }, now); if (!recorded) { // The comment is live but the lease moved on. Surface loudly: the // audit trail is now incomplete and only a human can reconcile it. this.logger.error( `Growth: posted comment ${result.fullname} to r/${subreddit} but could not record it ` + `against ${postId} — the lease was no longer held`, ); } this.logger.info(`Growth: commented on r/${subreddit} (${postId}) as u/${this.options.redditUsername}`); this.options.onActivity?.({ kind: 'posted', subreddit, postId, permalink: result.permalink, comment, }); return { kind: 'posted', postId, subreddit, permalink: result.permalink }; } case 'rate-limited': { if (result.scope === 'subreddit') { this.repository.cooldownSubreddit(this.accountId, subreddit, now + result.retryAfterMs, now); } return this.deferred(claimed, result.reason, now + result.retryAfterMs); } case 'thread-closed': return this.rejected(claimed, result.reason, comment); case 'not-allowed': this.repository.disableSubreddit(this.accountId, subreddit, result.reason, now); return this.rejected(claimed, result.reason, comment); case 'session-invalid': this.options.auth.invalidate?.(`Reddit rejected a comment as signed out: ${result.reason}`); this.options.commentClient.invalidateSession(); return this.deferred(claimed, result.reason, now + 5 * 60_000); case 'blocked': { this.repository.defer(this.accountId, postId, this.workerId, result.reason, now + DAY_MS, now); this.repository.pause(this.accountId, result.reason, now); this.logger.error(`Growth paused for u/${this.options.redditUsername}: ${result.reason}`); this.options.onActivity?.({ kind: 'paused', reason: result.reason }); return { kind: 'paused', reason: result.reason }; } default: return this.deferred(claimed, result.reason, now + 10 * 60_000); } } /** * Checks what happened to comments already posted, then re-reads this * account's standing from the result. */ async verifyPostedComments(signal?: AbortSignal): Promise { const now = this.now(); const due = this.repository.listDueForVerification(this.accountId, 100, now); if (due.length === 0) return null; const fullnames = due .map((record) => record.commentFullname) .filter((name): name is string => name !== null); if (fullnames.length === 0) return null; let states; try { states = await this.options.commentClient.fetchCommentStates(fullnames); } catch (error) { this.logger.warn(`Growth verification sweep failed: ${errorMessage(error)}`); return null; } if (signal?.aborted) return null; const byFullname = new Map(states.map((state) => [state.fullname, state])); // The public-visibility probe is the shadowban test. It costs one signed-out // request, so it runs once per comment, on the second sweep, by which point // an automated filter will have acted. const needsPublicCheck = due.filter((record) => record.verifyAttempts === 1); let publiclyVisible: Set | null = null; if (needsPublicCheck.length > 0) { try { publiclyVisible = await this.options.commentClient.fetchPubliclyVisible( needsPublicCheck .map((record) => record.commentFullname) .filter((name): name is string => name !== null), ); } catch (error) { // A failed probe proves nothing; never pause an account on a network blip. this.logger.debug(`Growth public-visibility probe failed: ${errorMessage(error)}`); publiclyVisible = null; } } let removed = 0; for (const record of due) { const state = record.commentFullname ? byFullname.get(record.commentFullname) : undefined; if (!state) continue; const isRemoved = state.removed || state.missing; if (isRemoved) removed += 1; const shadowBanned = publiclyVisible !== null && record.commentFullname !== null && record.verifyAttempts === 1 && !isRemoved ? !publiclyVisible.has(record.commentFullname) : null; this.repository.recordVerification(this.accountId, record.postId, { score: state.score, removed: isRemoved, shadowBanned, nextVerifyAt: isRemoved ? null : this.nextVerifyAt(record), }, now); if (isRemoved) { this.logger.warn(`Growth: comment on r/${record.subreddit} (${record.postId}) was removed`); this.options.onActivity?.({ kind: 'removed', subreddit: record.subreddit, postId: record.postId }); } } const pausedReason = this.applyTrustAssessment(); return { checked: due.length, removed, pausedReason }; } /** Re-derives the trust level and pause state from recent evidence. */ private applyTrustAssessment(): string | null { const now = this.now(); const settings = this.repository.getSettings(this.accountId, now); const outcomes: GrowthOutcomeSample[] = this.repository.listOutcomesSince( this.accountId, now - OUTCOME_HISTORY_MS, ); const assessment = assessTrust(settings, outcomes, now); if (assessment.consecutiveRemovals !== settings.consecutiveRemovals) { this.repository.setConsecutiveRemovals(this.accountId, assessment.consecutiveRemovals, now); } if (assessment.pauseReason) { if (!settings.pausedReason) { this.repository.pause(this.accountId, assessment.pauseReason, now); this.logger.error( `Growth paused for u/${this.options.redditUsername}: ${assessment.pauseReason}`, ); this.options.onActivity?.({ kind: 'paused', reason: assessment.pauseReason }); } return assessment.pauseReason; } if (assessment.levelChanged) { this.repository.setTrustLevel(this.accountId, assessment.trustLevel, now); this.logger.info( `Growth allowance for u/${this.options.redditUsername}: level ${settings.trustLevel} → ` + `${assessment.trustLevel} (${dailyAllowance(assessment.trustLevel)}/day) — ${assessment.rationale}`, ); this.options.onActivity?.({ kind: 'trust-changed', from: settings.trustLevel, to: assessment.trustLevel, rationale: assessment.rationale, }); } return null; } private nextVerifyAt(record: GrowthCommentRecord): number | null { const postedAt = record.postedAt ?? this.now(); const nextIndex = record.verifyAttempts + 1; const offset = VERIFY_SCHEDULE_MS[nextIndex]; return offset === undefined ? null : postedAt + offset; } private rejected( claimed: GrowthCommentRecord, reason: string, comment: string | null, ): GrowthTickOutcome { this.repository.reject(this.accountId, claimed.postId, this.workerId, reason, comment, this.now()); this.options.onActivity?.({ kind: 'rejected', subreddit: claimed.subreddit, postId: claimed.postId, reason, }); return { kind: 'rejected', postId: claimed.postId, subreddit: claimed.subreddit, reason }; } private deferred( claimed: GrowthCommentRecord, reason: string, availableAt: number, ): GrowthTickOutcome { this.repository.defer( this.accountId, claimed.postId, this.workerId, reason, availableAt, this.now(), ); return { kind: 'deferred', postId: claimed.postId, subreddit: claimed.subreddit, reason }; } /** * Weighted random choice (Efraimidis-Spirakis), matching how the outreach * monitor orders subreddits: heavier entries come up more often but every * enabled community keeps a real chance of being picked. */ private weightedPick(entries: readonly GrowthSubredditRecord[]): GrowthSubredditRecord { let best = entries[0]; let bestKey = Number.NEGATIVE_INFINITY; for (const entry of entries) { const weight = entry.weight > 0 ? entry.weight : 1; const roll = this.random(); const key = roll <= 0 ? Number.NEGATIVE_INFINITY : Math.log(roll) / weight; if (key > bestKey) { bestKey = key; best = entry; } } return best; } } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); }