Enable automatic browsing upvotes

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

No files matched your search

+33 -7
View File
@@ -17,6 +17,10 @@ export interface BrowsingDecisionSink {
recordSimulation(decision: BrowsingDecisionRecord): Promise<void>;
}
export interface BrowsingVoter {
upvote(thingFullname: string): Promise<{ status: 'submitted' | 'rate-limited' | 'session-invalid' | 'blocked' | 'failed'; reason?: string }>;
}
export interface AccountBrowsingSimulationServiceOptions {
accountId: string;
repository: AccountBrowsingSimulationRepository;
@@ -26,6 +30,7 @@ export interface AccountBrowsingSimulationServiceOptions {
getBrowsingComments(subreddit: string, postId: string, limit: number): Promise<BrowsingThing[]>;
};
sink: BrowsingDecisionSink;
voter?: BrowsingVoter;
logger: Logger;
now?: () => number;
random?: () => number;
@@ -50,19 +55,27 @@ export class AccountBrowsingSimulationService {
this.random = options.random ?? Math.random;
}
async tick(signal?: AbortSignal): Promise<BrowsingSimulationOutcome> {
/** Runs one operator-requested, simulation-only selection without waiting for the schedule. */
runNow(signal?: AbortSignal): Promise<BrowsingSimulationOutcome> {
return this.tick({ force: true }, signal);
}
async tick(
{ allowVoting = false, force = false }: { allowVoting?: boolean; force?: boolean } = {},
signal?: AbortSignal,
): Promise<BrowsingSimulationOutcome> {
if (signal?.aborted) throw new DOMException('Operation aborted', 'AbortError');
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) {
if (settings.nextDueAt === null && !force) {
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)) {
if (!force && now < settings.nextDueAt) return { kind: 'idle', reason: 'Browsing simulation is not due' };
if (!force && isQuietTime(new Date(now), settings)) {
const nextDueAt = moveOutsideQuietHours(now, settings);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
return { kind: 'scheduled', nextDueAt };
@@ -70,7 +83,7 @@ export class AccountBrowsingSimulationService {
const today = startOfLocalDay(now);
const target = dailyTarget(this.options.accountId, today, settings);
if (this.options.repository.countSelectedSince(this.options.accountId, today) >= target) {
if (!force && this.options.repository.countSelectedSince(this.options.accountId, today) >= target) {
const nextDueAt = moveOutsideQuietHours(today + 24 * 60 * 60_000, settings);
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
return { kind: 'scheduled', nextDueAt };
@@ -103,10 +116,22 @@ export class AccountBrowsingSimulationService {
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);
const decision = toDecision(this.options.accountId, selected, 'simulated-upvote', 'Eligible browsing selection', sessionId, now);
await this.options.sink.recordSimulation(decision);
if (settings.autoVoteEnabled && allowVoting && this.options.voter) {
const result = await this.options.voter.upvote(selected.fullname);
if (result.status === 'submitted') {
this.options.repository.markVoteSubmitted(this.options.accountId, selected.fullname);
decision.voteSubmitted = true;
decision.reason = 'Reddit upvote submitted';
this.options.logger.info(`Submitted browsing upvote for ${selected.fullname} in r/${selected.subreddit}`);
} else {
decision.reason = `Reddit upvote not submitted: ${result.reason ?? result.status}`;
this.options.logger.warn(`Browsing upvote for ${selected.fullname} was not submitted: ${result.reason ?? result.status}`);
}
}
this.options.onActivity?.(decision);
this.options.logger.info(`Simulated browsing selection for ${selected.fullname} in r/${selected.subreddit}; no Reddit vote was submitted`);
if (!decision.voteSubmitted) this.options.logger.info(`Simulated browsing selection for ${selected.fullname} in r/${selected.subreddit}; no Reddit vote was submitted`);
return { kind: 'simulated', decision, nextDueAt };
}
@@ -278,5 +303,6 @@ function toDecision(
discoveredAt: now,
decidedAt: now,
scheduledSessionId: sessionId,
voteSubmitted: false,
};
}
@@ -298,11 +298,14 @@ async function buildRuntime(
redditApi.getBrowsingComments(subreddit, postId, limit),
},
sink: browsingRepository,
voter: commentClient,
logger,
onActivity: (decision) => activityLogRepo.insert({
timestamp: decision.decidedAt,
type: 'response',
message: `Simulated ${decision.thingKind} upvote in r/${decision.subreddit}; no Reddit vote was submitted`,
message: decision.voteSubmitted
? `Submitted ${decision.thingKind} upvote in r/${decision.subreddit}`
: `Simulated ${decision.thingKind} upvote in r/${decision.subreddit}; no Reddit vote was submitted`,
postUrl: `https://www.reddit.com${decision.permalink}`,
postTitle: `r/${decision.subreddit}`,
}),
@@ -502,12 +505,14 @@ export class AccountRuntimeLoop {
this.logger.error('Account growth tick failed:', error);
}
}
// Browsing simulation is strictly below comment growth. It records a
// hypothetical decision only and runs in both scan and running modes.
// Browsing selection is strictly below comment growth. It runs in scan
// and running modes, but submits votes only while fully running and not dry-run.
if (this.resources.browsingService && Date.now() >= nextBrowsingCheckAt) {
nextBrowsingCheckAt = Date.now() + growthInterval;
try {
await this.resources.browsingService.tick(signal);
await this.resources.browsingService.tick(
{ allowVoting: allowsSending(mode) && !this.config.browser.dryRun }, signal,
);
} catch (error) {
if (signal.aborted) return;
this.logger.error('Browsing simulation tick failed:', error);