Add account browsing simulation

This commit is contained in:
jjbrunton committed 2026-08-11 21:40:31 +01:00
1 parent e2d7dc4b82
commit 27012399ec
31 files changed
+1476 -28

No files matched your search

@@ -5,6 +5,7 @@ import {
GrowthOverviewResponseSchema, GrowthOverviewResponseSchema,
type GrowthAccountDetail, type GrowthAccountDetail,
type GrowthAccountSummary, type GrowthAccountSummary,
type BrowsingSettingsUpdateRequest,
type GrowthSettingsUpdateRequest, type GrowthSettingsUpdateRequest,
type GrowthSubredditCreateRequest, type GrowthSubredditCreateRequest,
type GrowthSubredditUpdateRequest, type GrowthSubredditUpdateRequest,
@@ -50,6 +51,19 @@ export function updateGrowthSettings(
.then((payload) => payload.account); .then((payload) => payload.account);
} }
export function updateBrowsingSettings(
accountId: string,
update: BrowsingSettingsUpdateRequest,
): Promise<GrowthAccountDetail> {
return dashboardRequest(`${accountPath(accountId)}/browsing`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(update),
})
.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',
@@ -4,6 +4,7 @@ import type {
GrowthAccountSummary, GrowthAccountSummary,
GrowthComment, GrowthComment,
GrowthSubreddit, GrowthSubreddit,
BrowsingDecision,
} from '../../../../../packages/api-contracts/src/index.js'; } from '../../../../../packages/api-contracts/src/index.js';
import { NumberField, SwitchField, TextField } from '../../components/form-fields.js'; import { NumberField, SwitchField, TextField } from '../../components/form-fields.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js'; import { RedditUsernameLink } from '../../components/reddit-username-link.js';
@@ -15,6 +16,7 @@ import {
useRemoveGrowthSubredditMutation, useRemoveGrowthSubredditMutation,
useResumeGrowthMutation, useResumeGrowthMutation,
useUpdateGrowthSettingsMutation, useUpdateGrowthSettingsMutation,
useUpdateBrowsingSettingsMutation,
useUpdateGrowthSubredditMutation, useUpdateGrowthSubredditMutation,
} from './growth-queries.js'; } from './growth-queries.js';
import './growth.css'; import './growth.css';
@@ -170,6 +172,68 @@ function SubredditRow({
); );
} }
function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }) {
const update = useUpdateBrowsingSettingsMutation(account.accountId);
const { settings, stats, todayTarget, recentDecisions } = account.browsing;
const selected = recentDecisions.filter((decision) => decision.decision === 'simulated-upvote');
return (
<section aria-labelledby="browsing-simulation-heading" className="panel settings-card">
<div className="settings-card-heading">
<div>
<span className="eyebrow">Simulation only</span>
<h2 id="browsing-simulation-heading">Browsing simulation</h2>
<p>Tests selection and pacing without submitting votes or any other Reddit write.</p>
</div>
</div>
<div className="settings-card-body">
<div className="state-banner is-warning" role="status">
<strong>No Reddit votes were submitted.</strong>
<span>Every item below is a hypothetical upvote recorded only in RedditBot.</span>
</div>
<SwitchField
checked={settings.enabled}
description={`Selects ${settings.dailyTargetMin}–${settings.dailyTargetMax} eligible posts or comments during waking hours.`}
label="Simulate browsing selections"
onChange={(enabled) => update.mutate({ enabled })}
/>
<dl className="growth-metrics">
<div><dt>Selected today</dt><dd>{stats.selected} / {todayTarget}</dd></div>
<div><dt>Posts</dt><dd>{stats.posts}</dd></div>
<div><dt>Comments</dt><dd>{stats.comments}</dd></div>
<div><dt>Rejected</dt><dd>{stats.rejected}</dd></div>
<div><dt>Skipped</dt><dd>{stats.skipped}</dd></div>
<div>
<dt>Next session</dt>
<dd>{settings.nextDueAt === null ? 'Not scheduled' : new Date(settings.nextDueAt).toLocaleString()}</dd>
</div>
</dl>
{update.isError ? <p className="save-error" role="alert">{errorMessage(update.error)}</p> : null}
{selected.length === 0 ? (
<div className="empty-state">
<strong>No simulated selections yet</strong>
<span>The first session is durably scheduled after this feature is enabled.</span>
</div>
) : (
<ul className="growth-comment-list">
{selected.map((decision: BrowsingDecision) => (
<li className="growth-comment" key={`${decision.thingFullname}-${decision.decidedAt}`}>
<div className="growth-comment-head">
<a href={`https://www.reddit.com${decision.permalink}`} rel="noreferrer noopener" target="_blank">
r/{decision.subreddit} — {decision.thingKind} by u/{decision.author}
</a>
<span className="pill neutral">Simulated upvote</span>
</div>
<p className="field-hint">{new Date(decision.decidedAt).toLocaleString()}</p>
</li>
))}
</ul>
)}
</div>
</section>
);
}
function AccountDetail({ account }: { account: GrowthAccountDetail }) { function AccountDetail({ account }: { account: GrowthAccountDetail }) {
const { accountId, settings, stats } = account; const { accountId, settings, stats } = account;
const updateSettings = useUpdateGrowthSettingsMutation(accountId); const updateSettings = useUpdateGrowthSettingsMutation(accountId);
@@ -435,6 +499,8 @@ function AccountDetail({ account }: { account: GrowthAccountDetail }) {
</div> </div>
</section> </section>
<BrowsingSimulationSection account={account} />
<section aria-labelledby="growth-history-heading" className="panel settings-card"> <section aria-labelledby="growth-history-heading" className="panel settings-card">
<div className="settings-card-heading"> <div className="settings-card-heading">
<div> <div>
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { import type {
GrowthAccountDetail, GrowthAccountDetail,
BrowsingSettingsUpdateRequest,
GrowthSettingsUpdateRequest, GrowthSettingsUpdateRequest,
GrowthSubredditCreateRequest, GrowthSubredditCreateRequest,
GrowthSubredditUpdateRequest, GrowthSubredditUpdateRequest,
@@ -13,6 +14,7 @@ import {
removeGrowthSubreddit, removeGrowthSubreddit,
resumeGrowth, resumeGrowth,
updateGrowthSettings, updateGrowthSettings,
updateBrowsingSettings,
updateGrowthSubreddit, updateGrowthSubreddit,
} from './growth-api.js'; } from './growth-api.js';
@@ -63,6 +65,13 @@ export function useUpdateGrowthSettingsMutation(accountId: string) {
); );
} }
export function useUpdateBrowsingSettingsMutation(accountId: string) {
return useGrowthMutation(
accountId,
(update: BrowsingSettingsUpdateRequest) => updateBrowsingSettings(accountId, update),
);
}
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));
} }
+1
View File
@@ -108,6 +108,7 @@ 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 |
| `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 -1
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, and adaptive comment allowance; - growth settings, community list, comment history, adaptive comment allowance, and isolated browsing-simulation policy/audit;
- 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,6 +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.
- 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.
+2
View File
@@ -105,6 +105,8 @@ 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.
## Runtime-editable subset ## Runtime-editable subset
The dashboard/API exposes a deliberate subset: rate limits, dry-run, recency and ready-funnel limits, subreddit definitions/conversation behavior, and selected advanced configuration. Shared transport schemas live in `packages/api-contracts/src/config.ts`; the backend source schema is still the final validator. The dashboard/API exposes a deliberate subset: rate limits, dry-run, recency and ready-funnel limits, subreddit definitions/conversation behavior, and selected advanced configuration. Shared transport schemas live in `packages/api-contracts/src/config.ts`; the backend source schema is still the final validator.
+6
View File
@@ -60,6 +60,12 @@ 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
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.
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.
## Reply-first conversation automation ## Reply-first conversation automation
Explicit bot clients can send to a username through `POST /api/v1/messages` or the MCP `send_message_to_user` tool without selecting an account. The selector compares cached conversations case-insensitively and reuses the account that most recently interacted with that recipient. If no conversation exists, it chooses an authenticated running account with no invitation cooldown, preferring the lowest hourly send count. The selected account's normal rate limiter still owns the send. An API reply to an existing conversation marks automation as user-overridden so autonomous replies do not race the external caller. Explicit bot clients can send to a username through `POST /api/v1/messages` or the MCP `send_message_to_user` tool without selecting an account. The selector compares cached conversations case-insensitively and reuses the account that most recently interacted with that recipient. If no conversation exists, it chooses an authenticated running account with no invitation cooldown, preferring the lowest hourly send count. The selected account's normal rate limiter still owns the send. An API reply to an existing conversation marks automation as user-overridden so autonomous replies do not race the external caller.
+4
View File
@@ -20,6 +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_decisions`: minimal post/comment identity, permalink, decision/reason, 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.
@@ -60,6 +62,8 @@ The special legacy-unassigned account exists only to preserve rows created befor
9. A library image content hash has at most one reservation per account conversation, even if the same file is later re-uploaded under a new asset ID; an inbound request also has at most one selected asset. Failed or uncertain attempts remain reserved to prevent duplicates. 9. A library image content hash has at most one reservation per account conversation, even if the same file is later re-uploaded under a new asset ID; an inbound request also has at most one selected asset. Failed or uncertain attempts remain reserved to prevent duplicates.
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.
14. Browsing simulation rows never contribute to adaptive comment-growth promotion, and no schema or repository operation represents a submitted Reddit vote.
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.
+3 -1
View File
@@ -19,13 +19,15 @@ 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: switch, communities with daily caps, adaptive allowance, and the posted-comment audit trail. | | `/growth` | Per-account comment growth plus simulation-only browsing: switches, communities, pacing/allowance, next browsing session, 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.
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.
Prepared cards in the authenticated outreach funnel expose the exact opener, grounded image analysis, attractiveness and physique scores, current target range, detected gender, strategy/tone/model metadata, and original source image. The live range is returned through the existing preparation field so cached dashboard bundles remain compatible during a rolling deployment. The durable queue still owns the historical preparation snapshot used for reconciliation. The source image uses the shared `OriginPostCard` privacy behavior: blurred initially, first click reveals it, and a later click opens the full-screen preview. Candidates from older persisted snapshots degrade cleanly when newer optional metadata is absent. Prepared cards in the authenticated outreach funnel expose the exact opener, grounded image analysis, attractiveness and physique scores, current target range, detected gender, strategy/tone/model metadata, and original source image. The live range is returned through the existing preparation field so cached dashboard bundles remain compatible during a rolling deployment. The durable queue still owns the historical preparation snapshot used for reconciliation. The source image uses the shared `OriginPostCard` privacy behavior: blurred initially, first click reveals it, and a later click opens the full-screen preview. Candidates from older persisted snapshots degrade cleanly when newer optional metadata is absent.
+2
View File
@@ -70,6 +70,8 @@ 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.
## Operator exchange media ## Operator exchange media
The exchange library contains only operator-uploaded images. Upload requires confirmation that every depicted adult consented to storage and bot delivery. Received conversation media is account-owned evidence and can never be promoted into this shared library. The exchange library contains only operator-uploaded images. Upload requires confirmation that every depicted adult consented to storage and bot delivery. Received conversation media is account-owned evidence and can never be promoted into this shared library.
+2
View File
@@ -41,6 +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. |
| `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. |
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.
+65
View File
@@ -15,6 +15,51 @@ export const GrowthQuietHoursSchema = z.strictObject({
endHour: z.number().int().min(0).max(23), endHour: z.number().int().min(0).max(23),
}); });
export const BrowsingSettingsSchema = z.strictObject({
enabled: z.boolean(),
dailyTargetMin: z.number().int(),
dailyTargetMax: z.number().int(),
postWeight: z.number(),
commentWeight: z.number(),
quietHours: GrowthQuietHoursSchema,
minimumGapMs: z.number().int(),
jitterMs: z.number().int(),
allowNsfw: z.boolean(),
nextDueAt: z.number().nullable(),
updatedAt: z.number(),
});
export const BrowsingStatsSchema = z.strictObject({
selected: z.number().int(),
posts: z.number().int(),
comments: z.number().int(),
rejected: z.number().int(),
skipped: z.number().int(),
});
export const BrowsingDecisionSchema = z.strictObject({
thingFullname: z.string(),
thingKind: z.enum(['post', 'comment']),
postId: z.string(),
subreddit: z.string(),
author: z.string(),
permalink: z.string(),
decision: z.enum(['simulated-upvote', 'skipped', 'rejected']),
reason: z.string(),
decidedAt: z.number(),
});
export const BrowsingSummarySchema = z.strictObject({
settings: BrowsingSettingsSchema,
todayTarget: z.number().int(),
stats: BrowsingStatsSchema,
noVotesSubmitted: z.literal(true),
});
export const BrowsingDetailSchema = BrowsingSummarySchema.extend({
recentDecisions: z.array(BrowsingDecisionSchema),
});
export const GrowthSubredditSchema = z.strictObject({ export const GrowthSubredditSchema = z.strictObject({
subreddit: z.string(), subreddit: z.string(),
enabled: z.boolean(), enabled: z.boolean(),
@@ -82,11 +127,13 @@ export const GrowthAccountSummarySchema = z.strictObject({
stats: GrowthStatsSchema, stats: GrowthStatsSchema,
subredditCount: z.number().int(), subredditCount: z.number().int(),
activeSubredditCount: z.number().int(), activeSubredditCount: z.number().int(),
browsing: BrowsingSummarySchema,
}); });
export const GrowthAccountDetailSchema = GrowthAccountSummarySchema.extend({ export const GrowthAccountDetailSchema = GrowthAccountSummarySchema.extend({
subreddits: z.array(GrowthSubredditSchema), subreddits: z.array(GrowthSubredditSchema),
recentComments: z.array(GrowthCommentSchema), recentComments: z.array(GrowthCommentSchema),
browsing: BrowsingDetailSchema,
}); });
export const GrowthOverviewResponseSchema = z.strictObject({ export const GrowthOverviewResponseSchema = z.strictObject({
@@ -104,6 +151,18 @@ export const GrowthSettingsUpdateRequestSchema = z.strictObject({
allowNsfw: z.boolean().optional(), allowNsfw: z.boolean().optional(),
}); });
export const BrowsingSettingsUpdateRequestSchema = z.strictObject({
enabled: z.boolean().optional(),
dailyTargetMin: z.number().int().min(1).max(12).optional(),
dailyTargetMax: z.number().int().min(1).max(12).optional(),
postWeight: z.number().min(0).max(100).optional(),
commentWeight: z.number().min(0).max(100).optional(),
quietHours: GrowthQuietHoursSchema.optional(),
minimumGapMs: z.number().int().min(45 * 60_000).max(24 * 60 * 60_000).optional(),
jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
allowNsfw: z.boolean().optional(),
});
export const GrowthPauseRequestSchema = z.strictObject({ export const GrowthPauseRequestSchema = z.strictObject({
reason: z.string().min(1).max(200).optional(), reason: z.string().min(1).max(200).optional(),
}); });
@@ -138,3 +197,9 @@ export type GrowthSettingsUpdateRequest = z.infer<typeof GrowthSettingsUpdateReq
export type GrowthSubredditCreateRequest = z.infer<typeof GrowthSubredditCreateRequestSchema>; export type GrowthSubredditCreateRequest = z.infer<typeof GrowthSubredditCreateRequestSchema>;
export type GrowthSubredditUpdateRequest = z.infer<typeof GrowthSubredditUpdateRequestSchema>; export type GrowthSubredditUpdateRequest = z.infer<typeof GrowthSubredditUpdateRequestSchema>;
export type GrowthAccountResponse = z.infer<typeof GrowthAccountResponseSchema>; export type GrowthAccountResponse = z.infer<typeof GrowthAccountResponseSchema>;
export type BrowsingSettings = z.infer<typeof BrowsingSettingsSchema>;
export type BrowsingStats = z.infer<typeof BrowsingStatsSchema>;
export type BrowsingDecision = z.infer<typeof BrowsingDecisionSchema>;
export type BrowsingSummary = z.infer<typeof BrowsingSummarySchema>;
export type BrowsingDetail = z.infer<typeof BrowsingDetailSchema>;
export type BrowsingSettingsUpdateRequest = z.infer<typeof BrowsingSettingsUpdateRequestSchema>;
+5
View File
@@ -59,6 +59,7 @@ import { RecipientDeduplicator } from './services/recipient-deduplicator.js';
import { createConfigBackedSubredditPollingControl } from './services/subreddit-polling-control.js'; import { createConfigBackedSubredditPollingControl } from './services/subreddit-polling-control.js';
import { CandidateQueueRepository, RuntimeStateRepository } from './database/candidate-queue-repository.js'; import { CandidateQueueRepository, RuntimeStateRepository } from './database/candidate-queue-repository.js';
import { AccountGrowthRepository } from './database/growth-repository.js'; import { AccountGrowthRepository } from './database/growth-repository.js';
import { AccountBrowsingSimulationRepository } from './database/browsing-simulation-repository.js';
import { MediaLibraryRepository } from './database/media-library-repository.js'; import { MediaLibraryRepository } from './database/media-library-repository.js';
import { MediaLibraryService } from './services/media-library.js'; import { MediaLibraryService } from './services/media-library.js';
@@ -83,6 +84,7 @@ export interface AppContainer {
candidateQueueRepo: CandidateQueueRepository; candidateQueueRepo: CandidateQueueRepository;
runtimeStateRepo: RuntimeStateRepository; runtimeStateRepo: RuntimeStateRepository;
growthRepo: AccountGrowthRepository; growthRepo: AccountGrowthRepository;
browsingRepo: AccountBrowsingSimulationRepository;
redditApi: RedditApiClient; redditApi: RedditApiClient;
aiClient: OpenRouterClient; aiClient: OpenRouterClient;
@@ -151,6 +153,7 @@ export async function bootstrap(
const candidateQueueRepo = new CandidateQueueRepository(db); const candidateQueueRepo = new CandidateQueueRepository(db);
const runtimeStateRepo = new RuntimeStateRepository(db); const runtimeStateRepo = new RuntimeStateRepository(db);
const growthRepo = new AccountGrowthRepository(db); const growthRepo = new AccountGrowthRepository(db);
const browsingRepo = new AccountBrowsingSimulationRepository(db);
const redditAccountsRepo = bootstrapAccountsRepo; const redditAccountsRepo = bootstrapAccountsRepo;
const accountContentRepo = new AccountContentRepository(db); const accountContentRepo = new AccountContentRepository(db);
const aiUsageRepo = new AiUsageRepository(db); const aiUsageRepo = new AiUsageRepository(db);
@@ -473,6 +476,7 @@ export async function bootstrap(
webServer.setConversationReadStateRepository(conversationReadStateRepo); webServer.setConversationReadStateRepository(conversationReadStateRepo);
webServer.setCandidateQueueRepository(candidateQueueRepo); webServer.setCandidateQueueRepository(candidateQueueRepo);
webServer.setGrowthRepository(growthRepo); webServer.setGrowthRepository(growthRepo);
webServer.setBrowsingRepository(browsingRepo);
webServer.setConfigStore(new ConfigStore(db)); webServer.setConfigStore(new ConfigStore(db));
webServer.setPushNotifier(pushNotifier); webServer.setPushNotifier(pushNotifier);
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard')); webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
@@ -512,6 +516,7 @@ export async function bootstrap(
candidateQueueRepo, candidateQueueRepo,
runtimeStateRepo, runtimeStateRepo,
growthRepo, growthRepo,
browsingRepo,
redditApi, redditApi,
aiClient, aiClient,
monitorService, monitorService,
@@ -0,0 +1,294 @@
import type Database from 'better-sqlite3';
import type { GrowthQuietHours } from './growth-repository.js';
export type BrowsingThingKind = 'post' | 'comment';
export type BrowsingDecisionKind = 'simulated-upvote' | 'skipped' | 'rejected';
export interface AccountBrowsingSettingsRecord {
accountId: string;
enabled: boolean;
dailyTargetMin: number;
dailyTargetMax: number;
postWeight: number;
commentWeight: number;
quietHours: GrowthQuietHours;
minimumGapMs: number;
jitterMs: number;
allowNsfw: boolean;
nextDueAt: number | null;
updatedAt: number;
}
export interface AccountBrowsingSettingsPatch {
enabled?: boolean;
dailyTargetMin?: number;
dailyTargetMax?: number;
postWeight?: number;
commentWeight?: number;
quietHours?: GrowthQuietHours;
minimumGapMs?: number;
jitterMs?: number;
allowNsfw?: boolean;
}
export interface BrowsingDecisionRecord {
accountId: string;
thingFullname: string;
thingKind: BrowsingThingKind;
postId: string;
subreddit: string;
author: string;
permalink: string;
decision: BrowsingDecisionKind;
reason: string;
discoveredAt: number;
decidedAt: number;
scheduledSessionId: string;
}
export interface BrowsingDecisionCounts {
selected: number;
posts: number;
comments: number;
rejected: number;
skipped: number;
}
const DEFAULT_QUIET_HOURS: GrowthQuietHours = { enabled: true, startHour: 0, endHour: 7 };
/** Durable account-scoped policy and audit store for simulation-only browsing decisions. */
export class AccountBrowsingSimulationRepository {
constructor(private readonly db: Database.Database) {}
getSettings(accountId: string, now: number = Date.now()): AccountBrowsingSettingsRecord {
const row = this.db.prepare('SELECT * FROM account_browsing_settings WHERE account_id = ?')
.get(accountId) as Record<string, unknown> | undefined;
return row ? mapSettings(row) : {
accountId,
enabled: false,
dailyTargetMin: 3,
dailyTargetMax: 6,
postWeight: 60,
commentWeight: 40,
quietHours: { ...DEFAULT_QUIET_HOURS },
minimumGapMs: 45 * 60_000,
jitterMs: 15 * 60_000,
allowNsfw: false,
nextDueAt: null,
updatedAt: now,
};
}
saveSettings(
accountId: string,
patch: AccountBrowsingSettingsPatch,
now: number = Date.now(),
): AccountBrowsingSettingsRecord {
const current = this.getSettings(accountId, now);
const merged = {
...current,
...patch,
quietHours: patch.quietHours ?? current.quietHours,
updatedAt: now,
};
validateSettings(merged);
this.db.prepare(`
INSERT INTO account_browsing_settings (
account_id, enabled, daily_target_min, daily_target_max, post_weight,
comment_weight, quiet_hours_json, minimum_gap_ms, jitter_ms, allow_nsfw,
next_due_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
enabled = excluded.enabled,
daily_target_min = excluded.daily_target_min,
daily_target_max = excluded.daily_target_max,
post_weight = excluded.post_weight,
comment_weight = excluded.comment_weight,
quiet_hours_json = excluded.quiet_hours_json,
minimum_gap_ms = excluded.minimum_gap_ms,
jitter_ms = excluded.jitter_ms,
allow_nsfw = excluded.allow_nsfw,
updated_at = excluded.updated_at
`).run(
accountId, merged.enabled ? 1 : 0, merged.dailyTargetMin, merged.dailyTargetMax,
merged.postWeight, merged.commentWeight, JSON.stringify(merged.quietHours),
merged.minimumGapMs, merged.jitterMs, merged.allowNsfw ? 1 : 0,
merged.nextDueAt, now,
);
return this.getSettings(accountId, now);
}
setNextDueAt(accountId: string, nextDueAt: number | null, now: number = Date.now()): void {
if (!this.db.prepare('SELECT 1 FROM account_browsing_settings WHERE account_id = ?').get(accountId)) {
this.saveSettings(accountId, {}, now);
}
this.db.prepare(`
UPDATE account_browsing_settings SET next_due_at = ?, updated_at = ? WHERE account_id = ?
`).run(nextDueAt, now, accountId);
}
recordDecision(decision: BrowsingDecisionRecord): boolean {
return this.db.prepare(`
INSERT OR IGNORE INTO account_browsing_decisions (
account_id, thing_fullname, thing_kind, post_id, subreddit, author,
permalink, decision, reason, discovered_at, decided_at, scheduled_session_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
decision.accountId, decision.thingFullname, decision.thingKind, decision.postId,
decision.subreddit, decision.author, decision.permalink, decision.decision,
decision.reason, decision.discoveredAt, decision.decidedAt, decision.scheduledSessionId,
).changes === 1;
}
async recordSimulation(decision: BrowsingDecisionRecord): Promise<void> {
if (!this.recordDecision(decision)) {
throw new Error(`Browsing decision for ${decision.thingFullname} was already recorded`);
}
}
hasAccountDecision(accountId: string, thingFullname: string): boolean {
return this.db.prepare(`
SELECT 1 FROM account_browsing_decisions WHERE account_id = ? AND thing_fullname = ?
`).get(accountId, thingFullname) !== undefined;
}
wasSelectedByAnyAccount(thingFullname: string): boolean {
return this.db.prepare(`
SELECT 1 FROM account_browsing_decisions
WHERE thing_fullname = ? AND decision = 'simulated-upvote' LIMIT 1
`).get(thingFullname) !== undefined;
}
isOwnedAuthor(author: string): boolean {
return this.db.prepare(`
SELECT 1 FROM reddit_accounts WHERE reddit_username = ? COLLATE NOCASE LIMIT 1
`).get(author) !== undefined;
}
isOutreachRecipient(author: string): boolean {
return this.db.prepare(`
SELECT 1 FROM recipient_suppressions WHERE recipient = ? COLLATE NOCASE LIMIT 1
`).get(author) !== undefined;
}
isGrowthCommentPost(postId: string): boolean {
return this.db.prepare(`SELECT 1 FROM account_growth_comments WHERE post_id = ? LIMIT 1`)
.get(postId) !== undefined;
}
isPromotionExperimentPost(postId: string): boolean {
return this.db.prepare(`
SELECT 1
WHERE EXISTS (
SELECT 1 FROM sent_messages
WHERE post_id = ? AND experiment_id IS NOT NULL AND trim(experiment_id) <> ''
) OR EXISTS (
SELECT 1 FROM candidate_queue
WHERE post_id = ? AND prepared_json IS NOT NULL AND json_valid(prepared_json)
AND json_extract(prepared_json, '$.experimentId') IS NOT NULL
)
LIMIT 1
`).get(postId, postId) !== undefined;
}
countSelectedSince(accountId: string, since: number): number {
return (this.db.prepare(`
SELECT COUNT(*) AS count FROM account_browsing_decisions
WHERE account_id = ? AND decision = 'simulated-upvote' AND decided_at >= ?
`).get(accountId, since) as { count: number }).count;
}
getCountsSince(accountId: string, since: number): BrowsingDecisionCounts {
const rows = this.db.prepare(`
SELECT decision, thing_kind, COUNT(*) AS count
FROM account_browsing_decisions WHERE account_id = ? AND decided_at >= ?
GROUP BY decision, thing_kind
`).all(accountId, since) as Array<{ decision: BrowsingDecisionKind; thing_kind: BrowsingThingKind; count: number }>;
const counts: BrowsingDecisionCounts = { selected: 0, posts: 0, comments: 0, rejected: 0, skipped: 0 };
for (const row of rows) {
if (row.decision === 'simulated-upvote') {
counts.selected += row.count;
counts[row.thing_kind === 'post' ? 'posts' : 'comments'] += row.count;
} else if (row.decision === 'rejected') counts.rejected += row.count;
else counts.skipped += row.count;
}
return counts;
}
listRecent(accountId: string, limit = 20): BrowsingDecisionRecord[] {
const rows = this.db.prepare(`
SELECT * FROM account_browsing_decisions WHERE account_id = ?
ORDER BY decided_at DESC LIMIT ?
`).all(accountId, limit) as Array<Record<string, unknown>>;
return rows.map(mapDecision);
}
listRecentSelections(accountId: string, limit = 20): BrowsingDecisionRecord[] {
const rows = this.db.prepare(`
SELECT * FROM account_browsing_decisions
WHERE account_id = ? AND decision = 'simulated-upvote'
ORDER BY decided_at DESC LIMIT ?
`).all(accountId, limit) as Array<Record<string, unknown>>;
return rows.map(mapDecision);
}
}
function validateSettings(settings: AccountBrowsingSettingsRecord): void {
if (!Number.isInteger(settings.dailyTargetMin) || settings.dailyTargetMin < 1 || settings.dailyTargetMin > 12) {
throw new Error('Browsing minimum daily target must be a whole number between 1 and 12');
}
if (!Number.isInteger(settings.dailyTargetMax)
|| settings.dailyTargetMax < settings.dailyTargetMin || settings.dailyTargetMax > 12) {
throw new Error('Browsing maximum daily target must be between the minimum and 12');
}
if (!Number.isFinite(settings.postWeight) || settings.postWeight < 0
|| !Number.isFinite(settings.commentWeight) || settings.commentWeight < 0
|| settings.postWeight + settings.commentWeight <= 0) {
throw new Error('Browsing post and comment weights must be non-negative with a positive total');
}
if (!Number.isInteger(settings.minimumGapMs) || settings.minimumGapMs < 45 * 60_000) {
throw new Error('Browsing minimum gap cannot be less than 45 minutes');
}
if (!Number.isInteger(settings.jitterMs) || settings.jitterMs < 0) {
throw new Error('Browsing jitter must be a non-negative whole number');
}
const { startHour, endHour } = settings.quietHours;
if (!Number.isInteger(startHour) || startHour < 0 || startHour > 23
|| !Number.isInteger(endHour) || endHour < 0 || endHour > 23) {
throw new Error('Browsing quiet-hour boundaries must be whole hours from 0 to 23');
}
}
function mapSettings(row: Record<string, unknown>): AccountBrowsingSettingsRecord {
return {
accountId: row.account_id as string,
enabled: row.enabled === 1,
dailyTargetMin: row.daily_target_min as number,
dailyTargetMax: row.daily_target_max as number,
postWeight: row.post_weight as number,
commentWeight: row.comment_weight as number,
quietHours: JSON.parse(row.quiet_hours_json as string) as GrowthQuietHours,
minimumGapMs: row.minimum_gap_ms as number,
jitterMs: row.jitter_ms as number,
allowNsfw: row.allow_nsfw === 1,
nextDueAt: (row.next_due_at as number | null) ?? null,
updatedAt: row.updated_at as number,
};
}
function mapDecision(row: Record<string, unknown>): BrowsingDecisionRecord {
return {
accountId: row.account_id as string,
thingFullname: row.thing_fullname as string,
thingKind: row.thing_kind as BrowsingThingKind,
postId: row.post_id as string,
subreddit: row.subreddit as string,
author: row.author as string,
permalink: row.permalink as string,
decision: row.decision as BrowsingDecisionKind,
reason: row.reason as string,
discoveredAt: row.discovered_at as number,
decidedAt: row.decided_at as number,
scheduledSessionId: row.scheduled_session_id as string,
};
}
+44
View File
@@ -564,6 +564,50 @@ export function runMigrations(db: Database.Database): void {
// Null keeps the adaptive allowance; a value is an operator override. // Null keeps the adaptive allowance; a value is an operator override.
addColumnIfNotExists(db, 'account_growth_settings', 'daily_limit', 'INTEGER'); addColumnIfNotExists(db, 'account_growth_settings', 'daily_limit', 'INTEGER');
}, },
() => db.exec(`
CREATE TABLE IF NOT EXISTS account_browsing_settings (
account_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0 CHECK(enabled IN (0, 1)),
daily_target_min INTEGER NOT NULL DEFAULT 3,
daily_target_max INTEGER NOT NULL DEFAULT 6,
post_weight INTEGER NOT NULL DEFAULT 60,
comment_weight INTEGER NOT NULL DEFAULT 40,
quiet_hours_json TEXT NOT NULL DEFAULT '{"enabled":true,"startHour":0,"endHour":7}',
minimum_gap_ms INTEGER NOT NULL DEFAULT 2700000,
jitter_ms INTEGER NOT NULL DEFAULT 900000,
allow_nsfw INTEGER NOT NULL DEFAULT 0 CHECK(allow_nsfw IN (0, 1)),
next_due_at INTEGER,
updated_at INTEGER NOT NULL,
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE,
CHECK(daily_target_min BETWEEN 1 AND 12),
CHECK(daily_target_max BETWEEN daily_target_min AND 12),
CHECK(post_weight >= 0 AND comment_weight >= 0 AND post_weight + comment_weight > 0)
);
CREATE TABLE IF NOT EXISTS account_browsing_decisions (
account_id TEXT NOT NULL,
thing_fullname TEXT NOT NULL,
thing_kind TEXT NOT NULL CHECK(thing_kind IN ('post', 'comment')),
post_id TEXT NOT NULL,
subreddit TEXT NOT NULL COLLATE NOCASE,
author TEXT NOT NULL,
permalink TEXT NOT NULL,
decision TEXT NOT NULL CHECK(decision IN ('simulated-upvote', 'skipped', 'rejected')),
reason TEXT NOT NULL,
discovered_at INTEGER NOT NULL,
decided_at INTEGER NOT NULL,
scheduled_session_id TEXT NOT NULL,
PRIMARY KEY (account_id, thing_fullname),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_account_browsing_decisions_recent
ON account_browsing_decisions(account_id, decided_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_browsing_decisions_selected
ON account_browsing_decisions(thing_fullname)
WHERE decision = 'simulated-upvote';
CREATE INDEX IF NOT EXISTS idx_account_browsing_decisions_day
ON account_browsing_decisions(account_id, decision, decided_at);
`),
]; ];
let currentVersion = db.pragma('user_version', { simple: true }) as number; let currentVersion = db.pragma('user_version', { simple: true }) as number;
+69
View File
@@ -125,6 +125,23 @@ export interface RedditPost {
pinned?: boolean; pinned?: boolean;
distinguished?: string | null; distinguished?: string | null;
removed_by_category?: string | null; removed_by_category?: string | null;
score?: number;
}
/** Typed Reddit content used only by the simulation-only browsing reader. */
export interface BrowsingThing {
fullname: string;
kind: 'post' | 'comment';
subreddit: string;
author: string;
permalink: string;
createdAt: number;
score: number | null;
text: string;
nsfw: boolean;
parentPostId: string;
distinguished: string | null;
removed: boolean;
} }
interface RedditListingChild { interface RedditListingChild {
@@ -478,6 +495,58 @@ export class RedditApiClient {
} }
} }
/**
* Typed top-level comments for browsing simulation. This is intentionally
* separate from `getThreadTopComments()`, whose string-only contract belongs
* to comment generation.
*/
async getBrowsingComments(
subreddit: string,
postId: string,
limit = 25,
): Promise<BrowsingThing[]> {
const cookieHeader = await this.getCookieHeader();
const headers: Record<string, string> = {};
if (cookieHeader) headers.Cookie = cookieHeader;
try {
const response = await this.scheduleRequest(() => this.client.get(
`/r/${subreddit}/comments/${postId}.json`,
{ params: { limit, depth: 1, sort: 'hot', raw_json: 1 }, headers },
));
const listings = Array.isArray(response.data) ? response.data : [];
const children = (listings[1] as { data?: { children?: unknown } } | undefined)?.data?.children;
if (!Array.isArray(children)) return [];
return children.flatMap((child): BrowsingThing[] => {
const data = (child as { data?: Record<string, unknown> })?.data;
if (!data || typeof data.name !== 'string' || !data.name.startsWith('t1_')) return [];
const author = typeof data.author === 'string' ? data.author : '[deleted]';
const body = typeof data.body === 'string' ? data.body : '';
return [{
fullname: data.name,
kind: 'comment',
subreddit: typeof data.subreddit === 'string' ? data.subreddit : subreddit,
author,
permalink: typeof data.permalink === 'string'
? data.permalink
: `/r/${subreddit}/comments/${postId}/_/${data.name.slice(3)}/`,
createdAt: typeof data.created_utc === 'number' ? Math.round(data.created_utc * 1_000) : 0,
score: typeof data.score === 'number' ? data.score : null,
text: body,
nsfw: data.over_18 === true,
parentPostId: postId,
distinguished: typeof data.distinguished === 'string' ? data.distinguished : null,
removed: body === '[removed]' || body === '[deleted]' || data.removed === true,
}];
}).slice(0, limit);
} catch (error) {
const rateLimitError = this.toRateLimitError(error);
if (rateLimitError) throw rateLimitError;
this.logger.warn(`Failed to read browsing comments for ${postId}:`, error);
return [];
}
}
private isValidPost(post: RedditPost, type: 'image' | 'chat' = 'image'): boolean { private isValidPost(post: RedditPost, type: 'image' | 'chat' = 'image'): boolean {
// Skip deleted/removed authors // Skip deleted/removed authors
if (post.author === '[deleted]' || post.author === 'AutoModerator') return false; if (post.author === '[deleted]' || post.author === 'AutoModerator') return false;
+282
View File
@@ -0,0 +1,282 @@
import crypto from 'node:crypto';
import type {
AccountBrowsingSettingsRecord,
AccountBrowsingSimulationRepository,
BrowsingDecisionRecord,
BrowsingThingKind,
} from '../database/browsing-simulation-repository.js';
import type { GrowthSubredditRecord } from '../database/growth-repository.js';
import type { BrowsingThing, RedditPost } from '../reddit/api.js';
import type { Logger } from '../utils/logger.js';
const MAX_CONTENT_AGE_MS = 7 * 24 * 60 * 60_000;
const MIN_POST_CONTEXT = 12;
const MIN_COMMENT_CONTEXT = 20;
export interface BrowsingDecisionSink {
recordSimulation(decision: BrowsingDecisionRecord): Promise<void>;
}
export interface AccountBrowsingSimulationServiceOptions {
accountId: string;
repository: AccountBrowsingSimulationRepository;
growthSubreddits: (now: number) => GrowthSubredditRecord[];
reddit: {
getHotPosts(subreddit: string, limit: number): Promise<RedditPost[]>;
getBrowsingComments(subreddit: string, postId: string, limit: number): Promise<BrowsingThing[]>;
};
sink: BrowsingDecisionSink;
logger: Logger;
now?: () => number;
random?: () => number;
onActivity?: (decision: BrowsingDecisionRecord) => void;
}
export type BrowsingSimulationOutcome =
| { kind: 'idle'; reason: string }
| { kind: 'scheduled'; nextDueAt: number }
| { kind: 'simulated'; decision: BrowsingDecisionRecord; nextDueAt: number };
/**
* Selects hypothetical votes without exposing any Reddit write dependency.
* The only terminal capability is an injected durable decision sink.
*/
export class AccountBrowsingSimulationService {
private readonly now: () => number;
private readonly random: () => number;
constructor(private readonly options: AccountBrowsingSimulationServiceOptions) {
this.now = options.now ?? Date.now;
this.random = options.random ?? Math.random;
}
async tick(signal?: AbortSignal): Promise<BrowsingSimulationOutcome> {
if (signal?.aborted) throw new DOMException('Operation aborted', 'AbortError');
const now = this.now();
const settings = this.options.repository.getSettings(this.options.accountId, now);
if (!settings.enabled) return { kind: 'idle', reason: 'Browsing simulation is disabled' };
if (settings.nextDueAt === null) {
const nextDueAt = this.scheduleNext(settings, now);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
return { kind: 'scheduled', nextDueAt };
}
if (now < settings.nextDueAt) return { kind: 'idle', reason: 'Browsing simulation is not due' };
if (isQuietTime(new Date(now), settings)) {
const nextDueAt = moveOutsideQuietHours(now, settings);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
return { kind: 'scheduled', nextDueAt };
}
const today = startOfLocalDay(now);
const target = dailyTarget(this.options.accountId, today, settings);
if (this.options.repository.countSelectedSince(this.options.accountId, today) >= target) {
const nextDueAt = moveOutsideQuietHours(today + 24 * 60 * 60_000, settings);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
return { kind: 'scheduled', nextDueAt };
}
const communities = this.options.growthSubreddits(now);
if (communities.length === 0) {
const nextDueAt = this.scheduleNext(settings, now);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
return { kind: 'scheduled', nextDueAt };
}
const community = weightedPick(communities, (entry) => entry.weight, this.random);
const posts = await this.options.reddit.getHotPosts(community.subreddit, 25);
const sessionId = crypto.randomUUID();
const eligiblePosts = await this.screenPosts(posts, settings, sessionId, now);
const preferredKind: BrowsingThingKind = this.random() * (settings.postWeight + settings.commentWeight)
< settings.postWeight ? 'post' : 'comment';
let candidates: BrowsingThing[] = [];
if (preferredKind === 'comment' && eligiblePosts.length > 0) {
const parent = eligiblePosts[Math.floor(this.random() * eligiblePosts.length)];
const comments = await this.options.reddit.getBrowsingComments(parent.subreddit, parent.parentPostId, 25);
candidates = await this.screenThings(comments, settings, sessionId, now);
}
if (candidates.length === 0) candidates = eligiblePosts;
const nextDueAt = this.scheduleNext(settings, now);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
if (candidates.length === 0) return { kind: 'scheduled', nextDueAt };
const selected = candidates[Math.floor(this.random() * candidates.length)];
const decision = toDecision(this.options.accountId, selected, 'simulated-upvote', 'Eligible simulated selection', sessionId, now);
await this.options.sink.recordSimulation(decision);
this.options.onActivity?.(decision);
this.options.logger.info(`Simulated browsing selection for ${selected.fullname} in r/${selected.subreddit}; no Reddit vote was submitted`);
return { kind: 'simulated', decision, nextDueAt };
}
private async screenPosts(
posts: readonly RedditPost[],
settings: AccountBrowsingSettingsRecord,
sessionId: string,
now: number,
): Promise<BrowsingThing[]> {
return this.screenThings(posts.map(postToThing), settings, sessionId, now, posts);
}
private async screenThings(
things: readonly BrowsingThing[],
settings: AccountBrowsingSettingsRecord,
sessionId: string,
now: number,
posts: readonly RedditPost[] = [],
): Promise<BrowsingThing[]> {
const eligible: BrowsingThing[] = [];
for (const thing of things) {
const post = posts.find((candidate) => candidate.id === thing.parentPostId);
const reason = this.exclusionReason(thing, settings, now, post);
if (!reason) {
eligible.push(thing);
continue;
}
if (!this.options.repository.hasAccountDecision(this.options.accountId, thing.fullname)) {
await this.options.sink.recordSimulation(
toDecision(this.options.accountId, thing, 'rejected', reason, sessionId, now),
);
}
}
return eligible;
}
private exclusionReason(
thing: BrowsingThing,
settings: AccountBrowsingSettingsRecord,
now: number,
post?: RedditPost,
): string | null {
const repository = this.options.repository;
if (repository.hasAccountDecision(this.options.accountId, thing.fullname)) return 'Already considered by this account';
if (repository.wasSelectedByAnyAccount(thing.fullname)) return 'Already selected by another owned account';
if (thing.author === '[deleted]' || thing.removed) return 'Deleted or removed content';
if (thing.author.toLowerCase() === 'automoderator' || thing.distinguished !== null) return 'Moderator or AutoModerator content';
if (repository.isOwnedAuthor(thing.author)) return 'Authored by an owned account';
if (repository.isOutreachRecipient(thing.author)) return 'Authored by an outreach recipient';
if (repository.isGrowthCommentPost(thing.parentPostId)) return 'Post already used for owned-account growth';
if (repository.isPromotionExperimentPost(thing.parentPostId)) return 'Post belongs to a promotion experiment';
if (thing.nsfw && !settings.allowNsfw) return 'NSFW content is disabled';
if (thing.createdAt <= 0 || now - thing.createdAt > MAX_CONTENT_AGE_MS) return 'Content is too old';
if (thing.text.trim().length < (thing.kind === 'post' ? MIN_POST_CONTEXT : MIN_COMMENT_CONTEXT)) return 'Content has too little context';
if (post?.locked || post?.archived || post?.stickied || post?.pinned || post?.removed_by_category) {
return 'Thread is locked, archived, stickied, or removed';
}
return null;
}
private scheduleNext(settings: AccountBrowsingSettingsRecord, from: number): number {
const target = dailyTarget(this.options.accountId, startOfLocalDay(from), settings);
const quietHours = settings.quietHours.enabled
? quietDurationHours(settings.quietHours.startHour, settings.quietHours.endHour)
: 0;
const wakingMs = (24 - quietHours) * 60 * 60_000;
// Spread the day's target across the waking window while retaining bounded
// randomness. The hard minimum remains authoritative for unusual policies.
const distributedGap = (wakingMs / target) * (0.75 + this.random() * 0.5);
const jitter = Math.floor(this.random() * (settings.jitterMs + 1));
return moveOutsideQuietHours(
from + Math.max(settings.minimumGapMs, Math.floor(distributedGap)) + jitter,
settings,
);
}
}
export function dailyTarget(
accountId: string,
localDayStart: number,
settings: Pick<AccountBrowsingSettingsRecord, 'dailyTargetMin' | 'dailyTargetMax'>,
): number {
const range = settings.dailyTargetMax - settings.dailyTargetMin + 1;
const digest = crypto.createHash('sha256').update(`${accountId}:${localDayStart}`).digest();
return settings.dailyTargetMin + (digest.readUInt32BE(0) % range);
}
export function isQuietTime(
date: Date,
settings: Pick<AccountBrowsingSettingsRecord, 'quietHours'>,
): boolean {
const quiet = settings.quietHours;
if (!quiet.enabled || quiet.startHour === quiet.endHour) return false;
const hour = date.getHours();
return quiet.startHour < quiet.endHour
? hour >= quiet.startHour && hour < quiet.endHour
: hour >= quiet.startHour || hour < quiet.endHour;
}
function moveOutsideQuietHours(
timestamp: number,
settings: Pick<AccountBrowsingSettingsRecord, 'quietHours'>,
): number {
const date = new Date(timestamp);
if (!isQuietTime(date, settings)) return timestamp;
const end = new Date(timestamp);
end.setMinutes(0, 0, 0);
end.setHours(settings.quietHours.endHour);
if (end.getTime() <= timestamp) end.setDate(end.getDate() + 1);
return end.getTime();
}
function startOfLocalDay(timestamp: number): number {
const date = new Date(timestamp);
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function quietDurationHours(startHour: number, endHour: number): number {
if (startHour === endHour) return 0;
return endHour > startHour ? endHour - startHour : 24 - startHour + endHour;
}
function weightedPick<T>(items: readonly T[], weight: (item: T) => number, random: () => number): T {
const total = items.reduce((sum, item) => sum + weight(item), 0);
let cursor = random() * total;
for (const item of items) {
cursor -= weight(item);
if (cursor < 0) return item;
}
return items[items.length - 1];
}
function postToThing(post: RedditPost): BrowsingThing {
return {
fullname: post.name.startsWith('t3_') ? post.name : `t3_${post.id}`,
kind: 'post',
subreddit: post.subreddit,
author: post.author,
permalink: post.permalink,
createdAt: Math.round(post.created_utc * 1_000),
score: typeof post.score === 'number' ? post.score : null,
text: `${post.title}\n${post.selftext ?? ''}`.trim(),
nsfw: post.over_18,
parentPostId: post.id,
distinguished: post.distinguished ?? null,
removed: Boolean(post.removed_by_category),
};
}
function toDecision(
accountId: string,
thing: BrowsingThing,
decision: BrowsingDecisionRecord['decision'],
reason: string,
sessionId: string,
now: number,
): BrowsingDecisionRecord {
return {
accountId,
thingFullname: thing.fullname,
thingKind: thing.kind,
postId: thing.parentPostId,
subreddit: thing.subreddit,
author: thing.author,
permalink: thing.permalink,
decision,
reason,
discoveredAt: now,
decidedAt: now,
scheduledSessionId: sessionId,
};
}
+37 -2
View File
@@ -63,6 +63,8 @@ import { RecipientAccountStatusService } from './recipient-account-status.js';
import { AccountEstablishmentService } from './account-establishment.js'; import { AccountEstablishmentService } from './account-establishment.js';
import { AccountGrowthService, type GrowthActivityEvent } from './account-growth.js'; import { AccountGrowthService, type GrowthActivityEvent } from './account-growth.js';
import { AccountGrowthRepository } from '../database/growth-repository.js'; import { AccountGrowthRepository } from '../database/growth-repository.js';
import { AccountBrowsingSimulationRepository } from '../database/browsing-simulation-repository.js';
import { AccountBrowsingSimulationService } from './account-browsing-simulation.js';
import { allowsConversationSync, allowsSending, type BotMode } from './bot-mode.js'; import { allowsConversationSync, allowsSending, type BotMode } from './bot-mode.js';
export interface ConcreteRuntimeFactoryOptions { export interface ConcreteRuntimeFactoryOptions {
@@ -268,10 +270,11 @@ async function buildRuntime(
}, },
}, },
); );
const growthRepository = new AccountGrowthRepository(db);
const growthService = new AccountGrowthService({ const growthService = new AccountGrowthService({
accountId: session.accountId, accountId: session.accountId,
redditUsername: session.redditUsername, redditUsername: session.redditUsername,
repository: new AccountGrowthRepository(db), repository: growthRepository,
reddit: { reddit: {
getHotPosts: (subreddit, limit) => redditApi.getHotPosts(subreddit, limit), getHotPosts: (subreddit, limit) => redditApi.getHotPosts(subreddit, limit),
getThreadTopComments: (subreddit, postId, limit) => getThreadTopComments: (subreddit, postId, limit) =>
@@ -284,6 +287,26 @@ async function buildRuntime(
dryRun: accountConfig.browser.dryRun, dryRun: accountConfig.browser.dryRun,
onActivity: (event) => recordGrowthActivity(activityLogRepo, session.redditUsername, event), onActivity: (event) => recordGrowthActivity(activityLogRepo, session.redditUsername, event),
}); });
const browsingRepository = new AccountBrowsingSimulationRepository(db);
const browsingService = new AccountBrowsingSimulationService({
accountId: session.accountId,
repository: browsingRepository,
growthSubreddits: (now) => growthRepository.listActiveSubreddits(session.accountId, now),
reddit: {
getHotPosts: (subreddit, limit) => redditApi.getHotPosts(subreddit, limit),
getBrowsingComments: (subreddit, postId, limit) =>
redditApi.getBrowsingComments(subreddit, postId, limit),
},
sink: browsingRepository,
logger,
onActivity: (decision) => activityLogRepo.insert({
timestamp: decision.decidedAt,
type: 'response',
message: `Simulated ${decision.thingKind} upvote in r/${decision.subreddit}; no Reddit vote was submitted`,
postUrl: `https://www.reddit.com${decision.permalink}`,
postTitle: `r/${decision.subreddit}`,
}),
});
const rateLimiter = new RateLimiter(accountConfig, logger, undefined, { const rateLimiter = new RateLimiter(accountConfig, logger, undefined, {
sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since), sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since),
}); });
@@ -332,7 +355,7 @@ async function buildRuntime(
activityLogRepo, activityLogRepo,
monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService, monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService,
mediaSyncService, mediaExchange, rateLimiter, mediaSyncService, mediaExchange, rateLimiter,
recipientAccountStatusService, establishmentService, growthService, recipientAccountStatusService, establishmentService, growthService, browsingService,
}; };
const lifecycle = new AccountRuntimeLoop( const lifecycle = new AccountRuntimeLoop(
resources, accountConfig, logger, getBotMode, recipientDeduplicator, resources, accountConfig, logger, getBotMode, recipientDeduplicator,
@@ -418,6 +441,7 @@ export class AccountRuntimeLoop {
let nextReplyCheckAt = 0; let nextReplyCheckAt = 0;
let nextPerformanceCheckAt = 0; let nextPerformanceCheckAt = 0;
let nextGrowthCheckAt = 0; let nextGrowthCheckAt = 0;
let nextBrowsingCheckAt = 0;
const replyInterval = 30_000; const replyInterval = 30_000;
const performanceInterval = Math.max(this.config.reddit.pollIntervalMs * 5, 60_000); const performanceInterval = Math.max(this.config.reddit.pollIntervalMs * 5, 60_000);
// The pacer decides whether a comment is actually due; this is just how // The pacer decides whether a comment is actually due; this is just how
@@ -478,6 +502,17 @@ export class AccountRuntimeLoop {
this.logger.error('Account growth tick failed:', error); this.logger.error('Account growth tick failed:', error);
} }
} }
// Browsing simulation is strictly below comment growth. It records a
// hypothetical decision only and runs in both scan and running modes.
if (this.resources.browsingService && Date.now() >= nextBrowsingCheckAt) {
nextBrowsingCheckAt = Date.now() + growthInterval;
try {
await this.resources.browsingService.tick(signal);
} catch (error) {
if (signal.aborted) return;
this.logger.error('Browsing simulation tick failed:', error);
}
}
await delay(5_000, signal); await delay(5_000, signal);
} catch (error) { } catch (error) {
if (signal.aborted) return; if (signal.aborted) return;
+3
View File
@@ -37,6 +37,7 @@ import type { ConversationAutomationService } from './conversation-automation.js
import type { RecipientAccountStatusService } from './recipient-account-status.js'; import type { RecipientAccountStatusService } from './recipient-account-status.js';
import type { AccountEstablishmentService } from './account-establishment.js'; import type { AccountEstablishmentService } from './account-establishment.js';
import type { AccountGrowthService } from './account-growth.js'; import type { AccountGrowthService } from './account-growth.js';
import type { AccountBrowsingSimulationService } from './account-browsing-simulation.js';
import type { ProcessedPost, ProcessResult } from './processor.js'; import type { ProcessedPost, ProcessResult } from './processor.js';
/** Credentials passed to a factory. They must never be exposed by list/snapshot APIs. */ /** Credentials passed to a factory. They must never be exposed by list/snapshot APIs. */
@@ -91,6 +92,8 @@ export interface RedditAccountRuntimeResources {
* functional without it, and it is disabled per account by default. * functional without it, and it is disabled per account by default.
*/ */
growthService?: AccountGrowthService; growthService?: AccountGrowthService;
/** Simulation-only browsing selection. It has no Reddit write dependency. */
browsingService?: AccountBrowsingSimulationService;
mediaExchange?: MediaLibraryExchangeService; mediaExchange?: MediaLibraryExchangeService;
rateLimiter: RateLimiter; rateLimiter: RateLimiter;
} }
+6
View File
@@ -12,6 +12,7 @@ import {
SendRedditUserMessageResponseSchema, SendRedditUserMessageResponseSchema,
} from './agent-api-contracts.js'; } from './agent-api-contracts.js';
import { import {
BrowsingSettingsUpdateRequestSchema,
GrowthAccountResponseSchema, GrowthAccountResponseSchema,
GrowthOverviewResponseSchema, GrowthOverviewResponseSchema,
GrowthPauseRequestSchema, GrowthPauseRequestSchema,
@@ -180,6 +181,11 @@ 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'], {
access: 'admin',
requestSchema: BrowsingSettingsUpdateRequestSchema,
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,
+2
View File
@@ -28,6 +28,7 @@ import type { CandidateQueueRepository } from '../database/candidate-queue-repos
import type { MediaLibraryService } from '../services/media-library.js'; import type { MediaLibraryService } from '../services/media-library.js';
import type { AccountContentRepository } from '../database/account-repositories.js'; import type { AccountContentRepository } from '../database/account-repositories.js';
import type { AccountGrowthRepository } from '../database/growth-repository.js'; import type { AccountGrowthRepository } from '../database/growth-repository.js';
import type { AccountBrowsingSimulationRepository } from '../database/browsing-simulation-repository.js';
import type { PushNotifier } from '../services/push-notifier.js'; import type { PushNotifier } from '../services/push-notifier.js';
import type { BotMode } from '../services/bot-mode.js'; import type { BotMode } from '../services/bot-mode.js';
@@ -65,6 +66,7 @@ export interface WebContext {
accountRuntimes?: RedditAccountRuntimeManager; accountRuntimes?: RedditAccountRuntimeManager;
candidateQueue?: CandidateQueueRepository; candidateQueue?: CandidateQueueRepository;
growth?: AccountGrowthRepository; growth?: AccountGrowthRepository;
browsing?: AccountBrowsingSimulationRepository;
mediaLibrary?: MediaLibraryService; mediaLibrary?: MediaLibraryService;
pushNotifier?: PushNotifier; pushNotifier?: PushNotifier;
}; };
+41
View File
@@ -18,6 +18,45 @@ export const GrowthQuietHoursSchema = z.strictObject({
endHour: z.number().int().min(0).max(23), endHour: z.number().int().min(0).max(23),
}); });
export const BrowsingSettingsUpdateRequestSchema = z.strictObject({
enabled: z.boolean().optional(),
dailyTargetMin: z.number().int().min(1).max(12).optional(),
dailyTargetMax: z.number().int().min(1).max(12).optional(),
postWeight: z.number().min(0).max(100).optional(),
commentWeight: z.number().min(0).max(100).optional(),
quietHours: GrowthQuietHoursSchema.optional(),
minimumGapMs: z.number().int().min(45 * 60_000).max(DAY_MS).optional(),
jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
allowNsfw: z.boolean().optional(),
});
export const BrowsingSettingsSchema = z.strictObject({
enabled: z.boolean(), dailyTargetMin: z.number().int(), dailyTargetMax: z.number().int(),
postWeight: z.number(), commentWeight: z.number(), quietHours: GrowthQuietHoursSchema,
minimumGapMs: z.number().int(), jitterMs: z.number().int(), allowNsfw: z.boolean(),
nextDueAt: z.number().nullable(), updatedAt: z.number(),
});
export const BrowsingStatsSchema = z.strictObject({
selected: z.number().int(), posts: z.number().int(), comments: z.number().int(),
rejected: z.number().int(), skipped: z.number().int(),
});
export const BrowsingDecisionSchema = z.strictObject({
thingFullname: z.string(), thingKind: z.enum(['post', 'comment']), postId: z.string(),
subreddit: z.string(), author: z.string(), permalink: z.string(),
decision: z.enum(['simulated-upvote', 'skipped', 'rejected']), reason: z.string(), decidedAt: z.number(),
});
export const BrowsingSummarySchema = z.strictObject({
settings: BrowsingSettingsSchema, todayTarget: z.number().int(), stats: BrowsingStatsSchema,
noVotesSubmitted: z.literal(true),
});
export const BrowsingDetailSchema = BrowsingSummarySchema.extend({
recentDecisions: z.array(BrowsingDecisionSchema),
});
export const GrowthSettingsUpdateRequestSchema = z.strictObject({ export const GrowthSettingsUpdateRequestSchema = z.strictObject({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
minDelayMs: z.number().int().min(0).max(DAY_MS).optional(), minDelayMs: z.number().int().min(0).max(DAY_MS).optional(),
@@ -107,11 +146,13 @@ export const GrowthAccountSummarySchema = z.strictObject({
stats: GrowthStatsSchema, stats: GrowthStatsSchema,
subredditCount: z.number().int(), subredditCount: z.number().int(),
activeSubredditCount: z.number().int(), activeSubredditCount: z.number().int(),
browsing: BrowsingSummarySchema,
}); });
export const GrowthAccountDetailSchema = GrowthAccountSummarySchema.extend({ export const GrowthAccountDetailSchema = GrowthAccountSummarySchema.extend({
subreddits: z.array(GrowthSubredditSchema), subreddits: z.array(GrowthSubredditSchema),
recentComments: z.array(GrowthCommentSchema), recentComments: z.array(GrowthCommentSchema),
browsing: BrowsingDetailSchema,
}); });
export const GrowthOverviewResponseSchema = z.strictObject({ export const GrowthOverviewResponseSchema = z.strictObject({
+113 -20
View File
@@ -8,7 +8,10 @@ import {
type GrowthSubredditRecord, type GrowthSubredditRecord,
} from '../../database/growth-repository.js'; } from '../../database/growth-repository.js';
import { dailyAllowance, effectiveDailyAllowance } from '../../services/growth-pacer.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 { import {
BrowsingSettingsUpdateRequestSchema as BrowsingSettingsUpdateSchema,
GrowthPauseRequestSchema as PauseSchema, GrowthPauseRequestSchema as PauseSchema,
GrowthSettingsUpdateRequestSchema as SettingsUpdateSchema, GrowthSettingsUpdateRequestSchema as SettingsUpdateSchema,
GrowthSubredditCreateRequestSchema as SubredditCreateSchema, GrowthSubredditCreateRequestSchema as SubredditCreateSchema,
@@ -27,6 +30,7 @@ export function createGrowthRouter(ctx: WebContext): Router {
const router = Router(); const router = Router();
const growth = (): AccountGrowthRepository | null => ctx.services.growth ?? null; const growth = (): AccountGrowthRepository | null => ctx.services.growth ?? null;
const browsing = (): AccountBrowsingSimulationRepository | null => ctx.services.browsing ?? null;
const accountName = (accountId: string): string | null => { const accountName = (accountId: string): string | null => {
const account = ctx.services.redditAccounts?.list() const account = ctx.services.redditAccounts?.list()
@@ -36,12 +40,14 @@ export function createGrowthRouter(ctx: WebContext): Router {
router.get('/growth', (_req, res) => { router.get('/growth', (_req, res) => {
const repo = growth(); const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' }); const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const accounts = ctx.services.redditAccounts?.list() ?? []; const accounts = ctx.services.redditAccounts?.list() ?? [];
return res.json({ return res.json({
generatedAt: Date.now(), generatedAt: Date.now(),
accounts: accounts.map((account) => summarize( accounts: accounts.map((account) => summarize(
repo, repo,
browsingRepo,
account.accountId, account.accountId,
account.redditUsername, account.redditUsername,
)), )),
@@ -50,16 +56,43 @@ export function createGrowthRouter(ctx: WebContext): Router {
router.get('/growth/:accountId', (req, res) => { router.get('/growth/:accountId', (req, res) => {
const repo = growth(); const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' }); const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params; const { accountId } = req.params;
const username = accountName(accountId); const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' }); if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
return res.json({ success: true, account: detail(repo, accountId, username) }); return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
});
router.patch('/growth/:accountId/browsing', (req, res) => {
const repo = growth();
const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params;
const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
const parsed = BrowsingSettingsUpdateSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid browsing settings', detail: parsed.error.issues });
}
try {
browsingRepo.saveSettings(accountId, parsed.data);
} catch (error) {
return res.status(400).json({ error: errorMessage(error) });
}
if (parsed.data.enabled !== undefined) {
ctx.broadcaster.push(
'info',
`Browsing simulation ${parsed.data.enabled ? 'enabled' : 'disabled'} for u/${username}`,
);
}
return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
}); });
router.patch('/growth/:accountId', (req, res) => { router.patch('/growth/:accountId', (req, res) => {
const repo = growth(); const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' }); const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params; const { accountId } = req.params;
const username = accountName(accountId); const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' }); if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
@@ -79,12 +112,13 @@ export function createGrowthRouter(ctx: WebContext): Router {
`Account growth ${parsed.data.enabled ? 'enabled' : 'disabled'} for u/${username}`, `Account growth ${parsed.data.enabled ? 'enabled' : 'disabled'} for u/${username}`,
); );
} }
return res.json({ success: true, account: detail(repo, accountId, username) }); return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
}); });
router.post('/growth/:accountId/pause', (req, res) => { router.post('/growth/:accountId/pause', (req, res) => {
const repo = growth(); const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' }); const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params; const { accountId } = req.params;
const username = accountName(accountId); const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' }); if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
@@ -95,36 +129,39 @@ export function createGrowthRouter(ctx: WebContext): Router {
} }
repo.pause(accountId, parsed.data.reason ?? 'Paused by the operator'); repo.pause(accountId, parsed.data.reason ?? 'Paused by the operator');
ctx.broadcaster.push('warn', `Account growth paused for u/${username}`); ctx.broadcaster.push('warn', `Account growth paused for u/${username}`);
return res.json({ success: true, account: detail(repo, accountId, username) }); return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
}); });
router.post('/growth/:accountId/resume', (req, res) => { router.post('/growth/:accountId/resume', (req, res) => {
const repo = growth(); const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' }); const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params; const { accountId } = req.params;
const username = accountName(accountId); const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' }); if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
repo.resume(accountId); repo.resume(accountId);
ctx.broadcaster.push('info', `Account growth resumed for u/${username}`); ctx.broadcaster.push('info', `Account growth resumed for u/${username}`);
return res.json({ success: true, account: detail(repo, accountId, username) }); return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
}); });
router.delete('/growth/:accountId/queue', (req, res) => { router.delete('/growth/:accountId/queue', (req, res) => {
const repo = growth(); const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' }); const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params; const { accountId } = req.params;
const username = accountName(accountId); const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' }); if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
const cleared = repo.clearPendingQueue(accountId); const cleared = repo.clearPendingQueue(accountId);
ctx.broadcaster.push('warn', `Cleared ${cleared} pending growth item(s) for u/${username}`); ctx.broadcaster.push('warn', `Cleared ${cleared} pending growth item(s) for u/${username}`);
return res.json({ success: true, cleared, account: detail(repo, accountId, username) }); return res.json({ success: true, cleared, account: detail(repo, browsingRepo, accountId, username) });
}); });
router.post('/growth/:accountId/subreddits', (req, res) => { router.post('/growth/:accountId/subreddits', (req, res) => {
const repo = growth(); const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' }); const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params; const { accountId } = req.params;
const username = accountName(accountId); const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' }); if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
@@ -141,12 +178,13 @@ export function createGrowthRouter(ctx: WebContext): Router {
} catch (error) { } catch (error) {
return res.status(400).json({ error: errorMessage(error) }); return res.status(400).json({ error: errorMessage(error) });
} }
return res.status(201).json({ success: true, account: detail(repo, accountId, username) }); return res.status(201).json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
}); });
router.patch('/growth/:accountId/subreddits/:subreddit', (req, res) => { router.patch('/growth/:accountId/subreddits/:subreddit', (req, res) => {
const repo = growth(); const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' }); const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params; const { accountId } = req.params;
const username = accountName(accountId); const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' }); if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
@@ -175,12 +213,13 @@ export function createGrowthRouter(ctx: WebContext): Router {
if (parsed.data.enabled !== undefined) { if (parsed.data.enabled !== undefined) {
repo.setSubredditEnabled(accountId, name, parsed.data.enabled); repo.setSubredditEnabled(accountId, name, parsed.data.enabled);
} }
return res.json({ success: true, account: detail(repo, accountId, username) }); return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
}); });
router.delete('/growth/:accountId/subreddits/:subreddit', (req, res) => { router.delete('/growth/:accountId/subreddits/:subreddit', (req, res) => {
const repo = growth(); const repo = growth();
if (!repo) return res.status(503).json({ error: 'Growth storage is unavailable' }); const browsingRepo = browsing();
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
const { accountId } = req.params; const { accountId } = req.params;
const username = accountName(accountId); const username = accountName(accountId);
if (username === null) return res.status(404).json({ error: 'Reddit account not found' }); if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
@@ -194,13 +233,18 @@ export function createGrowthRouter(ctx: WebContext): Router {
if (!repo.removeSubreddit(accountId, name)) { if (!repo.removeSubreddit(accountId, name)) {
return res.status(404).json({ error: `r/${name} is not on this account's list` }); return res.status(404).json({ error: `r/${name} is not on this account's list` });
} }
return res.json({ success: true, account: detail(repo, accountId, username) }); return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
}); });
return router; return router;
} }
function summarize(repo: AccountGrowthRepository, accountId: string, redditUsername: string) { function summarize(
repo: AccountGrowthRepository,
browsingRepo: AccountBrowsingSimulationRepository,
accountId: string,
redditUsername: string,
) {
const now = Date.now(); const now = Date.now();
const settings = repo.getSettings(accountId, now); const settings = repo.getSettings(accountId, now);
const subreddits = repo.listSubreddits(accountId); const subreddits = repo.listSubreddits(accountId);
@@ -211,15 +255,64 @@ function summarize(repo: AccountGrowthRepository, accountId: string, redditUsern
stats: presentStats(repo, accountId, now), stats: presentStats(repo, accountId, now),
subredditCount: subreddits.length, subredditCount: subreddits.length,
activeSubredditCount: repo.listActiveSubreddits(accountId, now).length, activeSubredditCount: repo.listActiveSubreddits(accountId, now).length,
browsing: presentBrowsing(browsingRepo, accountId, now, false),
}; };
} }
function detail(repo: AccountGrowthRepository, accountId: string, redditUsername: string) { function detail(
repo: AccountGrowthRepository,
browsingRepo: AccountBrowsingSimulationRepository,
accountId: string,
redditUsername: string,
) {
const now = Date.now(); const now = Date.now();
return { return {
...summarize(repo, accountId, redditUsername), ...summarize(repo, browsingRepo, accountId, redditUsername),
subreddits: repo.listSubreddits(accountId).map((entry) => presentSubreddit(repo, accountId, entry, now)), subreddits: repo.listSubreddits(accountId).map((entry) => presentSubreddit(repo, accountId, entry, now)),
recentComments: repo.listRecent(accountId, 50).map(presentComment), recentComments: repo.listRecent(accountId, 50).map(presentComment),
browsing: presentBrowsing(browsingRepo, accountId, now, true),
};
}
function presentBrowsing(
repo: AccountBrowsingSimulationRepository,
accountId: string,
now: number,
includeRecent: boolean,
) {
const settings = repo.getSettings(accountId, now);
const day = new Date(now);
day.setHours(0, 0, 0, 0);
return {
settings: {
enabled: settings.enabled,
dailyTargetMin: settings.dailyTargetMin,
dailyTargetMax: settings.dailyTargetMax,
postWeight: settings.postWeight,
commentWeight: settings.commentWeight,
quietHours: settings.quietHours,
minimumGapMs: settings.minimumGapMs,
jitterMs: settings.jitterMs,
allowNsfw: settings.allowNsfw,
nextDueAt: settings.nextDueAt,
updatedAt: settings.updatedAt,
},
todayTarget: dailyTarget(accountId, day.getTime(), settings),
stats: repo.getCountsSince(accountId, day.getTime()),
noVotesSubmitted: true as const,
...(includeRecent ? {
recentDecisions: repo.listRecentSelections(accountId, 50).map((decision) => ({
thingFullname: decision.thingFullname,
thingKind: decision.thingKind,
postId: decision.postId,
subreddit: decision.subreddit,
author: decision.author,
permalink: decision.permalink,
decision: decision.decision,
reason: decision.reason,
decidedAt: decision.decidedAt,
})),
} : {}),
}; };
} }
+5
View File
@@ -52,6 +52,7 @@ import type { MediaLibraryService } from '../services/media-library.js';
import { createMediaLibraryRouter } from './routes/media-library.js'; import { createMediaLibraryRouter } from './routes/media-library.js';
import type { AccountContentRepository } from '../database/account-repositories.js'; import type { AccountContentRepository } from '../database/account-repositories.js';
import type { AccountGrowthRepository } from '../database/growth-repository.js'; import type { AccountGrowthRepository } from '../database/growth-repository.js';
import type { AccountBrowsingSimulationRepository } from '../database/browsing-simulation-repository.js';
import { createGrowthRouter } from './routes/growth.js'; import { createGrowthRouter } from './routes/growth.js';
import { allowsSending, type BotMode } from '../services/bot-mode.js'; import { allowsSending, type BotMode } from '../services/bot-mode.js';
@@ -278,6 +279,10 @@ export class WebServer {
this.services.growth = growth; this.services.growth = growth;
} }
setBrowsingRepository(browsing: AccountBrowsingSimulationRepository): void {
this.services.browsing = browsing;
}
setConfigStore(configStore: ConfigStore): void { setConfigStore(configStore: ConfigStore): void {
this.services.configStore = configStore; this.services.configStore = configStore;
} }
+247
View File
@@ -0,0 +1,247 @@
import Database from 'better-sqlite3';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { runMigrations } from '../src/database/client.js';
import {
AccountBrowsingSimulationRepository,
type BrowsingDecisionRecord,
} from '../src/database/browsing-simulation-repository.js';
import type { BrowsingThing, RedditPost } from '../src/reddit/api.js';
import {
AccountBrowsingSimulationService,
dailyTarget,
isQuietTime,
} from '../src/services/account-browsing-simulation.js';
import { createMockLogger } from './mockLogger.js';
const ACCOUNT = '@t2_owner:reddit.com';
const OTHER = '@t2_other:reddit.com';
const NOW = new Date(2026, 7, 11, 12, 0, 0, 0).getTime();
function insertAccount(db: Database.Database, accountId: string, username: string): void {
db.prepare(`
INSERT INTO reddit_accounts (
account_id, reddit_username, matrix_user_id, status, is_active,
cookies_json, matrix_credentials_json, created_at, updated_at
) VALUES (?, ?, ?, 'verified', 1, '[]', '{}', 0, 0)
`).run(accountId, username, accountId);
}
function post(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
return {
id,
name: `t3_${id}`,
subreddit: 'AskUK',
author: `author_${id}`,
title: `A useful discussion about topic ${id}`,
selftext: 'Enough context for an ordinary browsing choice.',
url: `https://www.reddit.com/r/AskUK/comments/${id}/`,
permalink: `/r/AskUK/comments/${id}/topic/`,
thumbnail: '',
is_self: true,
created_utc: NOW / 1_000 - 60,
over_18: false,
spoiler: false,
...overrides,
};
}
function comment(id: string, parentPostId: string): BrowsingThing {
return {
fullname: `t1_${id}`,
kind: 'comment',
subreddit: 'AskUK',
author: `commenter_${id}`,
permalink: `/r/AskUK/comments/${parentPostId}/topic/${id}/`,
createdAt: NOW - 30_000,
score: 2,
text: 'This comment has enough useful context to be eligible.',
nsfw: false,
parentPostId,
distinguished: null,
removed: false,
};
}
describe('AccountBrowsingSimulationService', () => {
let db: Database.Database;
let repository: AccountBrowsingSimulationRepository;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
insertAccount(db, ACCOUNT, 'owner');
insertAccount(db, OTHER, 'other');
repository = new AccountBrowsingSimulationRepository(db);
});
function service(options: {
posts?: RedditPost[];
comments?: BrowsingThing[];
random?: number;
} = {}) {
const getHotPosts = vi.fn().mockResolvedValue(options.posts ?? [post('one')]);
const getBrowsingComments = vi.fn().mockResolvedValue(options.comments ?? []);
const instance = new AccountBrowsingSimulationService({
accountId: ACCOUNT,
repository,
growthSubreddits: () => [{ subreddit: 'AskUK', weight: 1 }] as never,
reddit: { getHotPosts, getBrowsingComments },
sink: repository,
logger: createMockLogger(),
now: () => NOW,
random: () => options.random ?? 0,
});
return { instance, getHotPosts, getBrowsingComments };
}
function enable(overrides: Parameters<AccountBrowsingSimulationRepository['saveSettings']>[1] = {}): void {
repository.saveSettings(ACCOUNT, {
enabled: true,
quietHours: { enabled: false, startHour: 0, endHour: 7 },
...overrides,
}, NOW - 1);
}
it('is disabled by default and performs no discovery', async () => {
const fixture = service();
await expect(fixture.instance.tick()).resolves.toEqual({
kind: 'idle', reason: 'Browsing simulation is disabled',
});
expect(fixture.getHotPosts).not.toHaveBeenCalled();
});
it('persists a randomized due time that survives service recreation', async () => {
enable({ minimumGapMs: 45 * 60_000, jitterMs: 10 * 60_000 });
const first = service({ random: 0.5 });
const scheduled = await first.instance.tick();
const dueAt = repository.getSettings(ACCOUNT).nextDueAt;
expect(scheduled).toEqual({ kind: 'scheduled', nextDueAt: dueAt });
expect(dueAt).toBeGreaterThanOrEqual(NOW + 45 * 60_000);
expect(dueAt).toBeLessThanOrEqual(NOW + 12 * 60 * 60_000);
const restarted = service({ random: 0.1 });
await expect(restarted.instance.tick()).resolves.toEqual({
kind: 'idle', reason: 'Browsing simulation is not due',
});
expect(restarted.getHotPosts).not.toHaveBeenCalled();
});
it('records an eligible post as a simulation without any write transport', async () => {
enable({ postWeight: 100, commentWeight: 0 });
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
const result = await service().instance.tick();
expect(result.kind).toBe('simulated');
expect(repository.listRecent(ACCOUNT)).toEqual([
expect.objectContaining({ thingFullname: 't3_one', decision: 'simulated-upvote' }),
]);
});
it('honours comment weighting when eligible comments exist', async () => {
enable({ postWeight: 0, commentWeight: 100 });
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
const result = await service({ comments: [comment('c1', 'one')] }).instance.tick();
expect(result).toMatchObject({
kind: 'simulated', decision: { thingFullname: 't1_c1', thingKind: 'comment' },
});
});
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();
const otherSelection: BrowsingDecisionRecord = {
accountId: OTHER, thingFullname: 't3_taken', thingKind: 'post', postId: 'taken',
subreddit: 'AskUK', author: 'someone', permalink: '/taken', decision: 'simulated-upvote',
reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'other',
};
repository.recordDecision(otherSelection);
enable({ postWeight: 100, commentWeight: 0, allowNsfw: false });
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
const posts = [
post('owned', { author: 'owner' }),
post('recipient', { author: 'contacted' }),
post('adult', { over_18: true }),
post('taken'),
post('allowed'),
];
const result = await service({ posts }).instance.tick();
expect(result).toMatchObject({ kind: 'simulated', decision: { thingFullname: 't3_allowed' } });
const recent = repository.listRecent(ACCOUNT, 20);
expect(recent.filter((entry) => entry.decision === 'rejected').map((entry) => entry.reason))
.toEqual(expect.arrayContaining([
'Authored by an owned account',
'Authored by an outreach recipient',
'NSFW content is disabled',
'Already selected by another owned account',
]));
});
it('atomically prevents two accounts from recording the same simulated selection', async () => {
const first: BrowsingDecisionRecord = {
accountId: ACCOUNT, thingFullname: 't3_same', thingKind: 'post', postId: 'same',
subreddit: 'AskUK', author: 'author', permalink: '/same', decision: 'simulated-upvote',
reason: 'test', discoveredAt: NOW, decidedAt: NOW, scheduledSessionId: 'first',
};
expect(repository.recordDecision(first)).toBe(true);
expect(repository.recordDecision({ ...first, accountId: OTHER, scheduledSessionId: 'second' }))
.toBe(false);
});
it('stops at the deterministic daily target before making a Reddit request', async () => {
enable({ dailyTargetMin: 1, dailyTargetMax: 1 });
repository.recordDecision({
accountId: ACCOUNT, thingFullname: 't3_done', thingKind: 'post', postId: 'done',
subreddit: 'AskUK', author: 'author', permalink: '/done', decision: 'simulated-upvote',
reason: 'test', discoveredAt: NOW - 1, decidedAt: NOW - 1, scheduledSessionId: 'done',
});
repository.setNextDueAt(ACCOUNT, NOW, NOW - 1);
const fixture = service();
expect(await fixture.instance.tick()).toMatchObject({ kind: 'scheduled' });
expect(fixture.getHotPosts).not.toHaveBeenCalled();
});
});
describe('browsing pacing policy', () => {
it('handles quiet hours that cross midnight', () => {
const settings = { quietHours: { enabled: true, startHour: 23, endHour: 7 } };
expect(isQuietTime(new Date(2026, 7, 11, 23, 30), settings)).toBe(true);
expect(isQuietTime(new Date(2026, 7, 12, 6, 59), settings)).toBe(true);
expect(isQuietTime(new Date(2026, 7, 12, 7, 0), settings)).toBe(false);
});
it('derives one stable bounded target per account and local day', () => {
const settings = { dailyTargetMin: 3, dailyTargetMax: 6 };
const first = dailyTarget(ACCOUNT, NOW, settings);
expect(dailyTarget(ACCOUNT, NOW, settings)).toBe(first);
expect(first).toBeGreaterThanOrEqual(3);
expect(first).toBeLessThanOrEqual(6);
});
});
describe('browsing migration', () => {
it('upgrades the previous production schema without disturbing growth settings', () => {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
const version = db.pragma('user_version', { simple: true }) as number;
db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;');
db.pragma(`user_version = ${version - 1}`);
runMigrations(db);
insertAccount(db, ACCOUNT, 'owner');
expect(new AccountBrowsingSimulationRepository(db).getSettings(ACCOUNT)).toMatchObject({
enabled: false, dailyTargetMin: 3, dailyTargetMax: 6, nextDueAt: null,
});
expect(db.prepare(`PRAGMA table_info(account_growth_settings)`).all())
.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'daily_limit' })]));
});
});
+43
View File
@@ -43,6 +43,28 @@ function stats(overrides: Record<string, unknown> = {}) {
}; };
} }
function browsing(includeRecent = false) {
return {
settings: {
enabled: false,
dailyTargetMin: 3,
dailyTargetMax: 6,
postWeight: 60,
commentWeight: 40,
quietHours: { enabled: true, startHour: 23, endHour: 7 },
minimumGapMs: 2_700_000,
jitterMs: 900_000,
allowNsfw: false,
nextDueAt: null,
updatedAt: 1_700_000_000_000,
},
todayTarget: 4,
stats: { selected: 0, posts: 0, comments: 0, rejected: 0, skipped: 0 },
noVotesSubmitted: true,
...(includeRecent ? { recentDecisions: [] } : {}),
};
}
function summary(overrides: Record<string, unknown> = {}) { function summary(overrides: Record<string, unknown> = {}) {
return { return {
accountId: ACCOUNT_ID, accountId: ACCOUNT_ID,
@@ -51,6 +73,7 @@ function summary(overrides: Record<string, unknown> = {}) {
stats: stats(), stats: stats(),
subredditCount: 2, subredditCount: 2,
activeSubredditCount: 1, activeSubredditCount: 1,
browsing: browsing(),
...overrides, ...overrides,
}; };
} }
@@ -112,6 +135,7 @@ function detail(overrides: Record<string, unknown> = {}) {
lastError: 'Comment solicits contact or promotes a profile', lastError: 'Comment solicits contact or promotes a profile',
}, },
], ],
browsing: browsing(true),
...overrides, ...overrides,
}; };
} }
@@ -133,6 +157,10 @@ const server = setupServer(
requests.push({ method: 'PATCH', url: request.url, body: await request.json() }); requests.push({ method: 'PATCH', url: request.url, body: await request.json() });
return HttpResponse.json({ success: true, account: detailPayload }); return HttpResponse.json({ success: true, account: detailPayload });
}), }),
http.patch('/api/growth/:accountId/browsing', async ({ request }) => {
requests.push({ method: 'PATCH', url: request.url, body: await request.json() });
return HttpResponse.json({ success: true, account: detailPayload });
}),
http.post('/api/growth/:accountId/pause', async ({ request }) => { http.post('/api/growth/:accountId/pause', async ({ request }) => {
requests.push({ method: 'POST', url: request.url, body: await request.json() }); requests.push({ method: 'POST', url: request.url, body: await request.json() });
return HttpResponse.json({ success: true, account: detailPayload }); return HttpResponse.json({ success: true, account: detailPayload });
@@ -187,6 +215,21 @@ 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 () => {
renderGrowth();
const user = userEvent.setup();
expect(await screen.findByRole('heading', { name: 'Browsing simulation' })).toBeVisible();
expect(screen.getByText('No Reddit votes were submitted.')).toBeVisible();
expect(screen.getByText('0 / 4')).toBeVisible();
await user.click(screen.getByLabelText('Simulate browsing selections'));
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
method: 'PATCH', body: { enabled: true },
})));
expect(requests[0].url).toContain('/api/growth/%40t2_grower%3Areddit.com/browsing');
});
it('shows an empty state when there are no accounts', async () => { it('shows an empty state when there are no accounts', async () => {
overviewPayload = []; overviewPayload = [];
renderGrowth(); renderGrowth();
+3 -1
View File
@@ -108,7 +108,7 @@ describe('Database Client', () => {
.all() as { name: string }[]; .all() as { name: string }[];
// Existing domain/config tables plus account, conversation, sync, media // Existing domain/config tables plus account, conversation, sync, media
// ownership and account-growth stores. // ownership and account-growth stores.
expect(tables.length).toBe(26); expect(tables.length).toBe(28);
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([ expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
'candidate_queue', 'candidate_queue',
'runtime_state', 'runtime_state',
@@ -120,6 +120,8 @@ describe('Database Client', () => {
'account_growth_settings', 'account_growth_settings',
'account_growth_subreddits', 'account_growth_subreddits',
'account_growth_comments', 'account_growth_comments',
'account_browsing_settings',
'account_browsing_decisions',
])); ]));
}); });
+4 -2
View File
@@ -10,7 +10,7 @@ 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 = 2; const GROWTH_MIGRATION_COUNT = 3;
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost { function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
return { return {
@@ -536,7 +536,9 @@ describe('growth 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('ALTER TABLE account_growth_settings DROP COLUMN daily_limit'); db.exec('ALTER TABLE account_growth_settings DROP COLUMN daily_limit');
db.pragma(`user_version = ${version - 1}`); // Browsing simulation is the migration after the growth daily-limit
// column, so rewind both to replay the real upgrade sequence.
db.pragma(`user_version = ${version - 2}`);
insertAccount(db, ACCOUNT); insertAccount(db, ACCOUNT);
// Seed a row with the old shape so the upgrade runs against real data. // Seed a row with the old shape so the upgrade runs against real data.
db.prepare(` db.prepare(`
+41 -1
View File
@@ -43,6 +43,7 @@ describe('AccountRuntimeLoop mode gating', () => {
function loopHarness( function loopHarness(
getBotMode: () => 'running' | 'scan' | 'stopped', getBotMode: () => 'running' | 'scan' | 'stopped',
growthService?: { tick: ReturnType<typeof vi.fn>; recoverAfterRestart: ReturnType<typeof vi.fn> }, growthService?: { tick: ReturnType<typeof vi.fn>; recoverAfterRestart: ReturnType<typeof vi.fn> },
browsingService?: { tick: ReturnType<typeof vi.fn> },
) { ) {
const conversationAutomation = { processPendingConversations: vi.fn().mockResolvedValue([]) }; const conversationAutomation = { processPendingConversations: vi.fn().mockResolvedValue([]) };
const responseChecker = { checkForResponses: vi.fn().mockResolvedValue([]) }; const responseChecker = { checkForResponses: vi.fn().mockResolvedValue([]) };
@@ -65,13 +66,17 @@ describe('AccountRuntimeLoop mode gating', () => {
establishmentService: { start: vi.fn(), stop: vi.fn() }, establishmentService: { start: vi.fn(), stop: vi.fn() },
browserClient: { close: vi.fn().mockResolvedValue(undefined) }, browserClient: { close: vi.fn().mockResolvedValue(undefined) },
...(growthService ? { growthService } : {}), ...(growthService ? { growthService } : {}),
...(browsingService ? { browsingService } : {}),
}; };
const config = { reddit: { pollIntervalMs: 1_000 } }; const config = { reddit: { pollIntervalMs: 1_000 } };
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,
); );
return { loop, conversationAutomation, responseChecker, mediaSyncService, chatService, growthService }; return {
loop, conversationAutomation, responseChecker, mediaSyncService, chatService,
growthService, browsingService,
};
} }
/** Lets the loop complete at least one full pass before we assert. */ /** Lets the loop complete at least one full pass before we assert. */
@@ -204,4 +209,39 @@ describe('AccountRuntimeLoop mode gating', () => {
await expect(runBriefly(fixture.loop)).resolves.toBeUndefined(); await expect(runBriefly(fixture.loop)).resolves.toBeUndefined();
}); });
}); });
describe('browsing simulation', () => {
const browsingStub = () => ({ tick: vi.fn().mockResolvedValue({ kind: 'idle', reason: 'test' }) });
it.each(['running', 'scan'] as const)('runs in %s mode after growth', async (mode) => {
const growth = { tick: vi.fn().mockResolvedValue({ kind: 'idle' }), recoverAfterRestart: vi.fn() };
const browsing = browsingStub();
const fixture = loopHarness(() => mode, growth, browsing);
await runBriefly(fixture.loop);
expect(browsing.tick).toHaveBeenCalledWith(expect.anything());
expect(growth.tick.mock.invocationCallOrder[0])
.toBeLessThan(browsing.tick.mock.invocationCallOrder[0]);
});
it('does not run while stopped', async () => {
const browsing = browsingStub();
const fixture = loopHarness(() => 'stopped', undefined, browsing);
await runBriefly(fixture.loop);
expect(browsing.tick).not.toHaveBeenCalled();
});
it('isolates browsing failures from reply handling', async () => {
const browsing = { tick: vi.fn().mockRejectedValue(new Error('simulation exploded')) };
const fixture = loopHarness(() => 'running', undefined, browsing);
await runBriefly(fixture.loop);
expect(browsing.tick).toHaveBeenCalled();
expect(fixture.conversationAutomation.processPendingConversations).toHaveBeenCalled();
});
});
}); });
+26
View File
@@ -30,6 +30,32 @@ describe('RedditApiClient', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('getBrowsingComments', () => {
it('returns typed comment identity and moderation fields without changing the text reader', async () => {
const get = vi.fn().mockResolvedValue({
data: [{ data: { children: [] } }, { data: { children: [{
data: {
name: 't1_comment', author: 'alice', subreddit: 'AskUK',
permalink: '/r/AskUK/comments/post/topic/comment/', created_utc: 123,
score: 7, body: 'A useful answer with enough detail', distinguished: null,
},
}] } }],
});
vi.mocked(axios.create).mockReturnValue({ get } as unknown as AxiosInstance);
const client2 = new RedditApiClient(getTestConfig(), logger);
await expect(client2.getBrowsingComments('AskUK', 'post', 10)).resolves.toEqual([{
fullname: 't1_comment', kind: 'comment', subreddit: 'AskUK', author: 'alice',
permalink: '/r/AskUK/comments/post/topic/comment/', createdAt: 123_000,
score: 7, text: 'A useful answer with enough detail', nsfw: false,
parentPostId: 'post', distinguished: null, removed: false,
}]);
expect(get).toHaveBeenCalledWith('/r/AskUK/comments/post.json', {
params: { limit: 10, depth: 1, sort: 'hot', raw_json: 1 }, headers: {},
});
});
});
describe('hasImage', () => { describe('hasImage', () => {
const validPostFactory = (overrides: Partial<RedditPost> = {}): RedditPost => ({ const validPostFactory = (overrides: Partial<RedditPost> = {}): RedditPost => ({
id: 't3_test', id: 't3_test',
+35
View File
@@ -8,6 +8,7 @@ import {
} from '../../packages/api-contracts/src/index.js'; } from '../../packages/api-contracts/src/index.js';
import { runMigrations } from '../../src/database/client.js'; import { runMigrations } from '../../src/database/client.js';
import { AccountGrowthRepository } from '../../src/database/growth-repository.js'; import { AccountGrowthRepository } from '../../src/database/growth-repository.js';
import { AccountBrowsingSimulationRepository } from '../../src/database/browsing-simulation-repository.js';
import type { WebContext } from '../../src/web/context.js'; import type { WebContext } from '../../src/web/context.js';
import { createGrowthRouter } from '../../src/web/routes/growth.js'; import { createGrowthRouter } from '../../src/web/routes/growth.js';
import type { RedditPost } from '../../src/reddit/api.js'; import type { RedditPost } from '../../src/reddit/api.js';
@@ -63,6 +64,7 @@ function thread(id: string): RedditPost {
describe('growth route', () => { describe('growth route', () => {
let db: Database.Database; let db: Database.Database;
let repo: AccountGrowthRepository; let repo: AccountGrowthRepository;
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 }>;
@@ -77,12 +79,14 @@ describe('growth route', () => {
) VALUES (?, 'grower', ?, 'verified', 1, '[]', '{}', 0, 0) ) VALUES (?, 'grower', ?, 'verified', 1, '[]', '{}', 0, 0)
`).run(ACCOUNT, ACCOUNT); `).run(ACCOUNT, ACCOUNT);
repo = new AccountGrowthRepository(db); repo = new AccountGrowthRepository(db);
browsingRepo = new AccountBrowsingSimulationRepository(db);
pushed = []; pushed = [];
const ctx = { const ctx = {
broadcaster: { push: (level: string, message: string) => pushed.push({ level, message }) }, broadcaster: { push: (level: string, message: string) => pushed.push({ level, message }) },
services: { services: {
growth: repo, growth: repo,
browsing: browsingRepo,
redditAccounts: { redditAccounts: {
list: () => [{ accountId: ACCOUNT, redditUsername: 'grower' }], list: () => [{ accountId: ACCOUNT, redditUsername: 'grower' }],
}, },
@@ -246,6 +250,37 @@ describe('growth route', () => {
}); });
}); });
describe('PATCH /api/growth/:accountId/browsing', () => {
it('enables simulation within the conservative hard limits', async () => {
const { status, json } = await call(app, 'PATCH', `/api/growth/${ENCODED}/browsing`, {
enabled: true,
dailyTargetMin: 4,
dailyTargetMax: 8,
minimumGapMs: 3_600_000,
});
expect(status).toBe(200);
const parsed = GrowthAccountResponseSchema.parse(json);
expect(parsed.account.browsing).toMatchObject({
settings: { enabled: true, dailyTargetMin: 4, dailyTargetMax: 8 },
noVotesSubmitted: true,
});
expect(pushed).toContainEqual({
level: 'info', message: 'Browsing simulation enabled for u/grower',
});
});
it.each([
{ dailyTargetMax: 13 },
{ minimumGapMs: 44 * 60_000 },
{ postWeight: 0, commentWeight: 0 },
{ nextDueAt: 123 },
])('rejects unsafe or non-operator settings %o', async (body) => {
const { status } = await call(app, 'PATCH', `/api/growth/${ENCODED}/browsing`, body);
expect(status).toBe(400);
});
});
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`, {});