Enable automatic browsing upvotes

This commit is contained in:
jjbrunton committed 2026-08-11 21:52:54 +01:00
1 parent 27012399ec
commit 7bf1aa9d24
26 files changed
+339 -57

No files matched your search

@@ -64,6 +64,12 @@ export function updateBrowsingSettings(
.then((payload) => payload.account); .then((payload) => payload.account);
} }
export function runBrowsingSimulation(accountId: string): Promise<GrowthAccountDetail> {
return dashboardRequest(`${accountPath(accountId)}/browsing/run`, { method: 'POST' })
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
.then((payload) => payload.account);
}
export function pauseGrowth(accountId: string, reason?: string): Promise<GrowthAccountDetail> { export function pauseGrowth(accountId: string, reason?: string): Promise<GrowthAccountDetail> {
return dashboardRequest(`${accountPath(accountId)}/pause`, { return dashboardRequest(`${accountPath(accountId)}/pause`, {
method: 'POST', method: 'POST',
@@ -15,6 +15,7 @@ import {
usePauseGrowthMutation, usePauseGrowthMutation,
useRemoveGrowthSubredditMutation, useRemoveGrowthSubredditMutation,
useResumeGrowthMutation, useResumeGrowthMutation,
useRunBrowsingSimulationMutation,
useUpdateGrowthSettingsMutation, useUpdateGrowthSettingsMutation,
useUpdateBrowsingSettingsMutation, useUpdateBrowsingSettingsMutation,
useUpdateGrowthSubredditMutation, useUpdateGrowthSubredditMutation,
@@ -174,6 +175,7 @@ function SubredditRow({
function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }) { function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }) {
const update = useUpdateBrowsingSettingsMutation(account.accountId); const update = useUpdateBrowsingSettingsMutation(account.accountId);
const runNow = useRunBrowsingSimulationMutation(account.accountId);
const { settings, stats, todayTarget, recentDecisions } = account.browsing; const { settings, stats, todayTarget, recentDecisions } = account.browsing;
const selected = recentDecisions.filter((decision) => decision.decision === 'simulated-upvote'); const selected = recentDecisions.filter((decision) => decision.decision === 'simulated-upvote');
@@ -181,22 +183,39 @@ function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }
<section aria-labelledby="browsing-simulation-heading" className="panel settings-card"> <section aria-labelledby="browsing-simulation-heading" className="panel settings-card">
<div className="settings-card-heading"> <div className="settings-card-heading">
<div> <div>
<span className="eyebrow">Simulation only</span> <span className="eyebrow">Conservative account activity</span>
<h2 id="browsing-simulation-heading">Browsing simulation</h2> <h2 id="browsing-simulation-heading">Browsing sessions</h2>
<p>Tests selection and pacing without submitting votes or any other Reddit write.</p> <p>Selects conservatively; automatic upvotes require their own explicit switch.</p>
</div> </div>
</div> </div>
<div className="settings-card-body"> <div className="settings-card-body">
<div className="state-banner is-warning" role="status"> <div className="state-banner is-warning" role="status">
<strong>No Reddit votes were submitted.</strong> <strong>{account.browsing.noVotesSubmitted ? 'No Reddit votes will be submitted.' : 'Automatic upvotes are enabled.'}</strong>
<span>Every item below is a hypothetical upvote recorded only in RedditBot.</span> <span>{account.browsing.noVotesSubmitted
? 'Selections are recorded only in RedditBot.'
: 'Votes run only in running mode with browser dry-run disabled.'}</span>
</div> </div>
<SwitchField <SwitchField
checked={settings.enabled} checked={settings.enabled}
description={`Selects ${settings.dailyTargetMin}–${settings.dailyTargetMax} eligible posts or comments during waking hours.`} description={`Selects ${settings.dailyTargetMin}–${settings.dailyTargetMax} eligible posts or comments during waking hours.`}
label="Simulate browsing selections" label="Run browsing sessions"
onChange={(enabled) => update.mutate({ enabled })} onChange={(enabled) => update.mutate({ enabled })}
/> />
<SwitchField
checked={settings.autoVoteEnabled}
description="Submit the selected upvote automatically. Scan mode and browser dry-run always remain simulation-only."
label="Submit selected upvotes"
onChange={(autoVoteEnabled) => update.mutate({ autoVoteEnabled })}
/>
<button
className="button secondary"
disabled={!settings.enabled || runNow.isPending}
onClick={() => runNow.mutate()}
type="button"
>
{runNow.isPending ? 'Running simulation…' : 'Simulate session now'}
</button>
{!settings.enabled ? <p className="field-hint">Enable browsing sessions to run a simulation now.</p> : null}
<dl className="growth-metrics"> <dl className="growth-metrics">
<div><dt>Selected today</dt><dd>{stats.selected} / {todayTarget}</dd></div> <div><dt>Selected today</dt><dd>{stats.selected} / {todayTarget}</dd></div>
<div><dt>Posts</dt><dd>{stats.posts}</dd></div> <div><dt>Posts</dt><dd>{stats.posts}</dd></div>
@@ -209,9 +228,10 @@ function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }
</div> </div>
</dl> </dl>
{update.isError ? <p className="save-error" role="alert">{errorMessage(update.error)}</p> : null} {update.isError ? <p className="save-error" role="alert">{errorMessage(update.error)}</p> : null}
{runNow.isError ? <p className="save-error" role="alert">{errorMessage(runNow.error)}</p> : null}
{selected.length === 0 ? ( {selected.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<strong>No simulated selections yet</strong> <strong>No browsing selections yet</strong>
<span>The first session is durably scheduled after this feature is enabled.</span> <span>The first session is durably scheduled after this feature is enabled.</span>
</div> </div>
) : ( ) : (
@@ -222,7 +242,9 @@ function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }
<a href={`https://www.reddit.com${decision.permalink}`} rel="noreferrer noopener" target="_blank"> <a href={`https://www.reddit.com${decision.permalink}`} rel="noreferrer noopener" target="_blank">
r/{decision.subreddit} — {decision.thingKind} by u/{decision.author} r/{decision.subreddit} — {decision.thingKind} by u/{decision.author}
</a> </a>
<span className="pill neutral">Simulated upvote</span> <span className={`pill ${decision.voteSubmitted ? 'success' : 'neutral'}`}>
{decision.voteSubmitted ? 'Upvote submitted' : 'Simulated selection'}
</span>
</div> </div>
<p className="field-hint">{new Date(decision.decidedAt).toLocaleString()}</p> <p className="field-hint">{new Date(decision.decidedAt).toLocaleString()}</p>
</li> </li>
@@ -13,6 +13,7 @@ import {
pauseGrowth, pauseGrowth,
removeGrowthSubreddit, removeGrowthSubreddit,
resumeGrowth, resumeGrowth,
runBrowsingSimulation,
updateGrowthSettings, updateGrowthSettings,
updateBrowsingSettings, updateBrowsingSettings,
updateGrowthSubreddit, updateGrowthSubreddit,
@@ -72,6 +73,10 @@ export function useUpdateBrowsingSettingsMutation(accountId: string) {
); );
} }
export function useRunBrowsingSimulationMutation(accountId: string) {
return useGrowthMutation<void>(accountId, () => runBrowsingSimulation(accountId));
}
export function usePauseGrowthMutation(accountId: string) { export function usePauseGrowthMutation(accountId: string) {
return useGrowthMutation(accountId, (reason?: string) => pauseGrowth(accountId, reason)); return useGrowthMutation(accountId, (reason?: string) => pauseGrowth(accountId, reason));
} }
+2 -1
View File
@@ -108,7 +108,8 @@ an administrator credential.
| `GET` | `/api/growth` | Get per-account comment growth state | read | no | | `GET` | `/api/growth` | Get per-account comment growth state | read | no |
| `GET` | `/api/growth/{accountId}` | Get one account's growth settings, communities and comment history | read | no | | `GET` | `/api/growth/{accountId}` | Get one account's growth settings, communities and comment history | read | no |
| `PATCH` | `/api/growth/{accountId}` | Enable, disable or re-pace comment growth for an account | admin | yes | | `PATCH` | `/api/growth/{accountId}` | Enable, disable or re-pace comment growth for an account | admin | yes |
| `PATCH` | `/api/growth/{accountId}/browsing` | Configure simulation-only browsing selections for an account | admin | yes | | `PATCH` | `/api/growth/{accountId}/browsing` | Configure per-account browsing selection and automatic upvotes | admin | yes |
| `POST` | `/api/growth/{accountId}/browsing/run` | Run one simulation-only browsing session immediately | admin | yes |
| `POST` | `/api/growth/{accountId}/pause` | Stop an account from posting further comments | control | yes | | `POST` | `/api/growth/{accountId}/pause` | Stop an account from posting further comments | control | yes |
| `POST` | `/api/growth/{accountId}/resume` | Clear a growth pause after reviewing the cause | admin | yes | | `POST` | `/api/growth/{accountId}/resume` | Clear a growth pause after reviewing the cause | admin | yes |
| `DELETE` | `/api/growth/{accountId}/queue` | Clear queued and retryable growth threads that have not been posted | admin | yes | | `DELETE` | `/api/growth/{accountId}/queue` | Clear queued and retryable growth threads that have not been posted | admin | yes |
+2 -2
View File
@@ -66,7 +66,7 @@ SIGINT/SIGTERM abort shared loops, stop account runtimes, stop the web server, c
- verified Reddit and Matrix identity; - verified Reddit and Matrix identity;
- persistent Playwright browser profile; - persistent Playwright browser profile;
- posts, messages, outcomes, activity, outbox, and conversation state repositories; - posts, messages, outcomes, activity, outbox, and conversation state repositories;
- growth settings, community list, comment history, adaptive comment allowance, and isolated browsing-simulation policy/audit; - growth settings, community list, comment history, adaptive comment allowance, and isolated browsing policy/audit with optional submitted-vote state;
- Matrix chat cache and sync cursor; - Matrix chat cache and sync cursor;
- media index and files; - media index and files;
- rate limiter and invitation cooldown; - rate limiter and invitation cooldown;
@@ -137,7 +137,7 @@ docs/ Maintainer and agent documentation
- Shared operator media: keep it under `MediaLibraryService`/`MediaLibraryRepository`; never merge it with account-owned received media. - Shared operator media: keep it under `MediaLibraryService`/`MediaLibraryRepository`; never merge it with account-owned received media.
- New account behavior: place it in the account runtime or a service injected into it; do not add mutable cross-account identity to a singleton. - New account behavior: place it in the account runtime or a service injected into it; do not add mutable cross-account identity to a singleton.
- New account-growth behavior: keep it account-scoped and lowest-priority, fail closed before anything is published, and make every outcome visible in the growth audit trail. - New account-growth behavior: keep it account-scoped and lowest-priority, fail closed before anything is published, and make every outcome visible in the growth audit trail.
- Simulation-only account behavior: keep the decision sink separate from Reddit transports so future remote-write authority cannot appear as a hidden setting. - Browsing actions: keep selection and durable audit separate from the browser-session vote transport; remote writes require an explicit per-account setting and are unavailable in scan/dry-run modes.
- New candidate stage: make transitions durable and idempotent, expose counts/reasons, and define lease/retry behavior. - New candidate stage: make transitions durable and idempotent, expose counts/reasons, and define lease/retry behavior.
- New dashboard feature: add a feature slice and route, use shared schemas and query keys, and treat HTTP as authoritative. - New dashboard feature: add a feature slice and route, use shared schemas and query keys, and treat HTTP as authoritative.
+1 -1
View File
@@ -105,7 +105,7 @@ A per-community `dailyCap` only distributes the account allowance; caps are not
Community weights feed a new weighted-random pick for each discovered growth thread. The grower queues only one new thread at a time, drains due retries and existing queue work before polling another community, and exposes an account-scoped queue clear that removes only unposted discovered/retryable rows. Community weights feed a new weighted-random pick for each discovered growth thread. The grower queues only one new thread at a time, drains due retries and existing queue work before polling another community, and exposes an account-scoped queue clear that removes only unposted discovered/retryable rows.
Browsing simulation is configured separately per account through `/api/growth/{accountId}/browsing` and reuses the enabled growth-community list and weights. It defaults off, targets 3–6 hypothetical selections per local day with a 60/40 post/comment split, enforces at least 45 minutes between sessions, respects quiet hours, excludes NSFW unless enabled, and has a non-overridable 12/day ceiling. `nextDueAt` is service-owned durable state and cannot be set through the API. Browser dry-run does not change the behavior because simulation never performs a remote write. Browsing sessions are configured separately per account through `/api/growth/{accountId}/browsing` and reuse the enabled growth-community list and weights. Sessions default off and target 3–6 selections per local day with a 60/40 post/comment split, enforce at least 45 minutes between sessions, respect quiet hours, exclude NSFW unless enabled, and have a non-overridable 12/day ceiling. `autoVoteEnabled` is a separate, default-off operator setting; it can submit the selected upvote only in running mode when browser dry-run is off. `nextDueAt` is service-owned durable state and cannot be set through the API.
## Runtime-editable subset ## Runtime-editable subset
+3 -3
View File
@@ -60,11 +60,11 @@ Every runtime shares one `RedditRequestCoordinator`, because Reddit can enforce
Do not add local retries around this layer without preserving the shared schedule. Per-account bursts defeat the coordinator. Do not add local retries around this layer without preserving the shared schedule. Per-account bursts defeat the coordinator.
## Browsing simulation flow ## Browsing session flow
After reply handling, response checks, and comment-growth work, an enabled account may run one due browsing simulation session. The durable `next_due_at` gate survives restarts; a deterministic per-account daily target and randomized waking-hours spacing avoid recalculating a flapping schedule on each minute tick. The session picks one configured growth community by weight, reads its hot listing through the shared Reddit request coordinator, optionally reads typed top-level comments, applies deterministic exclusions, and only then selects randomly. After reply handling, response checks, and comment-growth work, an enabled account may run one due browsing session. The durable `next_due_at` gate survives restarts; a deterministic per-account daily target and randomized waking-hours spacing avoid recalculating a flapping schedule on each minute tick. The session picks one configured growth community by weight, reads its hot listing through the shared Reddit request coordinator, optionally reads typed top-level comments, applies deterministic exclusions, and only then selects randomly.
The terminal operation records `simulated-upvote` in SQLite and operator activity. There is no vote endpoint or Reddit write dependency. Scan, running, and browser dry-run all perform the same simulation; stopped mode performs no discovery or decision work. Reddit 429 responses extend the shared cooldown, while ordinary read failures remain isolated from replies and comment growth. The terminal operation records the selection in SQLite before any remote write, so an interrupted or uncertain vote is never retried. With the separate automatic-upvote setting enabled, running mode sends one authenticated Reddit `api/vote` request through the same browser transport and shared coordinator as growth comments, then marks the audit record submitted only after Reddit confirms it. Scan mode and browser dry-run always record simulation-only selections; stopped mode performs no discovery or decision work. Reddit 429 responses extend the shared cooldown, while ordinary read failures remain isolated from replies and comment growth. An operator can also run one immediate session for an enabled, running account: it bypasses the due, quiet-hours, and daily-target gates but preserves content exclusions and always disables vote submission.
## Reply-first conversation automation ## Reply-first conversation automation
+3 -3
View File
@@ -20,8 +20,8 @@ The application uses one `better-sqlite3` database, normally `data/reddit-bot.db
- `account_growth_settings`: per-account commenting switch, adaptive trust level, optional operator daily-limit override (`daily_limit`, null means adaptive), spacing/jitter, quiet hours, NSFW opt-in, and the operator-clearable pause reason. - `account_growth_settings`: per-account commenting switch, adaptive trust level, optional operator daily-limit override (`daily_limit`, null means adaptive), spacing/jitter, quiet hours, NSFW opt-in, and the operator-clearable pause reason.
- `account_growth_subreddits`: per-account community list with weight, daily cap, and any Reddit-imposed disable reason or cooldown. - `account_growth_subreddits`: per-account community list with weight, daily cap, and any Reddit-imposed disable reason or cooldown.
- `account_growth_comments`: durable growth comment records from discovery through posting and verification, including the exact text posted, score history, removal and public-visibility verdicts. - `account_growth_comments`: durable growth comment records from discovery through posting and verification, including the exact text posted, score history, removal and public-visibility verdicts.
- `account_browsing_settings`: disabled-by-default per-account simulation policy, conservative daily range, post/comment weights, quiet hours, spacing, NSFW policy, and durable next due time. - `account_browsing_settings`: disabled-by-default per-account browsing policy, conservative daily range, post/comment weights, quiet hours, spacing, NSFW policy, default-off automatic-upvote setting, and durable next due time.
- `account_browsing_decisions`: minimal post/comment identity, permalink, decision/reason, and scheduled-session audit. Reddit text is deliberately not retained. - `account_browsing_decisions`: minimal post/comment identity, permalink, decision/reason, submitted-vote flag, and scheduled-session audit. Reddit text is deliberately not retained.
The special legacy-unassigned account exists only to preserve rows created before account ownership was introduced. New runtime data must use a verified account ID. The special legacy-unassigned account exists only to preserve rows created before account ownership was introduced. New runtime data must use a verified account ID.
@@ -63,7 +63,7 @@ The special legacy-unassigned account exists only to preserve rows created befor
11. Exactly one account may comment on a given thread. `account_growth_comments` enforces this with a unique index on `post_id` across all accounts, because two owned accounts commenting on the same post is the easiest pattern for Reddit to correlate. 11. Exactly one account may comment on a given thread. `account_growth_comments` enforces this with a unique index on `post_id` across all accounts, because two owned accounts commenting on the same post is the easiest pattern for Reddit to correlate.
12. A growth comment interrupted while posting is rejected, never retried. The process cannot know whether the write reached Reddit, and a duplicate comment cannot be withdrawn. 12. A growth comment interrupted while posting is rejected, never retried. The process cannot know whether the write reached Reddit, and a duplicate comment cannot be withdrawn.
13. Browsing decisions are unique per account and thing. Before selecting, the service also checks for a `simulated-upvote` by any other owned account, but account-specific rejections do not unnecessarily reserve an item globally. 13. Browsing decisions are unique per account and thing. Before selecting, the service also checks for a `simulated-upvote` by any other owned account, but account-specific rejections do not unnecessarily reserve an item globally.
14. Browsing simulation rows never contribute to adaptive comment-growth promotion, and no schema or repository operation represents a submitted Reddit vote. 14. A browsing selection is persisted before any vote request. `vote_submitted` becomes true only after Reddit confirms the authenticated upvote; unconfirmed selections are never retried. Browsing rows never contribute to adaptive comment-growth promotion.
10. Recipient account status changes only after a definitive Reddit profile response. Transient lookup failures retain the previous value, and the shared check timestamp prevents duplicate lookups across account runtimes during the four-hour interval. 10. Recipient account status changes only after a definitive Reddit profile response. Transient lookup failures retain the previous value, and the shared check timestamp prevents duplicate lookups across account runtimes during the four-hour interval.
+2 -2
View File
@@ -19,14 +19,14 @@ Shared HTTP/SSE schemas live in `packages/api-contracts`. The server and client
| `/media-library` | Upload, classify, correct, enable, preview, and delete shared exchange images. | | `/media-library` | Upload, classify, correct, enable, preview, and delete shared exchange images. |
| `/performance` | Mature outcome trends, strategy/subreddit results, and model usage. | | `/performance` | Mature outcome trends, strategy/subreddit results, and model usage. |
| `/operations` | Background jobs, sync state, events, rate limits, and technical logs. | | `/operations` | Background jobs, sync state, events, rate limits, and technical logs. |
| `/growth` | Per-account comment growth plus simulation-only browsing: switches, communities, pacing/allowance, next browsing session, and both audit trails. | | `/growth` | Per-account comment growth plus browsing sessions: switches, communities, pacing/allowance, next session, optional automatic-upvote control, and both audit trails. |
| `/settings` | Validated runtime configuration and account/session management. | | `/settings` | Validated runtime configuration and account/session management. |
Navigation is declared in `app/navigation.ts`; route elements are composed in `app/router.tsx`. Navigation is declared in `app/navigation.ts`; route elements are composed in `app/router.tsx`.
The account-growth page states plainly that comments are AI-written and posted without review, shows the adaptive allowance and trust level rather than a settable rate, and surfaces every posted comment's exact text alongside its score and removal state. A paused account replaces the pause control with a resume control and explains why it stopped, because resuming is the one action that can compound the damage. The account-growth page states plainly that comments are AI-written and posted without review, shows the adaptive allowance and trust level rather than a settable rate, and surfaces every posted comment's exact text alongside its score and removal state. A paused account replaces the pause control with a resume control and explains why it stopped, because resuming is the one action that can compound the damage.
Its browsing section separately states that no Reddit votes were submitted, shows today's deterministic target, post/comment split, exclusion counts, durable next session, and recent simulated selections with Reddit permalinks. Enabling browsing changes only the simulation policy; it does not enable comment growth or bot processing. Its browsing section separates session scheduling from the default-off automatic-upvote switch, shows whether each selection was submitted or simulated, today's deterministic target, post/comment split, exclusion counts, durable next session, and recent selections with Reddit permalinks. Scan mode and browser dry-run remain simulation-only. An enabled, running account can also run one immediate simulation-only selection; it bypasses the schedule but never submits a vote. Enabling browsing does not enable comment growth or bot processing.
The Monitoring settings section owns the optional physique-targeting toggle and inclusive 1–10 range. It explains that the signal is subjective, applies only to image posts, and screens out unscorable images while enabled. The Monitoring settings section owns the optional physique-targeting toggle and inclusive 1–10 range. It explains that the signal is subjective, applies only to image posts, and screens out unscorable images while enabled.
+1 -1
View File
@@ -70,7 +70,7 @@ This is inauthentic account activity under Reddit's content policy, and many sub
- Growth deliberately continues while an account is held out of outbound messaging, because building the history is what lifts that hold. It is always the lowest-priority work an account performs, after inbound replies. - Growth deliberately continues while an account is held out of outbound messaging, because building the history is what lifts that hold. It is always the lowest-priority work an account performs, after inbound replies.
- Dry run prepares and records a comment but never posts it. Every posted comment, and every comment refused by either gate, is kept with its reason in the growth audit trail. - Dry run prepares and records a comment but never posts it. Every posted comment, and every comment refused by either gate, is kept with its reason in the growth audit trail.
Browsing simulation is a separate, disabled-by-default account-growth experiment. It selects a conservative number of eligible posts or comments from the same configured communities and records a hypothetical upvote, but it cannot submit a vote: the service has no Reddit vote method or write transport. Owned-account content, outreach recipients, cross-account duplicates, growth-comment threads, promotion experiments, moderator content, closed or removed threads, old or low-context items, and NSFW content unless explicitly allowed are excluded before randomness is applied. Simulation records never affect the adaptive comment-growth trust level. Browsing sessions are a separate, disabled-by-default account-growth experiment. They select a conservative number of eligible posts or comments from the same configured communities. The per-account automatic-upvote switch is separately disabled by default; when enabled, a selected item is upvoted through the account's authenticated browser session only in running mode and outside browser dry-run. Owned-account content, outreach recipients, cross-account duplicates, growth-comment threads, promotion experiments, moderator content, closed or removed threads, old or low-context items, and NSFW content unless explicitly allowed are excluded before randomness is applied. Every selection records whether Reddit confirmed the vote, and browsing never affects the adaptive comment-growth trust level.
## Operator exchange media ## Operator exchange media
+2 -2
View File
@@ -41,8 +41,8 @@ Comment-based growth exists so a new account accumulates the age/karma history R
| `services/growth-comment-safety.ts` | Deterministic pre-publication gate on generated comment text | Rejects links, contact solicitation, user tags, filler, and any text revealing the generator. Independent of prompt wording. | | `services/growth-comment-safety.ts` | Deterministic pre-publication gate on generated comment text | Rejects links, contact solicitation, user tags, filler, and any text revealing the generator. Independent of prompt wording. |
| `reddit/comment-client.ts` | Comment submission and comment-state lookups for one account | Classifies Reddit's error codes into retry/abandon/disable/re-auth/stop; runs inside the account's own browser session and shares the request coordinator. | | `reddit/comment-client.ts` | Comment submission and comment-state lookups for one account | Classifies Reddit's error codes into retry/abandon/disable/re-auth/stop; runs inside the account's own browser session and shares the request coordinator. |
| `database/growth-repository.ts` | Growth settings, per-account community list, durable comment records | One comment per thread across every account; mutations require the expected worker lease; queue clearing deletes only unposted discovered/retryable rows. | | `database/growth-repository.ts` | Growth settings, per-account community list, durable comment records | One comment per thread across every account; mutations require the expected worker lease; queue clearing deletes only unposted discovered/retryable rows. |
| `services/account-browsing-simulation.ts` | Durable pacing, eligibility, post/comment weighting, and hypothetical selection | Runs below comment growth; its only terminal capability is a simulation decision sink, never a Reddit vote. | | `services/account-browsing-simulation.ts` | Durable pacing, eligibility, post/comment weighting, selection, and optional vote dispatch | Runs below comment growth; votes require the per-account opt-in plus running mode outside dry-run, and uncertain writes are never retried. |
| `database/browsing-simulation-repository.ts` | Per-account browsing policy, cross-account selection checks, and minimal decision audit | Stores identity/permalink and reasons without copying Reddit content; simulation never feeds comment trust. | | `database/browsing-simulation-repository.ts` | Per-account browsing policy, cross-account selection checks, and minimal decision audit | Stores identity/permalink, reasons, and whether Reddit confirmed a vote without copying Reddit content; browsing never feeds comment trust. |
Verification is the safety mechanism that matters most. Posted comments are swept at roughly 15 minutes, 2 hours, and 24 hours using one batched `info.json` request, and a single signed-out probe on the second sweep decides whether the comment is publicly visible at all. Two consecutive removals, or one invisible comment, hard-pauses the account until an operator clears it; a failed probe never pauses anything. Verification is the safety mechanism that matters most. Posted comments are swept at roughly 15 minutes, 2 hours, and 24 hours using one batched `info.json` request, and a single signed-out probe on the second sweep decides whether the comment is publicly visible at all. Two consecutive removals, or one invisible comment, hard-pauses the account until an operator clears it; a failed probe never pauses anything.
+4 -1
View File
@@ -25,6 +25,7 @@ export const BrowsingSettingsSchema = z.strictObject({
minimumGapMs: z.number().int(), minimumGapMs: z.number().int(),
jitterMs: z.number().int(), jitterMs: z.number().int(),
allowNsfw: z.boolean(), allowNsfw: z.boolean(),
autoVoteEnabled: z.boolean(),
nextDueAt: z.number().nullable(), nextDueAt: z.number().nullable(),
updatedAt: z.number(), updatedAt: z.number(),
}); });
@@ -46,6 +47,7 @@ export const BrowsingDecisionSchema = z.strictObject({
permalink: z.string(), permalink: z.string(),
decision: z.enum(['simulated-upvote', 'skipped', 'rejected']), decision: z.enum(['simulated-upvote', 'skipped', 'rejected']),
reason: z.string(), reason: z.string(),
voteSubmitted: z.boolean(),
decidedAt: z.number(), decidedAt: z.number(),
}); });
@@ -53,7 +55,7 @@ export const BrowsingSummarySchema = z.strictObject({
settings: BrowsingSettingsSchema, settings: BrowsingSettingsSchema,
todayTarget: z.number().int(), todayTarget: z.number().int(),
stats: BrowsingStatsSchema, stats: BrowsingStatsSchema,
noVotesSubmitted: z.literal(true), noVotesSubmitted: z.boolean(),
}); });
export const BrowsingDetailSchema = BrowsingSummarySchema.extend({ export const BrowsingDetailSchema = BrowsingSummarySchema.extend({
@@ -161,6 +163,7 @@ export const BrowsingSettingsUpdateRequestSchema = z.strictObject({
minimumGapMs: z.number().int().min(45 * 60_000).max(24 * 60 * 60_000).optional(), minimumGapMs: z.number().int().min(45 * 60_000).max(24 * 60 * 60_000).optional(),
jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(), jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
allowNsfw: z.boolean().optional(), allowNsfw: z.boolean().optional(),
autoVoteEnabled: z.boolean().optional(),
}); });
export const GrowthPauseRequestSchema = z.strictObject({ export const GrowthPauseRequestSchema = z.strictObject({
+20 -5
View File
@@ -15,6 +15,7 @@ export interface AccountBrowsingSettingsRecord {
minimumGapMs: number; minimumGapMs: number;
jitterMs: number; jitterMs: number;
allowNsfw: boolean; allowNsfw: boolean;
autoVoteEnabled: boolean;
nextDueAt: number | null; nextDueAt: number | null;
updatedAt: number; updatedAt: number;
} }
@@ -29,6 +30,7 @@ export interface AccountBrowsingSettingsPatch {
minimumGapMs?: number; minimumGapMs?: number;
jitterMs?: number; jitterMs?: number;
allowNsfw?: boolean; allowNsfw?: boolean;
autoVoteEnabled?: boolean;
} }
export interface BrowsingDecisionRecord { export interface BrowsingDecisionRecord {
@@ -44,6 +46,7 @@ export interface BrowsingDecisionRecord {
discoveredAt: number; discoveredAt: number;
decidedAt: number; decidedAt: number;
scheduledSessionId: string; scheduledSessionId: string;
voteSubmitted: boolean;
} }
export interface BrowsingDecisionCounts { export interface BrowsingDecisionCounts {
@@ -74,6 +77,7 @@ export class AccountBrowsingSimulationRepository {
minimumGapMs: 45 * 60_000, minimumGapMs: 45 * 60_000,
jitterMs: 15 * 60_000, jitterMs: 15 * 60_000,
allowNsfw: false, allowNsfw: false,
autoVoteEnabled: false,
nextDueAt: null, nextDueAt: null,
updatedAt: now, updatedAt: now,
}; };
@@ -95,9 +99,9 @@ export class AccountBrowsingSimulationRepository {
this.db.prepare(` this.db.prepare(`
INSERT INTO account_browsing_settings ( INSERT INTO account_browsing_settings (
account_id, enabled, daily_target_min, daily_target_max, post_weight, account_id, enabled, daily_target_min, daily_target_max, post_weight,
comment_weight, quiet_hours_json, minimum_gap_ms, jitter_ms, allow_nsfw, comment_weight, quiet_hours_json, minimum_gap_ms, jitter_ms, allow_nsfw, auto_vote_enabled,
next_due_at, updated_at next_due_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET ON CONFLICT(account_id) DO UPDATE SET
enabled = excluded.enabled, enabled = excluded.enabled,
daily_target_min = excluded.daily_target_min, daily_target_min = excluded.daily_target_min,
@@ -108,11 +112,12 @@ export class AccountBrowsingSimulationRepository {
minimum_gap_ms = excluded.minimum_gap_ms, minimum_gap_ms = excluded.minimum_gap_ms,
jitter_ms = excluded.jitter_ms, jitter_ms = excluded.jitter_ms,
allow_nsfw = excluded.allow_nsfw, allow_nsfw = excluded.allow_nsfw,
auto_vote_enabled = excluded.auto_vote_enabled,
updated_at = excluded.updated_at updated_at = excluded.updated_at
`).run( `).run(
accountId, merged.enabled ? 1 : 0, merged.dailyTargetMin, merged.dailyTargetMax, accountId, merged.enabled ? 1 : 0, merged.dailyTargetMin, merged.dailyTargetMax,
merged.postWeight, merged.commentWeight, JSON.stringify(merged.quietHours), merged.postWeight, merged.commentWeight, JSON.stringify(merged.quietHours),
merged.minimumGapMs, merged.jitterMs, merged.allowNsfw ? 1 : 0, merged.minimumGapMs, merged.jitterMs, merged.allowNsfw ? 1 : 0, merged.autoVoteEnabled ? 1 : 0,
merged.nextDueAt, now, merged.nextDueAt, now,
); );
return this.getSettings(accountId, now); return this.getSettings(accountId, now);
@@ -131,12 +136,13 @@ export class AccountBrowsingSimulationRepository {
return this.db.prepare(` return this.db.prepare(`
INSERT OR IGNORE INTO account_browsing_decisions ( INSERT OR IGNORE INTO account_browsing_decisions (
account_id, thing_fullname, thing_kind, post_id, subreddit, author, account_id, thing_fullname, thing_kind, post_id, subreddit, author,
permalink, decision, reason, discovered_at, decided_at, scheduled_session_id permalink, decision, reason, discovered_at, decided_at, scheduled_session_id, vote_submitted
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
decision.accountId, decision.thingFullname, decision.thingKind, decision.postId, decision.accountId, decision.thingFullname, decision.thingKind, decision.postId,
decision.subreddit, decision.author, decision.permalink, decision.decision, decision.subreddit, decision.author, decision.permalink, decision.decision,
decision.reason, decision.discoveredAt, decision.decidedAt, decision.scheduledSessionId, decision.reason, decision.discoveredAt, decision.decidedAt, decision.scheduledSessionId,
decision.voteSubmitted ? 1 : 0,
).changes === 1; ).changes === 1;
} }
@@ -146,6 +152,13 @@ export class AccountBrowsingSimulationRepository {
} }
} }
markVoteSubmitted(accountId: string, thingFullname: string): boolean {
return this.db.prepare(`
UPDATE account_browsing_decisions SET vote_submitted = 1
WHERE account_id = ? AND thing_fullname = ? AND vote_submitted = 0
`).run(accountId, thingFullname).changes === 1;
}
hasAccountDecision(accountId: string, thingFullname: string): boolean { hasAccountDecision(accountId: string, thingFullname: string): boolean {
return this.db.prepare(` return this.db.prepare(`
SELECT 1 FROM account_browsing_decisions WHERE account_id = ? AND thing_fullname = ? SELECT 1 FROM account_browsing_decisions WHERE account_id = ? AND thing_fullname = ?
@@ -271,6 +284,7 @@ function mapSettings(row: Record<string, unknown>): AccountBrowsingSettingsRecor
minimumGapMs: row.minimum_gap_ms as number, minimumGapMs: row.minimum_gap_ms as number,
jitterMs: row.jitter_ms as number, jitterMs: row.jitter_ms as number,
allowNsfw: row.allow_nsfw === 1, allowNsfw: row.allow_nsfw === 1,
autoVoteEnabled: row.auto_vote_enabled === 1,
nextDueAt: (row.next_due_at as number | null) ?? null, nextDueAt: (row.next_due_at as number | null) ?? null,
updatedAt: row.updated_at as number, updatedAt: row.updated_at as number,
}; };
@@ -290,5 +304,6 @@ function mapDecision(row: Record<string, unknown>): BrowsingDecisionRecord {
discoveredAt: row.discovered_at as number, discoveredAt: row.discovered_at as number,
decidedAt: row.decided_at as number, decidedAt: row.decided_at as number,
scheduledSessionId: row.scheduled_session_id as string, scheduledSessionId: row.scheduled_session_id as string,
voteSubmitted: row.vote_submitted === 1,
}; };
} }
+4
View File
@@ -608,6 +608,10 @@ export function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_account_browsing_decisions_day CREATE INDEX IF NOT EXISTS idx_account_browsing_decisions_day
ON account_browsing_decisions(account_id, decision, decided_at); ON account_browsing_decisions(account_id, decision, decided_at);
`), `),
() => {
addColumnIfNotExists(db, 'account_browsing_settings', 'auto_vote_enabled', 'INTEGER NOT NULL DEFAULT 0 CHECK(auto_vote_enabled IN (0, 1))');
addColumnIfNotExists(db, 'account_browsing_decisions', 'vote_submitted', 'INTEGER NOT NULL DEFAULT 0 CHECK(vote_submitted IN (0, 1))');
},
]; ];
let currentVersion = db.pragma('user_version', { simple: true }) as number; let currentVersion = db.pragma('user_version', { simple: true }) as number;
+52
View File
@@ -31,6 +31,14 @@ export type CommentPostResult =
/** Anything else: transient, safe to retry later. */ /** Anything else: transient, safe to retry later. */
| { status: 'failed'; reason: string }; | { status: 'failed'; reason: string };
/** Result of a single upvote. A failed/uncertain request is never retried. */
export type VoteResult =
| { status: 'submitted' }
| { status: 'rate-limited'; retryAfterMs: number; reason: string }
| { status: 'session-invalid'; reason: string }
| { status: 'blocked'; reason: string }
| { status: 'failed'; reason: string };
/** Why a modhash lookup failed, so the caller can pick a proportionate response. */ /** Why a modhash lookup failed, so the caller can pick a proportionate response. */
type ModhashResult = type ModhashResult =
| { ok: true; modhash: string } | { ok: true; modhash: string }
@@ -206,6 +214,50 @@ export class RedditCommentClient {
return { status: 'posted', fullname: created.fullname, permalink: created.permalink }; return { status: 'posted', fullname: created.fullname, permalink: created.permalink };
} }
/** Submit one upvote through the same authenticated browser session as comments. */
async upvote(thingFullname: string): Promise<VoteResult> {
if (!/^t[13]_[a-z0-9]+$/i.test(thingFullname)) {
return { status: 'failed', reason: `"${thingFullname}" is not a post or comment fullname` };
}
const modhash = await this.ensureModhash();
if (!modhash.ok) return modhash.kind === 'signed-out'
? { status: 'session-invalid', reason: modhash.reason }
: { status: 'failed', reason: modhash.reason };
let response: HttpTextResponse;
try {
response = await this.scheduler.schedule(() => this.transport.postForm(
`${REDDIT_WRITE_ORIGIN}/api/vote`,
{ id: thingFullname, dir: '1', api_type: 'json', uh: modhash.modhash },
{ 'X-Modhash': modhash.modhash, 'Content-Type': 'application/x-www-form-urlencoded' },
));
} catch (error) {
return { status: 'failed', reason: errorMessage(error) };
}
if (response.status === 429) {
const retryAfterMs = 10 * 60_000;
this.scheduler.extendCooldown(retryAfterMs);
return { status: 'rate-limited', retryAfterMs, reason: 'Reddit returned HTTP 429 for the vote' };
}
if (response.status === 403) {
if (!looksLikeJson(response.body)) return { status: 'blocked', reason: blockPageReason(response) };
this.modhash = null;
return { status: 'session-invalid', reason: 'Reddit rejected the vote with HTTP 403' };
}
if (response.status >= 500) return { status: 'failed', reason: `Reddit returned HTTP ${response.status}` };
const payload = parseJson(response.body);
if (!payload) return { status: 'failed', reason: `Reddit returned an unreadable vote response (HTTP ${response.status})` };
const errors = readErrors(payload);
if (errors.length > 0) {
const classified = this.classifyError(errors[0], 'the selected community');
if (classified.status === 'rate-limited') return { status: 'rate-limited', retryAfterMs: classified.retryAfterMs, reason: classified.reason };
if (classified.status === 'session-invalid' || classified.status === 'blocked') return classified;
return { status: 'failed', reason: 'reason' in classified ? classified.reason : 'Reddit rejected the vote' };
}
this.logger.info(`Submitted Reddit upvote for ${thingFullname}`);
return { status: 'submitted' };
}
/** /**
* Current score and removal state for previously posted comments, from the * Current score and removal state for previously posted comments, from the
* account's own authenticated view. Batched because Reddit accepts up to 100 * account's own authenticated view. Batched because Reddit accepts up to 100
+33 -7
View File
@@ -17,6 +17,10 @@ export interface BrowsingDecisionSink {
recordSimulation(decision: BrowsingDecisionRecord): Promise<void>; recordSimulation(decision: BrowsingDecisionRecord): Promise<void>;
} }
export interface BrowsingVoter {
upvote(thingFullname: string): Promise<{ status: 'submitted' | 'rate-limited' | 'session-invalid' | 'blocked' | 'failed'; reason?: string }>;
}
export interface AccountBrowsingSimulationServiceOptions { export interface AccountBrowsingSimulationServiceOptions {
accountId: string; accountId: string;
repository: AccountBrowsingSimulationRepository; repository: AccountBrowsingSimulationRepository;
@@ -26,6 +30,7 @@ export interface AccountBrowsingSimulationServiceOptions {
getBrowsingComments(subreddit: string, postId: string, limit: number): Promise<BrowsingThing[]>; getBrowsingComments(subreddit: string, postId: string, limit: number): Promise<BrowsingThing[]>;
}; };
sink: BrowsingDecisionSink; sink: BrowsingDecisionSink;
voter?: BrowsingVoter;
logger: Logger; logger: Logger;
now?: () => number; now?: () => number;
random?: () => number; random?: () => number;
@@ -50,19 +55,27 @@ export class AccountBrowsingSimulationService {
this.random = options.random ?? Math.random; this.random = options.random ?? Math.random;
} }
async tick(signal?: AbortSignal): Promise<BrowsingSimulationOutcome> { /** Runs one operator-requested, simulation-only selection without waiting for the schedule. */
runNow(signal?: AbortSignal): Promise<BrowsingSimulationOutcome> {
return this.tick({ force: true }, signal);
}
async tick(
{ allowVoting = false, force = false }: { allowVoting?: boolean; force?: boolean } = {},
signal?: AbortSignal,
): Promise<BrowsingSimulationOutcome> {
if (signal?.aborted) throw new DOMException('Operation aborted', 'AbortError'); if (signal?.aborted) throw new DOMException('Operation aborted', 'AbortError');
const now = this.now(); const now = this.now();
const settings = this.options.repository.getSettings(this.options.accountId, now); const settings = this.options.repository.getSettings(this.options.accountId, now);
if (!settings.enabled) return { kind: 'idle', reason: 'Browsing simulation is disabled' }; if (!settings.enabled) return { kind: 'idle', reason: 'Browsing simulation is disabled' };
if (settings.nextDueAt === null) { if (settings.nextDueAt === null && !force) {
const nextDueAt = this.scheduleNext(settings, now); const nextDueAt = this.scheduleNext(settings, now);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now); this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
return { kind: 'scheduled', nextDueAt }; return { kind: 'scheduled', nextDueAt };
} }
if (now < settings.nextDueAt) return { kind: 'idle', reason: 'Browsing simulation is not due' }; if (!force && now < settings.nextDueAt) return { kind: 'idle', reason: 'Browsing simulation is not due' };
if (isQuietTime(new Date(now), settings)) { if (!force && isQuietTime(new Date(now), settings)) {
const nextDueAt = moveOutsideQuietHours(now, settings); const nextDueAt = moveOutsideQuietHours(now, settings);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now); this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
return { kind: 'scheduled', nextDueAt }; return { kind: 'scheduled', nextDueAt };
@@ -70,7 +83,7 @@ export class AccountBrowsingSimulationService {
const today = startOfLocalDay(now); const today = startOfLocalDay(now);
const target = dailyTarget(this.options.accountId, today, settings); const target = dailyTarget(this.options.accountId, today, settings);
if (this.options.repository.countSelectedSince(this.options.accountId, today) >= target) { if (!force && this.options.repository.countSelectedSince(this.options.accountId, today) >= target) {
const nextDueAt = moveOutsideQuietHours(today + 24 * 60 * 60_000, settings); const nextDueAt = moveOutsideQuietHours(today + 24 * 60 * 60_000, settings);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now); this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
return { kind: 'scheduled', nextDueAt }; return { kind: 'scheduled', nextDueAt };
@@ -103,10 +116,22 @@ export class AccountBrowsingSimulationService {
if (candidates.length === 0) return { kind: 'scheduled', nextDueAt }; if (candidates.length === 0) return { kind: 'scheduled', nextDueAt };
const selected = candidates[Math.floor(this.random() * candidates.length)]; const selected = candidates[Math.floor(this.random() * candidates.length)];
const decision = toDecision(this.options.accountId, selected, 'simulated-upvote', 'Eligible simulated selection', sessionId, now); const decision = toDecision(this.options.accountId, selected, 'simulated-upvote', 'Eligible browsing selection', sessionId, now);
await this.options.sink.recordSimulation(decision); await this.options.sink.recordSimulation(decision);
if (settings.autoVoteEnabled && allowVoting && this.options.voter) {
const result = await this.options.voter.upvote(selected.fullname);
if (result.status === 'submitted') {
this.options.repository.markVoteSubmitted(this.options.accountId, selected.fullname);
decision.voteSubmitted = true;
decision.reason = 'Reddit upvote submitted';
this.options.logger.info(`Submitted browsing upvote for ${selected.fullname} in r/${selected.subreddit}`);
} else {
decision.reason = `Reddit upvote not submitted: ${result.reason ?? result.status}`;
this.options.logger.warn(`Browsing upvote for ${selected.fullname} was not submitted: ${result.reason ?? result.status}`);
}
}
this.options.onActivity?.(decision); this.options.onActivity?.(decision);
this.options.logger.info(`Simulated browsing selection for ${selected.fullname} in r/${selected.subreddit}; no Reddit vote was submitted`); if (!decision.voteSubmitted) this.options.logger.info(`Simulated browsing selection for ${selected.fullname} in r/${selected.subreddit}; no Reddit vote was submitted`);
return { kind: 'simulated', decision, nextDueAt }; return { kind: 'simulated', decision, nextDueAt };
} }
@@ -278,5 +303,6 @@ function toDecision(
discoveredAt: now, discoveredAt: now,
decidedAt: now, decidedAt: now,
scheduledSessionId: sessionId, scheduledSessionId: sessionId,
voteSubmitted: false,
}; };
} }
@@ -298,11 +298,14 @@ async function buildRuntime(
redditApi.getBrowsingComments(subreddit, postId, limit), redditApi.getBrowsingComments(subreddit, postId, limit),
}, },
sink: browsingRepository, sink: browsingRepository,
voter: commentClient,
logger, logger,
onActivity: (decision) => activityLogRepo.insert({ onActivity: (decision) => activityLogRepo.insert({
timestamp: decision.decidedAt, timestamp: decision.decidedAt,
type: 'response', type: 'response',
message: `Simulated ${decision.thingKind} upvote in r/${decision.subreddit}; no Reddit vote was submitted`, message: decision.voteSubmitted
? `Submitted ${decision.thingKind} upvote in r/${decision.subreddit}`
: `Simulated ${decision.thingKind} upvote in r/${decision.subreddit}; no Reddit vote was submitted`,
postUrl: `https://www.reddit.com${decision.permalink}`, postUrl: `https://www.reddit.com${decision.permalink}`,
postTitle: `r/${decision.subreddit}`, postTitle: `r/${decision.subreddit}`,
}), }),
@@ -502,12 +505,14 @@ export class AccountRuntimeLoop {
this.logger.error('Account growth tick failed:', error); this.logger.error('Account growth tick failed:', error);
} }
} }
// Browsing simulation is strictly below comment growth. It records a // Browsing selection is strictly below comment growth. It runs in scan
// hypothetical decision only and runs in both scan and running modes. // and running modes, but submits votes only while fully running and not dry-run.
if (this.resources.browsingService && Date.now() >= nextBrowsingCheckAt) { if (this.resources.browsingService && Date.now() >= nextBrowsingCheckAt) {
nextBrowsingCheckAt = Date.now() + growthInterval; nextBrowsingCheckAt = Date.now() + growthInterval;
try { try {
await this.resources.browsingService.tick(signal); await this.resources.browsingService.tick(
{ allowVoting: allowsSending(mode) && !this.config.browser.dryRun }, signal,
);
} catch (error) { } catch (error) {
if (signal.aborted) return; if (signal.aborted) return;
this.logger.error('Browsing simulation tick failed:', error); this.logger.error('Browsing simulation tick failed:', error);
+5 -1
View File
@@ -181,11 +181,15 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
requestSchema: GrowthSettingsUpdateRequestSchema, requestSchema: GrowthSettingsUpdateRequestSchema,
responseSchema: GrowthAccountResponseSchema, responseSchema: GrowthAccountResponseSchema,
}), }),
operation('updateBrowsingSimulation', 'PATCH', '/api/growth/{accountId}/browsing', 'Configure simulation-only browsing selections for an account', ['Growth'], { operation('updateBrowsingSimulation', 'PATCH', '/api/growth/{accountId}/browsing', 'Configure per-account browsing selection and automatic upvotes', ['Growth'], {
access: 'admin', access: 'admin',
requestSchema: BrowsingSettingsUpdateRequestSchema, requestSchema: BrowsingSettingsUpdateRequestSchema,
responseSchema: GrowthAccountResponseSchema, responseSchema: GrowthAccountResponseSchema,
}), }),
operation('runBrowsingSimulation', 'POST', '/api/growth/{accountId}/browsing/run', 'Run one simulation-only browsing session immediately', ['Growth'], {
access: 'admin',
responseSchema: GrowthAccountResponseSchema,
}),
operation('pauseGrowth', 'POST', '/api/growth/{accountId}/pause', 'Stop an account from posting further comments', ['Growth'], { operation('pauseGrowth', 'POST', '/api/growth/{accountId}/pause', 'Stop an account from posting further comments', ['Growth'], {
access: 'control', access: 'control',
requestSchema: GrowthPauseRequestSchema, requestSchema: GrowthPauseRequestSchema,
+4 -2
View File
@@ -28,12 +28,14 @@ export const BrowsingSettingsUpdateRequestSchema = z.strictObject({
minimumGapMs: z.number().int().min(45 * 60_000).max(DAY_MS).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(), jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
allowNsfw: z.boolean().optional(), allowNsfw: z.boolean().optional(),
autoVoteEnabled: z.boolean().optional(),
}); });
export const BrowsingSettingsSchema = z.strictObject({ export const BrowsingSettingsSchema = z.strictObject({
enabled: z.boolean(), dailyTargetMin: z.number().int(), dailyTargetMax: z.number().int(), enabled: z.boolean(), dailyTargetMin: z.number().int(), dailyTargetMax: z.number().int(),
postWeight: z.number(), commentWeight: z.number(), quietHours: GrowthQuietHoursSchema, postWeight: z.number(), commentWeight: z.number(), quietHours: GrowthQuietHoursSchema,
minimumGapMs: z.number().int(), jitterMs: z.number().int(), allowNsfw: z.boolean(), minimumGapMs: z.number().int(), jitterMs: z.number().int(), allowNsfw: z.boolean(),
autoVoteEnabled: z.boolean(),
nextDueAt: z.number().nullable(), updatedAt: z.number(), nextDueAt: z.number().nullable(), updatedAt: z.number(),
}); });
@@ -45,12 +47,12 @@ export const BrowsingStatsSchema = z.strictObject({
export const BrowsingDecisionSchema = z.strictObject({ export const BrowsingDecisionSchema = z.strictObject({
thingFullname: z.string(), thingKind: z.enum(['post', 'comment']), postId: z.string(), thingFullname: z.string(), thingKind: z.enum(['post', 'comment']), postId: z.string(),
subreddit: z.string(), author: z.string(), permalink: z.string(), subreddit: z.string(), author: z.string(), permalink: z.string(),
decision: z.enum(['simulated-upvote', 'skipped', 'rejected']), reason: z.string(), decidedAt: z.number(), decision: z.enum(['simulated-upvote', 'skipped', 'rejected']), reason: z.string(), voteSubmitted: z.boolean(), decidedAt: z.number(),
}); });
export const BrowsingSummarySchema = z.strictObject({ export const BrowsingSummarySchema = z.strictObject({
settings: BrowsingSettingsSchema, todayTarget: z.number().int(), stats: BrowsingStatsSchema, settings: BrowsingSettingsSchema, todayTarget: z.number().int(), stats: BrowsingStatsSchema,
noVotesSubmitted: z.literal(true), noVotesSubmitted: z.boolean(),
}); });
export const BrowsingDetailSchema = BrowsingSummarySchema.extend({ export const BrowsingDetailSchema = BrowsingSummarySchema.extend({
+25 -1
View File
@@ -89,6 +89,28 @@ export function createGrowthRouter(ctx: WebContext): Router {
return res.json({ success: true, account: detail(repo, browsingRepo, accountId, 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 simulation run manually for u/${username}; no Reddit vote was submitted`);
return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
});
router.patch('/growth/:accountId', (req, res) => { router.patch('/growth/:accountId', (req, res) => {
const repo = growth(); const repo = growth();
const browsingRepo = browsing(); const browsingRepo = browsing();
@@ -294,12 +316,13 @@ function presentBrowsing(
minimumGapMs: settings.minimumGapMs, minimumGapMs: settings.minimumGapMs,
jitterMs: settings.jitterMs, jitterMs: settings.jitterMs,
allowNsfw: settings.allowNsfw, allowNsfw: settings.allowNsfw,
autoVoteEnabled: settings.autoVoteEnabled,
nextDueAt: settings.nextDueAt, nextDueAt: settings.nextDueAt,
updatedAt: settings.updatedAt, updatedAt: settings.updatedAt,
}, },
todayTarget: dailyTarget(accountId, day.getTime(), settings), todayTarget: dailyTarget(accountId, day.getTime(), settings),
stats: repo.getCountsSince(accountId, day.getTime()), stats: repo.getCountsSince(accountId, day.getTime()),
noVotesSubmitted: true as const, noVotesSubmitted: !settings.autoVoteEnabled,
...(includeRecent ? { ...(includeRecent ? {
recentDecisions: repo.listRecentSelections(accountId, 50).map((decision) => ({ recentDecisions: repo.listRecentSelections(accountId, 50).map((decision) => ({
thingFullname: decision.thingFullname, thingFullname: decision.thingFullname,
@@ -310,6 +333,7 @@ function presentBrowsing(
permalink: decision.permalink, permalink: decision.permalink,
decision: decision.decision, decision: decision.decision,
reason: decision.reason, reason: decision.reason,
voteSubmitted: decision.voteSubmitted,
decidedAt: decision.decidedAt, decidedAt: decision.decidedAt,
})), })),
} : {}), } : {}),
+43 -4
View File
@@ -79,6 +79,7 @@ describe('AccountBrowsingSimulationService', () => {
posts?: RedditPost[]; posts?: RedditPost[];
comments?: BrowsingThing[]; comments?: BrowsingThing[];
random?: number; random?: number;
voter?: { upvote: ReturnType<typeof vi.fn> };
} = {}) { } = {}) {
const getHotPosts = vi.fn().mockResolvedValue(options.posts ?? [post('one')]); const getHotPosts = vi.fn().mockResolvedValue(options.posts ?? [post('one')]);
const getBrowsingComments = vi.fn().mockResolvedValue(options.comments ?? []); const getBrowsingComments = vi.fn().mockResolvedValue(options.comments ?? []);
@@ -88,6 +89,7 @@ describe('AccountBrowsingSimulationService', () => {
growthSubreddits: () => [{ subreddit: 'AskUK', weight: 1 }] as never, growthSubreddits: () => [{ subreddit: 'AskUK', weight: 1 }] as never,
reddit: { getHotPosts, getBrowsingComments }, reddit: { getHotPosts, getBrowsingComments },
sink: repository, sink: repository,
voter: options.voter,
logger: createMockLogger(), logger: createMockLogger(),
now: () => NOW, now: () => NOW,
random: () => options.random ?? 0, random: () => options.random ?? 0,
@@ -141,6 +143,18 @@ describe('AccountBrowsingSimulationService', () => {
]); ]);
}); });
it('runs a requested simulation immediately without submitting a vote', async () => {
enable({ postWeight: 100, commentWeight: 0 });
const fixture = service();
const result = await fixture.instance.runNow();
expect(result).toMatchObject({
kind: 'simulated', decision: { thingFullname: 't3_one', voteSubmitted: false },
});
expect(fixture.getHotPosts).toHaveBeenCalledOnce();
});
it('honours comment weighting when eligible comments exist', async () => { it('honours comment weighting when eligible comments exist', async () => {
enable({ postWeight: 0, commentWeight: 100 }); enable({ postWeight: 0, commentWeight: 100 });
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1); repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
@@ -152,12 +166,37 @@ describe('AccountBrowsingSimulationService', () => {
}); });
}); });
it('submits the selected upvote only when the automatic action is enabled and allowed', async () => {
enable({ postWeight: 100, commentWeight: 0, autoVoteEnabled: true });
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
const voter = { upvote: vi.fn().mockResolvedValue({ status: 'submitted' }) };
const result = await service({ voter }).instance.tick({ allowVoting: true });
expect(voter.upvote).toHaveBeenCalledWith('t3_one');
expect(result).toMatchObject({ kind: 'simulated', decision: { voteSubmitted: true, reason: 'Reddit upvote submitted' } });
expect(repository.listRecentSelections(ACCOUNT)).toEqual([
expect.objectContaining({ thingFullname: 't3_one', voteSubmitted: true }),
]);
});
it('keeps automatic voting disabled during a simulation-only pass', async () => {
enable({ postWeight: 100, commentWeight: 0, autoVoteEnabled: true });
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
const voter = { upvote: vi.fn().mockResolvedValue({ status: 'submitted' }) };
const result = await service({ voter }).instance.tick();
expect(voter.upvote).not.toHaveBeenCalled();
expect(result).toMatchObject({ kind: 'simulated', decision: { voteSubmitted: false } });
});
it('rejects owned authors, outreach recipients, NSFW and cross-account duplicates', async () => { it('rejects owned authors, outreach recipients, NSFW and cross-account duplicates', async () => {
db.prepare(`INSERT INTO recipient_suppressions (recipient, reason, created_at) VALUES ('contacted', 'test', 0)`).run(); db.prepare(`INSERT INTO recipient_suppressions (recipient, reason, created_at) VALUES ('contacted', 'test', 0)`).run();
const otherSelection: BrowsingDecisionRecord = { const otherSelection: BrowsingDecisionRecord = {
accountId: OTHER, thingFullname: 't3_taken', thingKind: 'post', postId: 'taken', accountId: OTHER, thingFullname: 't3_taken', thingKind: 'post', postId: 'taken',
subreddit: 'AskUK', author: 'someone', permalink: '/taken', decision: 'simulated-upvote', subreddit: 'AskUK', author: 'someone', permalink: '/taken', decision: 'simulated-upvote',
reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'other', reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'other', voteSubmitted: false,
}; };
repository.recordDecision(otherSelection); repository.recordDecision(otherSelection);
enable({ postWeight: 100, commentWeight: 0, allowNsfw: false }); enable({ postWeight: 100, commentWeight: 0, allowNsfw: false });
@@ -187,7 +226,7 @@ describe('AccountBrowsingSimulationService', () => {
const first: BrowsingDecisionRecord = { const first: BrowsingDecisionRecord = {
accountId: ACCOUNT, thingFullname: 't3_same', thingKind: 'post', postId: 'same', accountId: ACCOUNT, thingFullname: 't3_same', thingKind: 'post', postId: 'same',
subreddit: 'AskUK', author: 'author', permalink: '/same', decision: 'simulated-upvote', subreddit: 'AskUK', author: 'author', permalink: '/same', decision: 'simulated-upvote',
reason: 'test', discoveredAt: NOW, decidedAt: NOW, scheduledSessionId: 'first', reason: 'test', discoveredAt: NOW, decidedAt: NOW, scheduledSessionId: 'first', voteSubmitted: false,
}; };
expect(repository.recordDecision(first)).toBe(true); expect(repository.recordDecision(first)).toBe(true);
expect(repository.recordDecision({ ...first, accountId: OTHER, scheduledSessionId: 'second' })) expect(repository.recordDecision({ ...first, accountId: OTHER, scheduledSessionId: 'second' }))
@@ -199,7 +238,7 @@ describe('AccountBrowsingSimulationService', () => {
repository.recordDecision({ repository.recordDecision({
accountId: ACCOUNT, thingFullname: 't3_done', thingKind: 'post', postId: 'done', accountId: ACCOUNT, thingFullname: 't3_done', thingKind: 'post', postId: 'done',
subreddit: 'AskUK', author: 'author', permalink: '/done', decision: 'simulated-upvote', subreddit: 'AskUK', author: 'author', permalink: '/done', decision: 'simulated-upvote',
reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'done', reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'done', voteSubmitted: false,
}); });
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1); repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
const fixture = service(); const fixture = service();
@@ -233,7 +272,7 @@ describe('browsing migration', () => {
runMigrations(db); runMigrations(db);
const version = db.pragma('user_version', { simple: true }) as number; const version = db.pragma('user_version', { simple: true }) as number;
db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;'); db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;');
db.pragma(`user_version = ${version - 1}`); db.pragma(`user_version = ${version - 2}`);
runMigrations(db); runMigrations(db);
insertAccount(db, ACCOUNT, 'owner'); insertAccount(db, ACCOUNT, 'owner');
+41 -5
View File
@@ -55,6 +55,7 @@ function browsing(includeRecent = false) {
minimumGapMs: 2_700_000, minimumGapMs: 2_700_000,
jitterMs: 900_000, jitterMs: 900_000,
allowNsfw: false, allowNsfw: false,
autoVoteEnabled: false,
nextDueAt: null, nextDueAt: null,
updatedAt: 1_700_000_000_000, updatedAt: 1_700_000_000_000,
}, },
@@ -158,7 +159,19 @@ const server = setupServer(
return HttpResponse.json({ success: true, account: detailPayload }); return HttpResponse.json({ success: true, account: detailPayload });
}), }),
http.patch('/api/growth/:accountId/browsing', async ({ request }) => { http.patch('/api/growth/:accountId/browsing', async ({ request }) => {
requests.push({ method: 'PATCH', url: request.url, body: await request.json() }); const body = await request.json() as Record<string, unknown>;
requests.push({ method: 'PATCH', url: request.url, body });
const currentBrowsing = detailPayload.browsing as ReturnType<typeof browsing>;
detailPayload = detail({
browsing: {
...currentBrowsing,
settings: { ...currentBrowsing.settings, ...body },
},
});
return HttpResponse.json({ success: true, account: detailPayload });
}),
http.post('/api/growth/:accountId/browsing/run', ({ request }) => {
requests.push({ method: 'POST', url: request.url, body: null });
return HttpResponse.json({ success: true, account: detailPayload }); return HttpResponse.json({ success: true, account: detailPayload });
}), }),
http.post('/api/growth/:accountId/pause', async ({ request }) => { http.post('/api/growth/:accountId/pause', async ({ request }) => {
@@ -215,19 +228,42 @@ describe('GrowthPage', () => {
expect(screen.getByText(/posted without review/i)).toBeVisible(); expect(screen.getByText(/posted without review/i)).toBeVisible();
}); });
it('shows browsing as an explicit no-vote simulation and can enable it', async () => { it('shows browsing sessions and can enable automatic upvotes', async () => {
renderGrowth(); renderGrowth();
const user = userEvent.setup(); const user = userEvent.setup();
expect(await screen.findByRole('heading', { name: 'Browsing simulation' })).toBeVisible(); expect(await screen.findByRole('heading', { name: 'Browsing sessions' })).toBeVisible();
expect(screen.getByText('No Reddit votes were submitted.')).toBeVisible(); expect(screen.getByText('No Reddit votes will be submitted.')).toBeVisible();
expect(screen.getByText('0 / 4')).toBeVisible(); expect(screen.getByText('0 / 4')).toBeVisible();
await user.click(screen.getByLabelText('Simulate browsing selections')); await user.click(screen.getByLabelText('Run browsing sessions'));
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({ await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
method: 'PATCH', body: { enabled: true }, method: 'PATCH', body: { enabled: true },
}))); })));
await waitFor(() => expect(screen.getByRole('button', { name: 'Simulate session now' })).toBeEnabled());
expect(requests[0].url).toContain('/api/growth/%40t2_grower%3Areddit.com/browsing'); expect(requests[0].url).toContain('/api/growth/%40t2_grower%3Areddit.com/browsing');
await user.click(screen.getByLabelText('Submit selected upvotes'));
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
method: 'PATCH', body: { autoVoteEnabled: true },
})));
});
it('runs an enabled browsing simulation immediately', async () => {
const currentBrowsing = browsing(true);
detailPayload = detail({
browsing: {
...currentBrowsing,
settings: { ...currentBrowsing.settings, enabled: true },
},
});
renderGrowth();
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: 'Simulate session now' }));
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
method: 'POST', body: null,
})));
}); });
it('shows an empty state when there are no accounts', async () => { it('shows an empty state when there are no accounts', async () => {
+3 -1
View File
@@ -10,7 +10,9 @@ import type { RedditPost } from '../src/reddit/api.js';
const ACCOUNT = '@t2_grower:reddit.com'; const ACCOUNT = '@t2_grower:reddit.com';
const OTHER_ACCOUNT = '@t2_other:reddit.com'; const OTHER_ACCOUNT = '@t2_other:reddit.com';
/** Migrations that make up account growth, replayed together in the upgrade test. */ /** Migrations that make up account growth, replayed together in the upgrade test. */
const GROWTH_MIGRATION_COUNT = 3; // Browsing migrations depend on the growth tables, so a pre-growth fixture
// must rewind past both the growth sequence and the later browsing additions.
const GROWTH_MIGRATION_COUNT = 5;
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost { function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
return { return {
+5 -3
View File
@@ -68,7 +68,7 @@ describe('AccountRuntimeLoop mode gating', () => {
...(growthService ? { growthService } : {}), ...(growthService ? { growthService } : {}),
...(browsingService ? { browsingService } : {}), ...(browsingService ? { browsingService } : {}),
}; };
const config = { reddit: { pollIntervalMs: 1_000 } }; const config = { reddit: { pollIntervalMs: 1_000 }, browser: { dryRun: false } };
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
const loop = new AccountRuntimeLoop( const loop = new AccountRuntimeLoop(
resources as never, config as never, logger as never, getBotMode, { } as never, resources as never, config as never, logger as never, getBotMode, { } as never,
@@ -82,7 +82,7 @@ describe('AccountRuntimeLoop mode gating', () => {
/** Lets the loop complete at least one full pass before we assert. */ /** Lets the loop complete at least one full pass before we assert. */
async function runBriefly(loop: AccountRuntimeLoop): Promise<void> { async function runBriefly(loop: AccountRuntimeLoop): Promise<void> {
loop.start(); loop.start();
await new Promise((resolve) => setTimeout(resolve, 30)); await new Promise((resolve) => setTimeout(resolve, 60));
await loop.stop(); await loop.stop();
} }
@@ -220,7 +220,9 @@ describe('AccountRuntimeLoop mode gating', () => {
await runBriefly(fixture.loop); await runBriefly(fixture.loop);
expect(browsing.tick).toHaveBeenCalledWith(expect.anything()); expect(browsing.tick).toHaveBeenCalledWith(
{ allowVoting: mode === 'running' }, expect.anything(),
);
expect(growth.tick.mock.invocationCallOrder[0]) expect(growth.tick.mock.invocationCallOrder[0])
.toBeLessThan(browsing.tick.mock.invocationCallOrder[0]); .toBeLessThan(browsing.tick.mock.invocationCallOrder[0]);
}); });
+13
View File
@@ -353,6 +353,19 @@ describe('RedditCommentClient.postComment', () => {
}); });
}); });
describe('RedditCommentClient.upvote', () => {
it('submits the selected fullname through Reddit’s vote endpoint', async () => {
const transport = fakeTransport({ post: () => ({ status: 200, body: JSON.stringify({ json: { errors: [] } }) }) });
const client = new RedditCommentClient(transport, createMockLogger());
await expect(client.upvote('t1_comment')).resolves.toEqual({ status: 'submitted' });
expect(transport.posts[0]).toMatchObject({
url: 'https://old.reddit.com/api/vote',
form: { id: 't1_comment', dir: '1', api_type: 'json', uh: 'abc123' },
});
});
});
describe('RedditCommentClient.fetchCommentStates', () => { describe('RedditCommentClient.fetchCommentStates', () => {
const logger = createMockLogger(); const logger = createMockLogger();
+21
View File
@@ -67,6 +67,7 @@ describe('growth route', () => {
let browsingRepo: AccountBrowsingSimulationRepository; let browsingRepo: AccountBrowsingSimulationRepository;
let app: express.Express; let app: express.Express;
let pushed: Array<{ level: string; message: string }>; let pushed: Array<{ level: string; message: string }>;
let runNow: ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
db = new Database(':memory:'); db = new Database(':memory:');
@@ -81,6 +82,7 @@ describe('growth route', () => {
repo = new AccountGrowthRepository(db); repo = new AccountGrowthRepository(db);
browsingRepo = new AccountBrowsingSimulationRepository(db); browsingRepo = new AccountBrowsingSimulationRepository(db);
pushed = []; pushed = [];
runNow = vi.fn().mockResolvedValue({ kind: 'scheduled', nextDueAt: 2_000 });
const ctx = { const ctx = {
broadcaster: { push: (level: string, message: string) => pushed.push({ level, message }) }, broadcaster: { push: (level: string, message: string) => pushed.push({ level, message }) },
@@ -90,6 +92,12 @@ describe('growth route', () => {
redditAccounts: { redditAccounts: {
list: () => [{ accountId: ACCOUNT, redditUsername: 'grower' }], list: () => [{ accountId: ACCOUNT, redditUsername: 'grower' }],
}, },
accountRuntimes: {
get: () => ({
snapshot: () => ({ state: 'running' }),
resources: { browsingService: { runNow } },
}),
},
}, },
} as unknown as WebContext; } as unknown as WebContext;
@@ -281,6 +289,19 @@ describe('growth route', () => {
}); });
}); });
describe('POST /api/growth/:accountId/browsing/run', () => {
it('runs one immediate simulation through the active account runtime', async () => {
const { status, json } = await call(app, 'POST', `/api/growth/${ENCODED}/browsing/run`);
expect(status).toBe(200);
GrowthAccountResponseSchema.parse(json);
expect(runNow).toHaveBeenCalledOnce();
expect(pushed).toContainEqual({
level: 'info', message: 'Browsing simulation run manually for u/grower; no Reddit vote was submitted',
});
});
});
describe('pause and resume', () => { describe('pause and resume', () => {
it('pauses with a default reason and resumes', async () => { it('pauses with a default reason and resumes', async () => {
const paused = await call(app, 'POST', `/api/growth/${ENCODED}/pause`, {}); const paused = await call(app, 'POST', `/api/growth/${ENCODED}/pause`, {});