339 lines
14 KiB
TypeScript
339 lines
14 KiB
TypeScript
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 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<RedditPost[]>;
|
|
getBrowsingComments(subreddit: string, postId: string, limit: number): Promise<BrowsingThing[]>;
|
|
};
|
|
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<BrowsingSimulationOutcome> {
|
|
return this.tick({ allowVoting: !this.options.dryRun, force: true }, signal);
|
|
}
|
|
|
|
async tick(
|
|
{ allowVoting = false, force = false }: { allowVoting?: boolean; force?: boolean } = {},
|
|
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 && !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 (!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
|
|
? settings.selectionsPerSession
|
|
: 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<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, '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<T>(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<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 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,
|
|
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,
|
|
};
|
|
}
|