Files
RedditBot/src/services/account-growth.ts
T

611 lines
23 KiB
TypeScript

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<string>;
screenThreadComment(thread: GrowthCommentThread, comment: string): Promise<GrowthCommentScreening>;
}
export interface GrowthRedditReader {
getHotPosts(subreddit: string, limit?: number): Promise<RedditPost[]>;
getThreadTopComments(subreddit: string, postId: string, limit?: number): Promise<string[]>;
}
export interface GrowthAuthContext {
isFullyAuthenticated(): boolean;
ensureFullyAuthenticated(): Promise<boolean>;
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<GrowthThreadPolicy>;
/** 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<GrowthTickOutcome> {
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<GrowthTickOutcome> {
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<string, number>();
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<GrowthTickOutcome> {
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<GrowthVerificationSummary | null> {
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<string> | 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);
}