import crypto from 'node:crypto'; import type { AccountBrowsingSettingsRecord, AccountBrowsingSimulationRepository, BrowsingDecisionRecord, BrowsingThingKind, } from '../database/browsing-simulation-repository.js'; import type { GrowthSubredditRecord } from '../database/growth-repository.js'; import type { BrowsingThing, RedditPost } from '../reddit/api.js'; import type { Logger } from '../utils/logger.js'; const MAX_CONTENT_AGE_MS = 7 * 24 * 60 * 60_000; const MIN_POST_CONTEXT = 12; const MIN_COMMENT_CONTEXT = 20; const MAX_DAILY_SELECTIONS = 12; export interface BrowsingDecisionSink { recordSimulation(decision: BrowsingDecisionRecord): Promise; } export interface BrowsingVoter { upvote(thingFullname: string): Promise<{ status: 'submitted' | 'rate-limited' | 'session-invalid' | 'blocked' | 'failed'; reason?: string }>; } export interface AccountBrowsingSimulationServiceOptions { accountId: string; repository: AccountBrowsingSimulationRepository; growthSubreddits: (now: number) => GrowthSubredditRecord[]; reddit: { getHotPosts(subreddit: string, limit: number): Promise; getBrowsingComments(subreddit: string, postId: string, limit: number): Promise; }; sink: BrowsingDecisionSink; voter?: BrowsingVoter; /** Browser dry-run is an absolute no-write boundary, including manual runs. */ dryRun?: boolean; logger: Logger; now?: () => number; random?: () => number; onActivity?: (decision: BrowsingDecisionRecord) => void; } export type BrowsingSimulationOutcome = | { kind: 'idle'; reason: string } | { kind: 'scheduled'; nextDueAt: number } | { kind: 'simulated'; /** Retained for callers that only need the first selection. */ decision: BrowsingDecisionRecord; decisions: BrowsingDecisionRecord[]; nextDueAt: number; }; /** * Selects hypothetical votes without exposing any Reddit write dependency. * The only terminal capability is an injected durable decision sink. */ export class AccountBrowsingSimulationService { private readonly now: () => number; private readonly random: () => number; constructor(private readonly options: AccountBrowsingSimulationServiceOptions) { this.now = options.now ?? Date.now; this.random = options.random ?? Math.random; } /** Runs one operator-requested selection without waiting for the schedule. */ runNow(signal?: AbortSignal): Promise { return this.tick({ allowVoting: !this.options.dryRun, force: true }, signal); } async tick( { allowVoting = false, force = false }: { allowVoting?: boolean; force?: boolean } = {}, signal?: AbortSignal, ): Promise { if (signal?.aborted) throw new DOMException('Operation aborted', 'AbortError'); const now = this.now(); const settings = this.options.repository.getSettings(this.options.accountId, now); if (!settings.enabled) return { kind: 'idle', reason: 'Browsing simulation is disabled' }; if (settings.nextDueAt === null && !force) { const nextDueAt = this.scheduleNext(settings, now); this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now); return { kind: 'scheduled', nextDueAt }; } if (!force && settings.nextDueAt !== null && now < settings.nextDueAt) { return { kind: 'idle', reason: 'Browsing simulation is not due' }; } if (!force && isQuietTime(new Date(now), settings)) { const nextDueAt = moveOutsideQuietHours(now, settings); this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now); return { kind: 'scheduled', nextDueAt }; } const today = startOfLocalDay(now); const target = dailyTarget(this.options.accountId, today, settings); const selectedToday = this.options.repository.countSelectedSince(this.options.accountId, today); if (selectedToday >= MAX_DAILY_SELECTIONS) { const nextDueAt = moveOutsideQuietHours(today + 24 * 60 * 60_000, settings); this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now); return { kind: 'scheduled', nextDueAt }; } if (!force && selectedToday >= target) { const nextDueAt = moveOutsideQuietHours(today + 24 * 60 * 60_000, settings); this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now); return { kind: 'scheduled', nextDueAt }; } const communities = this.options.growthSubreddits(now); if (communities.length === 0) { const nextDueAt = this.scheduleNext(settings, now); this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now); return { kind: 'scheduled', nextDueAt }; } const community = weightedPick(communities, (entry) => entry.weight, this.random); const posts = await this.options.reddit.getHotPosts(community.subreddit, 25); const sessionId = crypto.randomUUID(); const eligiblePosts = await this.screenPosts(posts, settings, sessionId, now); const preferredKind: BrowsingThingKind = this.random() * (settings.postWeight + settings.commentWeight) < settings.postWeight ? 'post' : 'comment'; let candidates: BrowsingThing[] = eligiblePosts; let fallbackCandidates: BrowsingThing[] = []; if (preferredKind === 'comment' && eligiblePosts.length > 0) { const parent = eligiblePosts[Math.floor(this.random() * eligiblePosts.length)]; const comments = await this.options.reddit.getBrowsingComments(parent.subreddit, parent.parentPostId, 25); candidates = await this.screenThings(comments, settings, sessionId, now); fallbackCandidates = eligiblePosts; } const nextDueAt = this.scheduleNext(settings, now); this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now); if (candidates.length === 0 && fallbackCandidates.length === 0) return { kind: 'scheduled', nextDueAt }; const sessionLimit = force ? Math.min(settings.selectionsPerSession, MAX_DAILY_SELECTIONS - selectedToday) : Math.min(settings.selectionsPerSession, target - selectedToday); const selected = pickRandom(candidates, sessionLimit, this.random); if (selected.length < sessionLimit) { selected.push(...pickRandom(fallbackCandidates, sessionLimit - selected.length, this.random)); } const voter = settings.autoVoteEnabled && allowVoting && !this.options.dryRun ? this.options.voter : undefined; const decisions: BrowsingDecisionRecord[] = []; for (const thing of selected) { const decision = toDecision( this.options.accountId, thing, 'simulated-upvote', voter ? 'Upvote pending Reddit confirmation' : 'Eligible browsing selection', sessionId, now, voter ? 'pending' : 'simulated', ); await this.options.sink.recordSimulation(decision); if (voter) { const result = await voter.upvote(thing.fullname); if (result.status === 'submitted') { this.options.repository.markVoteSubmitted(this.options.accountId, thing.fullname); decision.voteSubmitted = true; decision.voteStatus = 'submitted'; decision.reason = 'Reddit upvote submitted'; this.options.logger.info(`Submitted browsing upvote for ${thing.fullname} in r/${thing.subreddit}`); } else { decision.reason = `Reddit upvote not submitted: ${result.reason ?? result.status}`; decision.voteStatus = 'failed'; this.options.repository.markVoteFailed(this.options.accountId, thing.fullname, decision.reason); this.options.logger.warn(`Browsing upvote for ${thing.fullname} was not submitted: ${result.reason ?? result.status}`); } } this.options.onActivity?.(decision); if (!decision.voteSubmitted) this.options.logger.info(`Simulated browsing selection for ${thing.fullname} in r/${thing.subreddit}; no Reddit vote was submitted`); decisions.push(decision); } return { kind: 'simulated', decision: decisions[0], decisions, nextDueAt }; } private async screenPosts( posts: readonly RedditPost[], settings: AccountBrowsingSettingsRecord, sessionId: string, now: number, ): Promise { return this.screenThings(posts.map(postToThing), settings, sessionId, now, posts); } private async screenThings( things: readonly BrowsingThing[], settings: AccountBrowsingSettingsRecord, sessionId: string, now: number, posts: readonly RedditPost[] = [], ): Promise { const eligible: BrowsingThing[] = []; for (const thing of things) { const post = posts.find((candidate) => candidate.id === thing.parentPostId); const reason = this.exclusionReason(thing, settings, now, post); if (!reason) { eligible.push(thing); continue; } if (!this.options.repository.hasAccountDecision(this.options.accountId, thing.fullname)) { await this.options.sink.recordSimulation( toDecision(this.options.accountId, thing, 'rejected', reason, sessionId, now, 'simulated'), ); } } return eligible; } private exclusionReason( thing: BrowsingThing, settings: AccountBrowsingSettingsRecord, now: number, post?: RedditPost, ): string | null { const repository = this.options.repository; if (repository.hasAccountDecision(this.options.accountId, thing.fullname)) return 'Already considered by this account'; if (repository.wasSelectedByAnyAccount(thing.fullname)) return 'Already selected by another owned account'; if (thing.author === '[deleted]' || thing.removed) return 'Deleted or removed content'; if (thing.author.toLowerCase() === 'automoderator' || thing.distinguished !== null) return 'Moderator or AutoModerator content'; if (repository.isOwnedAuthor(thing.author)) return 'Authored by an owned account'; if (repository.isOutreachRecipient(thing.author)) return 'Authored by an outreach recipient'; if (repository.isGrowthCommentPost(thing.parentPostId)) return 'Post already used for owned-account growth'; if (repository.isPromotionExperimentPost(thing.parentPostId)) return 'Post belongs to a promotion experiment'; if (thing.nsfw && !settings.allowNsfw) return 'NSFW content is disabled'; if (thing.createdAt <= 0 || now - thing.createdAt > MAX_CONTENT_AGE_MS) return 'Content is too old'; if (thing.text.trim().length < (thing.kind === 'post' ? MIN_POST_CONTEXT : MIN_COMMENT_CONTEXT)) return 'Content has too little context'; if (post?.locked || post?.archived || post?.stickied || post?.pinned || post?.removed_by_category) { return 'Thread is locked, archived, stickied, or removed'; } return null; } private scheduleNext(settings: AccountBrowsingSettingsRecord, from: number): number { const jitter = Math.floor(this.random() * (settings.jitterMs + 1)); return moveOutsideQuietHours( from + settings.minimumGapMs + jitter, settings, ); } } function pickRandom(items: readonly T[], limit: number, random: () => number): T[] { const remaining = [...items]; const selected: T[] = []; while (selected.length < limit && remaining.length > 0) { selected.push(remaining.splice(Math.floor(random() * remaining.length), 1)[0]); } return selected; } export function dailyTarget( accountId: string, localDayStart: number, settings: Pick, ): number { const range = settings.dailyTargetMax - settings.dailyTargetMin + 1; const digest = crypto.createHash('sha256').update(`${accountId}:${localDayStart}`).digest(); return settings.dailyTargetMin + (digest.readUInt32BE(0) % range); } export function isQuietTime( date: Date, settings: Pick, ): boolean { const quiet = settings.quietHours; if (!quiet.enabled || quiet.startHour === quiet.endHour) return false; const hour = date.getHours(); return quiet.startHour < quiet.endHour ? hour >= quiet.startHour && hour < quiet.endHour : hour >= quiet.startHour || hour < quiet.endHour; } function moveOutsideQuietHours( timestamp: number, settings: Pick, ): number { const date = new Date(timestamp); if (!isQuietTime(date, settings)) return timestamp; const end = new Date(timestamp); end.setMinutes(0, 0, 0); end.setHours(settings.quietHours.endHour); if (end.getTime() <= timestamp) end.setDate(end.getDate() + 1); return end.getTime(); } function startOfLocalDay(timestamp: number): number { const date = new Date(timestamp); date.setHours(0, 0, 0, 0); return date.getTime(); } function weightedPick(items: readonly T[], weight: (item: T) => number, random: () => number): T { const total = items.reduce((sum, item) => sum + weight(item), 0); let cursor = random() * total; for (const item of items) { cursor -= weight(item); if (cursor < 0) return item; } return items[items.length - 1]; } function postToThing(post: RedditPost): BrowsingThing { return { fullname: post.name.startsWith('t3_') ? post.name : `t3_${post.id}`, kind: 'post', subreddit: post.subreddit, author: post.author, permalink: post.permalink, createdAt: Math.round(post.created_utc * 1_000), score: typeof post.score === 'number' ? post.score : null, text: `${post.title}\n${post.selftext ?? ''}`.trim(), nsfw: post.over_18, parentPostId: post.id, distinguished: post.distinguished ?? null, removed: Boolean(post.removed_by_category), }; } function toDecision( accountId: string, thing: BrowsingThing, decision: BrowsingDecisionRecord['decision'], reason: string, sessionId: string, now: number, voteStatus: BrowsingDecisionRecord['voteStatus'], ): BrowsingDecisionRecord { return { accountId, thingFullname: thing.fullname, thingKind: thing.kind, postId: thing.parentPostId, subreddit: thing.subreddit, author: thing.author, permalink: thing.permalink, decision, reason, discoveredAt: now, decidedAt: now, scheduledSessionId: sessionId, voteSubmitted: false, voteStatus, }; }