import { Router } from 'express'; import type { WebContext } from '../context.js'; import { normalizeSubreddit, type AccountGrowthRepository, type GrowthCommentRecord, type GrowthSettingsRecord, 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, GrowthSubredditUpdateRequestSchema as SubredditUpdateSchema, } from '../growth-contracts.js'; const DAY_MS = 24 * 60 * 60_000; /** * Operator control over the account grower. * * Growth is per-account, so every route is scoped by account ID and nothing * here can act on more than one identity at a time. */ 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() .find((entry) => entry.accountId === accountId); return account?.redditUsername ?? null; }; router.get('/growth', (_req, res) => { const repo = growth(); 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, )), }); }); router.get('/growth/:accountId', (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' }); 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.post('/growth/:accountId/browsing/run', async (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 runtime = ctx.services.accountRuntimes?.get(accountId); const browsingService = runtime?.resources.browsingService; if (runtime?.snapshot().state !== 'running' || !browsingService) { return res.status(409).json({ error: 'Start this account before running a browsing simulation' }); } try { await browsingService.runNow(); } catch (error) { return res.status(502).json({ error: `Browsing simulation failed: ${errorMessage(error)}` }); } ctx.broadcaster.push('info', `Browsing session run manually for u/${username}`); return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) }); }); router.patch('/growth/:accountId', (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 = SettingsUpdateSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: 'Invalid growth settings', detail: parsed.error.issues }); } try { repo.saveSettings(accountId, parsed.data); } catch (error) { return res.status(400).json({ error: errorMessage(error) }); } if (parsed.data.enabled !== undefined) { ctx.broadcaster.push( 'info', `Account growth ${parsed.data.enabled ? 'enabled' : 'disabled'} for u/${username}`, ); } return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) }); }); router.post('/growth/:accountId/pause', (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 = PauseSchema.safeParse(req.body ?? {}); if (!parsed.success) { return res.status(400).json({ error: 'Invalid pause request', detail: parsed.error.issues }); } 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, browsingRepo, accountId, username) }); }); router.post('/growth/:accountId/resume', (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' }); repo.resume(accountId); ctx.broadcaster.push('info', `Account growth resumed for u/${username}`); return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) }); }); router.delete('/growth/:accountId/queue', (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 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, browsingRepo, accountId, username) }); }); router.post('/growth/:accountId/subreddits', (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 = SubredditCreateSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: 'Invalid subreddit', detail: parsed.error.issues }); } try { repo.addSubreddit(accountId, parsed.data.subreddit, { weight: parsed.data.weight, dailyCap: parsed.data.dailyCap, }); } catch (error) { return res.status(400).json({ error: errorMessage(error) }); } return res.status(201).json({ success: true, account: detail(repo, browsingRepo, accountId, username) }); }); router.patch('/growth/:accountId/subreddits/:subreddit', (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 = SubredditUpdateSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: 'Invalid subreddit update', detail: parsed.error.issues }); } let name: string; try { name = normalizeSubreddit(req.params.subreddit); } catch (error) { return res.status(400).json({ error: errorMessage(error) }); } const existing = repo.listSubreddits(accountId) .find((entry) => entry.subreddit.toLowerCase() === name.toLowerCase()); if (!existing) return res.status(404).json({ error: `r/${name} is not on this account's list` }); if (parsed.data.weight !== undefined || parsed.data.dailyCap !== undefined) { repo.addSubreddit(accountId, name, { weight: parsed.data.weight ?? existing.weight, dailyCap: parsed.data.dailyCap ?? existing.dailyCap, }); } if (parsed.data.enabled !== undefined) { repo.setSubredditEnabled(accountId, name, parsed.data.enabled); } return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) }); }); router.delete('/growth/:accountId/subreddits/:subreddit', (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' }); let name: string; try { name = normalizeSubreddit(req.params.subreddit); } catch (error) { return res.status(400).json({ error: errorMessage(error) }); } 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, browsingRepo, accountId, username) }); }); return router; } 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); return { accountId, redditUsername, settings: presentSettings(settings), stats: presentStats(repo, accountId, now), subredditCount: subreddits.length, activeSubredditCount: repo.listActiveSubreddits(accountId, now).length, browsing: presentBrowsing(browsingRepo, accountId, now, false), }; } function detail( repo: AccountGrowthRepository, browsingRepo: AccountBrowsingSimulationRepository, accountId: string, redditUsername: string, ) { const now = Date.now(); return { ...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, autoVoteEnabled: settings.autoVoteEnabled, nextDueAt: settings.nextDueAt, updatedAt: settings.updatedAt, }, todayTarget: dailyTarget(accountId, day.getTime(), settings), stats: repo.getCountsSince(accountId, day.getTime()), noVotesSubmitted: !settings.autoVoteEnabled, ...(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, voteSubmitted: decision.voteSubmitted, decidedAt: decision.decidedAt, })), } : {}), }; } function presentSettings(settings: GrowthSettingsRecord) { return { enabled: settings.enabled, trustLevel: settings.trustLevel, /** What is enforced: the override when set, otherwise the earned allowance. */ dailyAllowance: effectiveDailyAllowance(settings), /** What the adaptive ladder alone would permit, shown alongside an override. */ adaptiveAllowance: dailyAllowance(settings.trustLevel), dailyLimit: settings.dailyLimit, minDelayMs: settings.minDelayMs, jitterMs: settings.jitterMs, quietHours: settings.quietHours, allowNsfw: settings.allowNsfw, pausedReason: settings.pausedReason, pausedAt: settings.pausedAt, consecutiveRemovals: settings.consecutiveRemovals, levelChangedAt: settings.levelChangedAt, updatedAt: settings.updatedAt, }; } function presentStats(repo: AccountGrowthRepository, accountId: string, now: number) { const stats = repo.getStats(accountId, now); return { postedLast24h: stats.postedLast24h, postedTotal: stats.postedTotal, removedTotal: stats.removedTotal, medianScore: stats.medianScore, queued: stats.byStatus.discovered + stats.byStatus.retryable + stats.byStatus.posting, rejectedTotal: stats.byStatus.rejected, }; } function presentSubreddit( repo: AccountGrowthRepository, accountId: string, entry: GrowthSubredditRecord, now: number, ) { return { subreddit: entry.subreddit, enabled: entry.enabled, weight: entry.weight, dailyCap: entry.dailyCap, disabledReason: entry.disabledReason, cooldownUntil: entry.cooldownUntil, addedAt: entry.addedAt, postedLast24h: repo.countPostedInSubredditSince(accountId, entry.subreddit, now - DAY_MS), }; } function presentComment(record: GrowthCommentRecord) { return { postId: record.postId, subreddit: record.subreddit, status: record.status, threadTitle: record.threadTitle, threadUrl: record.threadUrl, comment: record.generatedComment, commentPermalink: record.commentPermalink, score: record.lastScore, removed: record.removedAt !== null, shadowBanned: record.shadowBanned, postedAt: record.postedAt, discoveredAt: record.discoveredAt, lastError: record.lastError, }; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); }