Files
RedditBot/src/web/routes/growth.ts
T

415 lines
16 KiB
TypeScript

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);
}