Add account browsing simulation

This commit is contained in:
jjbrunton committed 2026-08-11 21:40:31 +01:00
1 parent e2d7dc4b82
commit 27012399ec
31 files changed
+1476 -28

No files matched your search

+282
View File
@@ -0,0 +1,282 @@
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;
export interface BrowsingDecisionSink {
recordSimulation(decision: BrowsingDecisionRecord): Promise<void>;
}
export interface AccountBrowsingSimulationServiceOptions {
accountId: string;
repository: AccountBrowsingSimulationRepository;
growthSubreddits: (now: number) => GrowthSubredditRecord[];
reddit: {
getHotPosts(subreddit: string, limit: number): Promise<RedditPost[]>;
getBrowsingComments(subreddit: string, postId: string, limit: number): Promise<BrowsingThing[]>;
};
sink: BrowsingDecisionSink;
logger: Logger;
now?: () => number;
random?: () => number;
onActivity?: (decision: BrowsingDecisionRecord) => void;
}
export type BrowsingSimulationOutcome =
| { kind: 'idle'; reason: string }
| { kind: 'scheduled'; nextDueAt: number }
| { kind: 'simulated'; decision: 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;
}
async tick(signal?: AbortSignal): Promise<BrowsingSimulationOutcome> {
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) {
const nextDueAt = this.scheduleNext(settings, now);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
return { kind: 'scheduled', nextDueAt };
}
if (now < settings.nextDueAt) return { kind: 'idle', reason: 'Browsing simulation is not due' };
if (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);
if (this.options.repository.countSelectedSince(this.options.accountId, today) >= 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[] = [];
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);
}
if (candidates.length === 0) candidates = eligiblePosts;
const nextDueAt = this.scheduleNext(settings, now);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
if (candidates.length === 0) return { kind: 'scheduled', nextDueAt };
const selected = candidates[Math.floor(this.random() * candidates.length)];
const decision = toDecision(this.options.accountId, selected, 'simulated-upvote', 'Eligible simulated selection', sessionId, now);
await this.options.sink.recordSimulation(decision);
this.options.onActivity?.(decision);
this.options.logger.info(`Simulated browsing selection for ${selected.fullname} in r/${selected.subreddit}; no Reddit vote was submitted`);
return { kind: 'simulated', decision, nextDueAt };
}
private async screenPosts(
posts: readonly RedditPost[],
settings: AccountBrowsingSettingsRecord,
sessionId: string,
now: number,
): Promise<BrowsingThing[]> {
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<BrowsingThing[]> {
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),
);
}
}
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 target = dailyTarget(this.options.accountId, startOfLocalDay(from), settings);
const quietHours = settings.quietHours.enabled
? quietDurationHours(settings.quietHours.startHour, settings.quietHours.endHour)
: 0;
const wakingMs = (24 - quietHours) * 60 * 60_000;
// Spread the day's target across the waking window while retaining bounded
// randomness. The hard minimum remains authoritative for unusual policies.
const distributedGap = (wakingMs / target) * (0.75 + this.random() * 0.5);
const jitter = Math.floor(this.random() * (settings.jitterMs + 1));
return moveOutsideQuietHours(
from + Math.max(settings.minimumGapMs, Math.floor(distributedGap)) + jitter,
settings,
);
}
}
export function dailyTarget(
accountId: string,
localDayStart: number,
settings: Pick<AccountBrowsingSettingsRecord, 'dailyTargetMin' | 'dailyTargetMax'>,
): 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<AccountBrowsingSettingsRecord, 'quietHours'>,
): 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<AccountBrowsingSettingsRecord, 'quietHours'>,
): 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 quietDurationHours(startHour: number, endHour: number): number {
if (startHour === endHour) return 0;
return endHour > startHour ? endHour - startHour : 24 - startHour + endHour;
}
function weightedPick<T>(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,
): 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,
};
}
+37 -2
View File
@@ -63,6 +63,8 @@ import { RecipientAccountStatusService } from './recipient-account-status.js';
import { AccountEstablishmentService } from './account-establishment.js';
import { AccountGrowthService, type GrowthActivityEvent } from './account-growth.js';
import { AccountGrowthRepository } from '../database/growth-repository.js';
import { AccountBrowsingSimulationRepository } from '../database/browsing-simulation-repository.js';
import { AccountBrowsingSimulationService } from './account-browsing-simulation.js';
import { allowsConversationSync, allowsSending, type BotMode } from './bot-mode.js';
export interface ConcreteRuntimeFactoryOptions {
@@ -268,10 +270,11 @@ async function buildRuntime(
},
},
);
const growthRepository = new AccountGrowthRepository(db);
const growthService = new AccountGrowthService({
accountId: session.accountId,
redditUsername: session.redditUsername,
repository: new AccountGrowthRepository(db),
repository: growthRepository,
reddit: {
getHotPosts: (subreddit, limit) => redditApi.getHotPosts(subreddit, limit),
getThreadTopComments: (subreddit, postId, limit) =>
@@ -284,6 +287,26 @@ async function buildRuntime(
dryRun: accountConfig.browser.dryRun,
onActivity: (event) => recordGrowthActivity(activityLogRepo, session.redditUsername, event),
});
const browsingRepository = new AccountBrowsingSimulationRepository(db);
const browsingService = new AccountBrowsingSimulationService({
accountId: session.accountId,
repository: browsingRepository,
growthSubreddits: (now) => growthRepository.listActiveSubreddits(session.accountId, now),
reddit: {
getHotPosts: (subreddit, limit) => redditApi.getHotPosts(subreddit, limit),
getBrowsingComments: (subreddit, postId, limit) =>
redditApi.getBrowsingComments(subreddit, postId, limit),
},
sink: browsingRepository,
logger,
onActivity: (decision) => activityLogRepo.insert({
timestamp: decision.decidedAt,
type: 'response',
message: `Simulated ${decision.thingKind} upvote in r/${decision.subreddit}; no Reddit vote was submitted`,
postUrl: `https://www.reddit.com${decision.permalink}`,
postTitle: `r/${decision.subreddit}`,
}),
});
const rateLimiter = new RateLimiter(accountConfig, logger, undefined, {
sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since),
});
@@ -332,7 +355,7 @@ async function buildRuntime(
activityLogRepo,
monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService,
mediaSyncService, mediaExchange, rateLimiter,
recipientAccountStatusService, establishmentService, growthService,
recipientAccountStatusService, establishmentService, growthService, browsingService,
};
const lifecycle = new AccountRuntimeLoop(
resources, accountConfig, logger, getBotMode, recipientDeduplicator,
@@ -418,6 +441,7 @@ export class AccountRuntimeLoop {
let nextReplyCheckAt = 0;
let nextPerformanceCheckAt = 0;
let nextGrowthCheckAt = 0;
let nextBrowsingCheckAt = 0;
const replyInterval = 30_000;
const performanceInterval = Math.max(this.config.reddit.pollIntervalMs * 5, 60_000);
// The pacer decides whether a comment is actually due; this is just how
@@ -478,6 +502,17 @@ export class AccountRuntimeLoop {
this.logger.error('Account growth tick failed:', error);
}
}
// Browsing simulation is strictly below comment growth. It records a
// hypothetical decision only and runs in both scan and running modes.
if (this.resources.browsingService && Date.now() >= nextBrowsingCheckAt) {
nextBrowsingCheckAt = Date.now() + growthInterval;
try {
await this.resources.browsingService.tick(signal);
} catch (error) {
if (signal.aborted) return;
this.logger.error('Browsing simulation tick failed:', error);
}
}
await delay(5_000, signal);
} catch (error) {
if (signal.aborted) return;
+3
View File
@@ -37,6 +37,7 @@ import type { ConversationAutomationService } from './conversation-automation.js
import type { RecipientAccountStatusService } from './recipient-account-status.js';
import type { AccountEstablishmentService } from './account-establishment.js';
import type { AccountGrowthService } from './account-growth.js';
import type { AccountBrowsingSimulationService } from './account-browsing-simulation.js';
import type { ProcessedPost, ProcessResult } from './processor.js';
/** Credentials passed to a factory. They must never be exposed by list/snapshot APIs. */
@@ -91,6 +92,8 @@ export interface RedditAccountRuntimeResources {
* functional without it, and it is disabled per account by default.
*/
growthService?: AccountGrowthService;
/** Simulation-only browsing selection. It has no Reddit write dependency. */
browsingService?: AccountBrowsingSimulationService;
mediaExchange?: MediaLibraryExchangeService;
rateLimiter: RateLimiter;
}