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

+5
View File
@@ -59,6 +59,7 @@ import { RecipientDeduplicator } from './services/recipient-deduplicator.js';
import { createConfigBackedSubredditPollingControl } from './services/subreddit-polling-control.js';
import { CandidateQueueRepository, RuntimeStateRepository } from './database/candidate-queue-repository.js';
import { AccountGrowthRepository } from './database/growth-repository.js';
import { AccountBrowsingSimulationRepository } from './database/browsing-simulation-repository.js';
import { MediaLibraryRepository } from './database/media-library-repository.js';
import { MediaLibraryService } from './services/media-library.js';
@@ -83,6 +84,7 @@ export interface AppContainer {
candidateQueueRepo: CandidateQueueRepository;
runtimeStateRepo: RuntimeStateRepository;
growthRepo: AccountGrowthRepository;
browsingRepo: AccountBrowsingSimulationRepository;
redditApi: RedditApiClient;
aiClient: OpenRouterClient;
@@ -151,6 +153,7 @@ export async function bootstrap(
const candidateQueueRepo = new CandidateQueueRepository(db);
const runtimeStateRepo = new RuntimeStateRepository(db);
const growthRepo = new AccountGrowthRepository(db);
const browsingRepo = new AccountBrowsingSimulationRepository(db);
const redditAccountsRepo = bootstrapAccountsRepo;
const accountContentRepo = new AccountContentRepository(db);
const aiUsageRepo = new AiUsageRepository(db);
@@ -473,6 +476,7 @@ export async function bootstrap(
webServer.setConversationReadStateRepository(conversationReadStateRepo);
webServer.setCandidateQueueRepository(candidateQueueRepo);
webServer.setGrowthRepository(growthRepo);
webServer.setBrowsingRepository(browsingRepo);
webServer.setConfigStore(new ConfigStore(db));
webServer.setPushNotifier(pushNotifier);
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
@@ -512,6 +516,7 @@ export async function bootstrap(
candidateQueueRepo,
runtimeStateRepo,
growthRepo,
browsingRepo,
redditApi,
aiClient,
monitorService,
@@ -0,0 +1,294 @@
import type Database from 'better-sqlite3';
import type { GrowthQuietHours } from './growth-repository.js';
export type BrowsingThingKind = 'post' | 'comment';
export type BrowsingDecisionKind = 'simulated-upvote' | 'skipped' | 'rejected';
export interface AccountBrowsingSettingsRecord {
accountId: string;
enabled: boolean;
dailyTargetMin: number;
dailyTargetMax: number;
postWeight: number;
commentWeight: number;
quietHours: GrowthQuietHours;
minimumGapMs: number;
jitterMs: number;
allowNsfw: boolean;
nextDueAt: number | null;
updatedAt: number;
}
export interface AccountBrowsingSettingsPatch {
enabled?: boolean;
dailyTargetMin?: number;
dailyTargetMax?: number;
postWeight?: number;
commentWeight?: number;
quietHours?: GrowthQuietHours;
minimumGapMs?: number;
jitterMs?: number;
allowNsfw?: boolean;
}
export interface BrowsingDecisionRecord {
accountId: string;
thingFullname: string;
thingKind: BrowsingThingKind;
postId: string;
subreddit: string;
author: string;
permalink: string;
decision: BrowsingDecisionKind;
reason: string;
discoveredAt: number;
decidedAt: number;
scheduledSessionId: string;
}
export interface BrowsingDecisionCounts {
selected: number;
posts: number;
comments: number;
rejected: number;
skipped: number;
}
const DEFAULT_QUIET_HOURS: GrowthQuietHours = { enabled: true, startHour: 0, endHour: 7 };
/** Durable account-scoped policy and audit store for simulation-only browsing decisions. */
export class AccountBrowsingSimulationRepository {
constructor(private readonly db: Database.Database) {}
getSettings(accountId: string, now: number = Date.now()): AccountBrowsingSettingsRecord {
const row = this.db.prepare('SELECT * FROM account_browsing_settings WHERE account_id = ?')
.get(accountId) as Record<string, unknown> | undefined;
return row ? mapSettings(row) : {
accountId,
enabled: false,
dailyTargetMin: 3,
dailyTargetMax: 6,
postWeight: 60,
commentWeight: 40,
quietHours: { ...DEFAULT_QUIET_HOURS },
minimumGapMs: 45 * 60_000,
jitterMs: 15 * 60_000,
allowNsfw: false,
nextDueAt: null,
updatedAt: now,
};
}
saveSettings(
accountId: string,
patch: AccountBrowsingSettingsPatch,
now: number = Date.now(),
): AccountBrowsingSettingsRecord {
const current = this.getSettings(accountId, now);
const merged = {
...current,
...patch,
quietHours: patch.quietHours ?? current.quietHours,
updatedAt: now,
};
validateSettings(merged);
this.db.prepare(`
INSERT INTO account_browsing_settings (
account_id, enabled, daily_target_min, daily_target_max, post_weight,
comment_weight, quiet_hours_json, minimum_gap_ms, jitter_ms, allow_nsfw,
next_due_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
enabled = excluded.enabled,
daily_target_min = excluded.daily_target_min,
daily_target_max = excluded.daily_target_max,
post_weight = excluded.post_weight,
comment_weight = excluded.comment_weight,
quiet_hours_json = excluded.quiet_hours_json,
minimum_gap_ms = excluded.minimum_gap_ms,
jitter_ms = excluded.jitter_ms,
allow_nsfw = excluded.allow_nsfw,
updated_at = excluded.updated_at
`).run(
accountId, merged.enabled ? 1 : 0, merged.dailyTargetMin, merged.dailyTargetMax,
merged.postWeight, merged.commentWeight, JSON.stringify(merged.quietHours),
merged.minimumGapMs, merged.jitterMs, merged.allowNsfw ? 1 : 0,
merged.nextDueAt, now,
);
return this.getSettings(accountId, now);
}
setNextDueAt(accountId: string, nextDueAt: number | null, now: number = Date.now()): void {
if (!this.db.prepare('SELECT 1 FROM account_browsing_settings WHERE account_id = ?').get(accountId)) {
this.saveSettings(accountId, {}, now);
}
this.db.prepare(`
UPDATE account_browsing_settings SET next_due_at = ?, updated_at = ? WHERE account_id = ?
`).run(nextDueAt, now, accountId);
}
recordDecision(decision: BrowsingDecisionRecord): boolean {
return this.db.prepare(`
INSERT OR IGNORE INTO account_browsing_decisions (
account_id, thing_fullname, thing_kind, post_id, subreddit, author,
permalink, decision, reason, discovered_at, decided_at, scheduled_session_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
decision.accountId, decision.thingFullname, decision.thingKind, decision.postId,
decision.subreddit, decision.author, decision.permalink, decision.decision,
decision.reason, decision.discoveredAt, decision.decidedAt, decision.scheduledSessionId,
).changes === 1;
}
async recordSimulation(decision: BrowsingDecisionRecord): Promise<void> {
if (!this.recordDecision(decision)) {
throw new Error(`Browsing decision for ${decision.thingFullname} was already recorded`);
}
}
hasAccountDecision(accountId: string, thingFullname: string): boolean {
return this.db.prepare(`
SELECT 1 FROM account_browsing_decisions WHERE account_id = ? AND thing_fullname = ?
`).get(accountId, thingFullname) !== undefined;
}
wasSelectedByAnyAccount(thingFullname: string): boolean {
return this.db.prepare(`
SELECT 1 FROM account_browsing_decisions
WHERE thing_fullname = ? AND decision = 'simulated-upvote' LIMIT 1
`).get(thingFullname) !== undefined;
}
isOwnedAuthor(author: string): boolean {
return this.db.prepare(`
SELECT 1 FROM reddit_accounts WHERE reddit_username = ? COLLATE NOCASE LIMIT 1
`).get(author) !== undefined;
}
isOutreachRecipient(author: string): boolean {
return this.db.prepare(`
SELECT 1 FROM recipient_suppressions WHERE recipient = ? COLLATE NOCASE LIMIT 1
`).get(author) !== undefined;
}
isGrowthCommentPost(postId: string): boolean {
return this.db.prepare(`SELECT 1 FROM account_growth_comments WHERE post_id = ? LIMIT 1`)
.get(postId) !== undefined;
}
isPromotionExperimentPost(postId: string): boolean {
return this.db.prepare(`
SELECT 1
WHERE EXISTS (
SELECT 1 FROM sent_messages
WHERE post_id = ? AND experiment_id IS NOT NULL AND trim(experiment_id) <> ''
) OR EXISTS (
SELECT 1 FROM candidate_queue
WHERE post_id = ? AND prepared_json IS NOT NULL AND json_valid(prepared_json)
AND json_extract(prepared_json, '$.experimentId') IS NOT NULL
)
LIMIT 1
`).get(postId, postId) !== undefined;
}
countSelectedSince(accountId: string, since: number): number {
return (this.db.prepare(`
SELECT COUNT(*) AS count FROM account_browsing_decisions
WHERE account_id = ? AND decision = 'simulated-upvote' AND decided_at >= ?
`).get(accountId, since) as { count: number }).count;
}
getCountsSince(accountId: string, since: number): BrowsingDecisionCounts {
const rows = this.db.prepare(`
SELECT decision, thing_kind, COUNT(*) AS count
FROM account_browsing_decisions WHERE account_id = ? AND decided_at >= ?
GROUP BY decision, thing_kind
`).all(accountId, since) as Array<{ decision: BrowsingDecisionKind; thing_kind: BrowsingThingKind; count: number }>;
const counts: BrowsingDecisionCounts = { selected: 0, posts: 0, comments: 0, rejected: 0, skipped: 0 };
for (const row of rows) {
if (row.decision === 'simulated-upvote') {
counts.selected += row.count;
counts[row.thing_kind === 'post' ? 'posts' : 'comments'] += row.count;
} else if (row.decision === 'rejected') counts.rejected += row.count;
else counts.skipped += row.count;
}
return counts;
}
listRecent(accountId: string, limit = 20): BrowsingDecisionRecord[] {
const rows = this.db.prepare(`
SELECT * FROM account_browsing_decisions WHERE account_id = ?
ORDER BY decided_at DESC LIMIT ?
`).all(accountId, limit) as Array<Record<string, unknown>>;
return rows.map(mapDecision);
}
listRecentSelections(accountId: string, limit = 20): BrowsingDecisionRecord[] {
const rows = this.db.prepare(`
SELECT * FROM account_browsing_decisions
WHERE account_id = ? AND decision = 'simulated-upvote'
ORDER BY decided_at DESC LIMIT ?
`).all(accountId, limit) as Array<Record<string, unknown>>;
return rows.map(mapDecision);
}
}
function validateSettings(settings: AccountBrowsingSettingsRecord): void {
if (!Number.isInteger(settings.dailyTargetMin) || settings.dailyTargetMin < 1 || settings.dailyTargetMin > 12) {
throw new Error('Browsing minimum daily target must be a whole number between 1 and 12');
}
if (!Number.isInteger(settings.dailyTargetMax)
|| settings.dailyTargetMax < settings.dailyTargetMin || settings.dailyTargetMax > 12) {
throw new Error('Browsing maximum daily target must be between the minimum and 12');
}
if (!Number.isFinite(settings.postWeight) || settings.postWeight < 0
|| !Number.isFinite(settings.commentWeight) || settings.commentWeight < 0
|| settings.postWeight + settings.commentWeight <= 0) {
throw new Error('Browsing post and comment weights must be non-negative with a positive total');
}
if (!Number.isInteger(settings.minimumGapMs) || settings.minimumGapMs < 45 * 60_000) {
throw new Error('Browsing minimum gap cannot be less than 45 minutes');
}
if (!Number.isInteger(settings.jitterMs) || settings.jitterMs < 0) {
throw new Error('Browsing jitter must be a non-negative whole number');
}
const { startHour, endHour } = settings.quietHours;
if (!Number.isInteger(startHour) || startHour < 0 || startHour > 23
|| !Number.isInteger(endHour) || endHour < 0 || endHour > 23) {
throw new Error('Browsing quiet-hour boundaries must be whole hours from 0 to 23');
}
}
function mapSettings(row: Record<string, unknown>): AccountBrowsingSettingsRecord {
return {
accountId: row.account_id as string,
enabled: row.enabled === 1,
dailyTargetMin: row.daily_target_min as number,
dailyTargetMax: row.daily_target_max as number,
postWeight: row.post_weight as number,
commentWeight: row.comment_weight as number,
quietHours: JSON.parse(row.quiet_hours_json as string) as GrowthQuietHours,
minimumGapMs: row.minimum_gap_ms as number,
jitterMs: row.jitter_ms as number,
allowNsfw: row.allow_nsfw === 1,
nextDueAt: (row.next_due_at as number | null) ?? null,
updatedAt: row.updated_at as number,
};
}
function mapDecision(row: Record<string, unknown>): BrowsingDecisionRecord {
return {
accountId: row.account_id as string,
thingFullname: row.thing_fullname as string,
thingKind: row.thing_kind as BrowsingThingKind,
postId: row.post_id as string,
subreddit: row.subreddit as string,
author: row.author as string,
permalink: row.permalink as string,
decision: row.decision as BrowsingDecisionKind,
reason: row.reason as string,
discoveredAt: row.discovered_at as number,
decidedAt: row.decided_at as number,
scheduledSessionId: row.scheduled_session_id as string,
};
}
+44
View File
@@ -564,6 +564,50 @@ export function runMigrations(db: Database.Database): void {
// Null keeps the adaptive allowance; a value is an operator override.
addColumnIfNotExists(db, 'account_growth_settings', 'daily_limit', 'INTEGER');
},
() => db.exec(`
CREATE TABLE IF NOT EXISTS account_browsing_settings (
account_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0 CHECK(enabled IN (0, 1)),
daily_target_min INTEGER NOT NULL DEFAULT 3,
daily_target_max INTEGER NOT NULL DEFAULT 6,
post_weight INTEGER NOT NULL DEFAULT 60,
comment_weight INTEGER NOT NULL DEFAULT 40,
quiet_hours_json TEXT NOT NULL DEFAULT '{"enabled":true,"startHour":0,"endHour":7}',
minimum_gap_ms INTEGER NOT NULL DEFAULT 2700000,
jitter_ms INTEGER NOT NULL DEFAULT 900000,
allow_nsfw INTEGER NOT NULL DEFAULT 0 CHECK(allow_nsfw IN (0, 1)),
next_due_at INTEGER,
updated_at INTEGER NOT NULL,
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE,
CHECK(daily_target_min BETWEEN 1 AND 12),
CHECK(daily_target_max BETWEEN daily_target_min AND 12),
CHECK(post_weight >= 0 AND comment_weight >= 0 AND post_weight + comment_weight > 0)
);
CREATE TABLE IF NOT EXISTS account_browsing_decisions (
account_id TEXT NOT NULL,
thing_fullname TEXT NOT NULL,
thing_kind TEXT NOT NULL CHECK(thing_kind IN ('post', 'comment')),
post_id TEXT NOT NULL,
subreddit TEXT NOT NULL COLLATE NOCASE,
author TEXT NOT NULL,
permalink TEXT NOT NULL,
decision TEXT NOT NULL CHECK(decision IN ('simulated-upvote', 'skipped', 'rejected')),
reason TEXT NOT NULL,
discovered_at INTEGER NOT NULL,
decided_at INTEGER NOT NULL,
scheduled_session_id TEXT NOT NULL,
PRIMARY KEY (account_id, thing_fullname),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_account_browsing_decisions_recent
ON account_browsing_decisions(account_id, decided_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_browsing_decisions_selected
ON account_browsing_decisions(thing_fullname)
WHERE decision = 'simulated-upvote';
CREATE INDEX IF NOT EXISTS idx_account_browsing_decisions_day
ON account_browsing_decisions(account_id, decision, decided_at);
`),
];
let currentVersion = db.pragma('user_version', { simple: true }) as number;
+69
View File
@@ -125,6 +125,23 @@ export interface RedditPost {
pinned?: boolean;
distinguished?: string | null;
removed_by_category?: string | null;
score?: number;
}
/** Typed Reddit content used only by the simulation-only browsing reader. */
export interface BrowsingThing {
fullname: string;
kind: 'post' | 'comment';
subreddit: string;
author: string;
permalink: string;
createdAt: number;
score: number | null;
text: string;
nsfw: boolean;
parentPostId: string;
distinguished: string | null;
removed: boolean;
}
interface RedditListingChild {
@@ -478,6 +495,58 @@ export class RedditApiClient {
}
}
/**
* Typed top-level comments for browsing simulation. This is intentionally
* separate from `getThreadTopComments()`, whose string-only contract belongs
* to comment generation.
*/
async getBrowsingComments(
subreddit: string,
postId: string,
limit = 25,
): Promise<BrowsingThing[]> {
const cookieHeader = await this.getCookieHeader();
const headers: Record<string, string> = {};
if (cookieHeader) headers.Cookie = cookieHeader;
try {
const response = await this.scheduleRequest(() => this.client.get(
`/r/${subreddit}/comments/${postId}.json`,
{ params: { limit, depth: 1, sort: 'hot', raw_json: 1 }, headers },
));
const listings = Array.isArray(response.data) ? response.data : [];
const children = (listings[1] as { data?: { children?: unknown } } | undefined)?.data?.children;
if (!Array.isArray(children)) return [];
return children.flatMap((child): BrowsingThing[] => {
const data = (child as { data?: Record<string, unknown> })?.data;
if (!data || typeof data.name !== 'string' || !data.name.startsWith('t1_')) return [];
const author = typeof data.author === 'string' ? data.author : '[deleted]';
const body = typeof data.body === 'string' ? data.body : '';
return [{
fullname: data.name,
kind: 'comment',
subreddit: typeof data.subreddit === 'string' ? data.subreddit : subreddit,
author,
permalink: typeof data.permalink === 'string'
? data.permalink
: `/r/${subreddit}/comments/${postId}/_/${data.name.slice(3)}/`,
createdAt: typeof data.created_utc === 'number' ? Math.round(data.created_utc * 1_000) : 0,
score: typeof data.score === 'number' ? data.score : null,
text: body,
nsfw: data.over_18 === true,
parentPostId: postId,
distinguished: typeof data.distinguished === 'string' ? data.distinguished : null,
removed: body === '[removed]' || body === '[deleted]' || data.removed === true,
}];
}).slice(0, limit);
} catch (error) {
const rateLimitError = this.toRateLimitError(error);
if (rateLimitError) throw rateLimitError;
this.logger.warn(`Failed to read browsing comments for ${postId}:`, error);
return [];
}
}
private isValidPost(post: RedditPost, type: 'image' | 'chat' = 'image'): boolean {
// Skip deleted/removed authors
if (post.author === '[deleted]' || post.author === 'AutoModerator') return false;
+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;
}
+6
View File
@@ -12,6 +12,7 @@ import {
SendRedditUserMessageResponseSchema,
} from './agent-api-contracts.js';
import {
BrowsingSettingsUpdateRequestSchema,
GrowthAccountResponseSchema,
GrowthOverviewResponseSchema,
GrowthPauseRequestSchema,
@@ -180,6 +181,11 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
requestSchema: GrowthSettingsUpdateRequestSchema,
responseSchema: GrowthAccountResponseSchema,
}),
operation('updateBrowsingSimulation', 'PATCH', '/api/growth/{accountId}/browsing', 'Configure simulation-only browsing selections for an account', ['Growth'], {
access: 'admin',
requestSchema: BrowsingSettingsUpdateRequestSchema,
responseSchema: GrowthAccountResponseSchema,
}),
operation('pauseGrowth', 'POST', '/api/growth/{accountId}/pause', 'Stop an account from posting further comments', ['Growth'], {
access: 'control',
requestSchema: GrowthPauseRequestSchema,
+2
View File
@@ -28,6 +28,7 @@ import type { CandidateQueueRepository } from '../database/candidate-queue-repos
import type { MediaLibraryService } from '../services/media-library.js';
import type { AccountContentRepository } from '../database/account-repositories.js';
import type { AccountGrowthRepository } from '../database/growth-repository.js';
import type { AccountBrowsingSimulationRepository } from '../database/browsing-simulation-repository.js';
import type { PushNotifier } from '../services/push-notifier.js';
import type { BotMode } from '../services/bot-mode.js';
@@ -65,6 +66,7 @@ export interface WebContext {
accountRuntimes?: RedditAccountRuntimeManager;
candidateQueue?: CandidateQueueRepository;
growth?: AccountGrowthRepository;
browsing?: AccountBrowsingSimulationRepository;
mediaLibrary?: MediaLibraryService;
pushNotifier?: PushNotifier;
};
+41
View File
@@ -18,6 +18,45 @@ export const GrowthQuietHoursSchema = z.strictObject({
endHour: z.number().int().min(0).max(23),
});
export const BrowsingSettingsUpdateRequestSchema = z.strictObject({
enabled: z.boolean().optional(),
dailyTargetMin: z.number().int().min(1).max(12).optional(),
dailyTargetMax: z.number().int().min(1).max(12).optional(),
postWeight: z.number().min(0).max(100).optional(),
commentWeight: z.number().min(0).max(100).optional(),
quietHours: GrowthQuietHoursSchema.optional(),
minimumGapMs: z.number().int().min(45 * 60_000).max(DAY_MS).optional(),
jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
allowNsfw: z.boolean().optional(),
});
export const BrowsingSettingsSchema = z.strictObject({
enabled: z.boolean(), dailyTargetMin: z.number().int(), dailyTargetMax: z.number().int(),
postWeight: z.number(), commentWeight: z.number(), quietHours: GrowthQuietHoursSchema,
minimumGapMs: z.number().int(), jitterMs: z.number().int(), allowNsfw: z.boolean(),
nextDueAt: z.number().nullable(), updatedAt: z.number(),
});
export const BrowsingStatsSchema = z.strictObject({
selected: z.number().int(), posts: z.number().int(), comments: z.number().int(),
rejected: z.number().int(), skipped: z.number().int(),
});
export const BrowsingDecisionSchema = z.strictObject({
thingFullname: z.string(), thingKind: z.enum(['post', 'comment']), postId: z.string(),
subreddit: z.string(), author: z.string(), permalink: z.string(),
decision: z.enum(['simulated-upvote', 'skipped', 'rejected']), reason: z.string(), decidedAt: z.number(),
});
export const BrowsingSummarySchema = z.strictObject({
settings: BrowsingSettingsSchema, todayTarget: z.number().int(), stats: BrowsingStatsSchema,
noVotesSubmitted: z.literal(true),
});
export const BrowsingDetailSchema = BrowsingSummarySchema.extend({
recentDecisions: z.array(BrowsingDecisionSchema),
});
export const GrowthSettingsUpdateRequestSchema = z.strictObject({
enabled: z.boolean().optional(),
minDelayMs: z.number().int().min(0).max(DAY_MS).optional(),
@@ -107,11 +146,13 @@ export const GrowthAccountSummarySchema = z.strictObject({
stats: GrowthStatsSchema,
subredditCount: z.number().int(),
activeSubredditCount: z.number().int(),
browsing: BrowsingSummarySchema,
});
export const GrowthAccountDetailSchema = GrowthAccountSummarySchema.extend({
subreddits: z.array(GrowthSubredditSchema),
recentComments: z.array(GrowthCommentSchema),
browsing: BrowsingDetailSchema,
});
export const GrowthOverviewResponseSchema = z.strictObject({
+113 -20
View File
@@ -8,7 +8,10 @@ import {
type GrowthSubredditRecord,
} from '../../database/growth-repository.js';
import { dailyAllowance, effectiveDailyAllowance } from '../../services/growth-pacer.js';
import type { AccountBrowsingSimulationRepository } from '../../database/browsing-simulation-repository.js';
import { dailyTarget } from '../../services/account-browsing-simulation.js';
import {
BrowsingSettingsUpdateRequestSchema as BrowsingSettingsUpdateSchema,
GrowthPauseRequestSchema as PauseSchema,
GrowthSettingsUpdateRequestSchema as SettingsUpdateSchema,
GrowthSubredditCreateRequestSchema as SubredditCreateSchema,
@@ -27,6 +30,7 @@ export function createGrowthRouter(ctx: WebContext): Router {
const router = Router();
const growth = (): AccountGrowthRepository | null => ctx.services.growth ?? null;
const browsing = (): AccountBrowsingSimulationRepository | null => ctx.services.browsing ?? null;
const accountName = (accountId: string): string | null => {
const account = ctx.services.redditAccounts?.list()
@@ -36,12 +40,14 @@ export function createGrowthRouter(ctx: WebContext): Router {
router.get('/growth', (_req, res) => {
const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const accounts = ctx.services.redditAccounts?.list() ?? [];
return res.json({
generatedAt: Date.now(),
accounts: accounts.map((account) => summarize(
repo,
browsingRepo,
account.accountId,
account.redditUsername,
)),
@@ -50,16 +56,43 @@ export function createGrowthRouter(ctx: WebContext): Router {
router.get('/growth/:accountId', (req, res) => {
const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params;
const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
return res.json({ success: true, account: detail(repo, accountId, username) });
return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
});
router.patch('/growth/:accountId/browsing', (req, res) => {
const repo = growth();
const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params;
const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
const parsed = BrowsingSettingsUpdateSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid browsing settings', detail: parsed.error.issues });
}
try {
browsingRepo.saveSettings(accountId, parsed.data);
} catch (error) {
return res.status(400).json({ error: errorMessage(error) });
}
if (parsed.data.enabled !== undefined) {
ctx.broadcaster.push(
'info',
`Browsing simulation ${parsed.data.enabled ? 'enabled' : 'disabled'} for u/${username}`,
);
}
return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
});
router.patch('/growth/:accountId', (req, res) => {
const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params;
const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
@@ -79,12 +112,13 @@ export function createGrowthRouter(ctx: WebContext): Router {
`Account growth ${parsed.data.enabled ? 'enabled' : 'disabled'} for u/${username}`,
);
}
return res.json({ success: true, account: detail(repo, accountId, username) });
return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
});
router.post('/growth/:accountId/pause', (req, res) => {
const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params;
const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
@@ -95,36 +129,39 @@ export function createGrowthRouter(ctx: WebContext): Router {
}
repo.pause(accountId, parsed.data.reason ?? 'Paused by the operator');
ctx.broadcaster.push('warn', `Account growth paused for u/${username}`);
return res.json({ success: true, account: detail(repo, accountId, username) });
return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
});
router.post('/growth/:accountId/resume', (req, res) => {
const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params;
const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
repo.resume(accountId);
ctx.broadcaster.push('info', `Account growth resumed for u/${username}`);
return res.json({ success: true, account: detail(repo, accountId, username) });
return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
});
router.delete('/growth/:accountId/queue', (req, res) => {
const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params;
const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
const cleared = repo.clearPendingQueue(accountId);
ctx.broadcaster.push('warn', `Cleared ${cleared} pending growth item(s) for u/${username}`);
return res.json({ success: true, cleared, account: detail(repo, accountId, username) });
return res.json({ success: true, cleared, account: detail(repo, browsingRepo, accountId, username) });
});
router.post('/growth/:accountId/subreddits', (req, res) => {
const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params;
const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
@@ -141,12 +178,13 @@ export function createGrowthRouter(ctx: WebContext): Router {
} catch (error) {
return res.status(400).json({ error: errorMessage(error) });
}
return res.status(201).json({ success: true, account: detail(repo, accountId, username) });
return res.status(201).json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
});
router.patch('/growth/:accountId/subreddits/:subreddit', (req, res) => {
const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params;
const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
@@ -175,12 +213,13 @@ export function createGrowthRouter(ctx: WebContext): Router {
if (parsed.data.enabled !== undefined) {
repo.setSubredditEnabled(accountId, name, parsed.data.enabled);
}
return res.json({ success: true, account: detail(repo, accountId, username) });
return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
});
router.delete('/growth/:accountId/subreddits/:subreddit', (req, res) => {
const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params;
const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
@@ -194,13 +233,18 @@ export function createGrowthRouter(ctx: WebContext): Router {
if (!repo.removeSubreddit(accountId, name)) {
return res.status(404).json({ error: `r/${name} is not on this account's list` });
}
return res.json({ success: true, account: detail(repo, accountId, username) });
return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
});
return router;
}
function summarize(repo: AccountGrowthRepository, accountId: string, redditUsername: string) {
function summarize(
repo: AccountGrowthRepository,
browsingRepo: AccountBrowsingSimulationRepository,
accountId: string,
redditUsername: string,
) {
const now = Date.now();
const settings = repo.getSettings(accountId, now);
const subreddits = repo.listSubreddits(accountId);
@@ -211,15 +255,64 @@ function summarize(repo: AccountGrowthRepository, accountId: string, redditUsern
stats: presentStats(repo, accountId, now),
subredditCount: subreddits.length,
activeSubredditCount: repo.listActiveSubreddits(accountId, now).length,
browsing: presentBrowsing(browsingRepo, accountId, now, false),
};
}
function detail(repo: AccountGrowthRepository, accountId: string, redditUsername: string) {
function detail(
repo: AccountGrowthRepository,
browsingRepo: AccountBrowsingSimulationRepository,
accountId: string,
redditUsername: string,
) {
const now = Date.now();
return {
...summarize(repo, accountId, redditUsername),
...summarize(repo, browsingRepo, accountId, redditUsername),
subreddits: repo.listSubreddits(accountId).map((entry) => presentSubreddit(repo, accountId, entry, now)),
recentComments: repo.listRecent(accountId, 50).map(presentComment),
browsing: presentBrowsing(browsingRepo, accountId, now, true),
};
}
function presentBrowsing(
repo: AccountBrowsingSimulationRepository,
accountId: string,
now: number,
includeRecent: boolean,
) {
const settings = repo.getSettings(accountId, now);
const day = new Date(now);
day.setHours(0, 0, 0, 0);
return {
settings: {
enabled: settings.enabled,
dailyTargetMin: settings.dailyTargetMin,
dailyTargetMax: settings.dailyTargetMax,
postWeight: settings.postWeight,
commentWeight: settings.commentWeight,
quietHours: settings.quietHours,
minimumGapMs: settings.minimumGapMs,
jitterMs: settings.jitterMs,
allowNsfw: settings.allowNsfw,
nextDueAt: settings.nextDueAt,
updatedAt: settings.updatedAt,
},
todayTarget: dailyTarget(accountId, day.getTime(), settings),
stats: repo.getCountsSince(accountId, day.getTime()),
noVotesSubmitted: true as const,
...(includeRecent ? {
recentDecisions: repo.listRecentSelections(accountId, 50).map((decision) => ({
thingFullname: decision.thingFullname,
thingKind: decision.thingKind,
postId: decision.postId,
subreddit: decision.subreddit,
author: decision.author,
permalink: decision.permalink,
decision: decision.decision,
reason: decision.reason,
decidedAt: decision.decidedAt,
})),
} : {}),
};
}
+5
View File
@@ -52,6 +52,7 @@ import type { MediaLibraryService } from '../services/media-library.js';
import { createMediaLibraryRouter } from './routes/media-library.js';
import type { AccountContentRepository } from '../database/account-repositories.js';
import type { AccountGrowthRepository } from '../database/growth-repository.js';
import type { AccountBrowsingSimulationRepository } from '../database/browsing-simulation-repository.js';
import { createGrowthRouter } from './routes/growth.js';
import { allowsSending, type BotMode } from '../services/bot-mode.js';
@@ -278,6 +279,10 @@ export class WebServer {
this.services.growth = growth;
}
setBrowsingRepository(browsing: AccountBrowsingSimulationRepository): void {
this.services.browsing = browsing;
}
setConfigStore(configStore: ConfigStore): void {
this.services.configStore = configStore;
}