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