Add account browsing simulation
This commit is contained in:
1 parent
e2d7dc4b82
commit
27012399ec
31 files changed
+1476
-28
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user