611 lines
23 KiB
TypeScript
611 lines
23 KiB
TypeScript
import type { GrowthCommentScreening, GrowthCommentThread } from '../ai/openrouter.js';
|
|
import type {
|
|
AccountGrowthRepository,
|
|
GrowthCommentRecord,
|
|
GrowthOutcomeSample,
|
|
GrowthSettingsRecord,
|
|
GrowthSubredditRecord,
|
|
} from '../database/growth-repository.js';
|
|
import {
|
|
RedditAccessDeniedError,
|
|
RedditRateLimitError,
|
|
RedditSubredditUnavailableError,
|
|
type RedditPost,
|
|
} from '../reddit/api.js';
|
|
import type { RedditCommentClient } from '../reddit/comment-client.js';
|
|
import type { Logger } from '../utils/logger.js';
|
|
import { screenGrowthComment } from './growth-comment-safety.js';
|
|
import {
|
|
assessTrust,
|
|
dailyAllowance,
|
|
evaluateGrowthWindow,
|
|
} from './growth-pacer.js';
|
|
import {
|
|
DEFAULT_GROWTH_THREAD_POLICY,
|
|
screenGrowthThread,
|
|
type GrowthThreadPolicy,
|
|
} from './growth-thread-screening.js';
|
|
|
|
const DAY_MS = 24 * 60 * 60_000;
|
|
const OUTCOME_HISTORY_MS = 7 * DAY_MS;
|
|
const LEASE_MS = 5 * 60_000;
|
|
|
|
/** Sweep offsets from the moment a comment was posted. */
|
|
const VERIFY_SCHEDULE_MS: readonly number[] = [15 * 60_000, 2 * 60 * 60_000, 24 * 60 * 60_000];
|
|
|
|
export interface GrowthAiClient {
|
|
generateThreadComment(
|
|
thread: GrowthCommentThread,
|
|
existingComments?: readonly string[],
|
|
guidance?: string,
|
|
): Promise<string>;
|
|
screenThreadComment(thread: GrowthCommentThread, comment: string): Promise<GrowthCommentScreening>;
|
|
}
|
|
|
|
export interface GrowthRedditReader {
|
|
getHotPosts(subreddit: string, limit?: number): Promise<RedditPost[]>;
|
|
getThreadTopComments(subreddit: string, postId: string, limit?: number): Promise<string[]>;
|
|
}
|
|
|
|
export interface GrowthAuthContext {
|
|
isFullyAuthenticated(): boolean;
|
|
ensureFullyAuthenticated(): Promise<boolean>;
|
|
invalidate?(reason: string): void;
|
|
}
|
|
|
|
export type GrowthActivityEvent =
|
|
| { kind: 'posted'; subreddit: string; postId: string; permalink: string | null; comment: string }
|
|
| { kind: 'rejected'; subreddit: string; postId: string; reason: string }
|
|
| { kind: 'removed'; subreddit: string; postId: string }
|
|
| { kind: 'paused'; reason: string }
|
|
| { kind: 'trust-changed'; from: number; to: number; rationale: string };
|
|
|
|
export type GrowthTickOutcome =
|
|
| { kind: 'idle'; reason: string }
|
|
| { kind: 'posted'; postId: string; subreddit: string; permalink: string | null }
|
|
| { kind: 'rejected'; postId: string; subreddit: string; reason: string }
|
|
| { kind: 'deferred'; postId: string; subreddit: string; reason: string }
|
|
| { kind: 'paused'; reason: string }
|
|
| { kind: 'verified'; checked: number; removed: number };
|
|
|
|
export interface GrowthVerificationSummary {
|
|
checked: number;
|
|
removed: number;
|
|
pausedReason: string | null;
|
|
}
|
|
|
|
export interface AccountGrowthServiceOptions {
|
|
accountId: string;
|
|
redditUsername: string;
|
|
repository: AccountGrowthRepository;
|
|
reddit: GrowthRedditReader;
|
|
commentClient: RedditCommentClient;
|
|
ai: GrowthAiClient;
|
|
auth: GrowthAuthContext;
|
|
logger: Logger;
|
|
dryRun?: boolean;
|
|
/** Minimum classifier certainty required before a comment may be posted. */
|
|
screeningMinConfidence?: number;
|
|
threadPolicy?: Partial<GrowthThreadPolicy>;
|
|
/** Whether to spend a request reading a thread's existing comments for context. */
|
|
useExistingCommentContext?: boolean;
|
|
now?: () => number;
|
|
hourOfDay?: (at: number) => number;
|
|
random?: () => number;
|
|
onActivity?: (event: GrowthActivityEvent) => void;
|
|
}
|
|
|
|
/**
|
|
* Builds one Reddit account's comment history so Reddit stops treating it as
|
|
* new.
|
|
*
|
|
* The account grower is deliberately the lowest-priority work an account does:
|
|
* it is account-scoped rather than part of the shared outreach funnel, it runs
|
|
* even while the account is held out of outbound messaging (that is the point),
|
|
* and every posting path fails closed. Nothing here retries a comment that may
|
|
* already exist, because a duplicate comment cannot be taken back.
|
|
*/
|
|
export class AccountGrowthService {
|
|
private readonly accountId: string;
|
|
private readonly repository: AccountGrowthRepository;
|
|
private readonly logger: Logger;
|
|
private readonly now: () => number;
|
|
private readonly hourOfDay: ((at: number) => number) | undefined;
|
|
private readonly random: () => number;
|
|
private readonly threadPolicy: GrowthThreadPolicy;
|
|
private readonly screeningMinConfidence: number;
|
|
private readonly workerId: string;
|
|
|
|
constructor(private readonly options: AccountGrowthServiceOptions) {
|
|
this.accountId = options.accountId;
|
|
this.repository = options.repository;
|
|
this.logger = options.logger;
|
|
this.now = options.now ?? Date.now;
|
|
this.hourOfDay = options.hourOfDay;
|
|
this.random = options.random ?? Math.random;
|
|
this.threadPolicy = { ...DEFAULT_GROWTH_THREAD_POLICY, ...options.threadPolicy };
|
|
this.screeningMinConfidence = options.screeningMinConfidence ?? 0.6;
|
|
this.workerId = `growth:${options.accountId}`;
|
|
}
|
|
|
|
/**
|
|
* Clears leases left by a process that died mid-post. Those rows are rejected
|
|
* rather than retried, so this must run before the first tick.
|
|
*/
|
|
recoverAfterRestart(): number {
|
|
const recovered = this.repository.recoverLeases(this.accountId, this.now());
|
|
if (recovered > 0) {
|
|
this.logger.warn(
|
|
`Growth: abandoned ${recovered} comment(s) interrupted mid-post for `
|
|
+ `u/${this.options.redditUsername}; they will not be retried`,
|
|
);
|
|
}
|
|
return recovered;
|
|
}
|
|
|
|
/**
|
|
* One unit of growth work.
|
|
*
|
|
* `allowPosting` is false in scan mode: verification still runs, so removals
|
|
* and shadowbans are still detected, but nothing new is published.
|
|
*/
|
|
async tick(
|
|
{ allowPosting }: { allowPosting: boolean },
|
|
signal?: AbortSignal,
|
|
): Promise<GrowthTickOutcome> {
|
|
const settings = this.repository.getSettings(this.accountId, this.now());
|
|
if (!settings.enabled) {
|
|
return { kind: 'idle', reason: 'Growth is disabled for this account' };
|
|
}
|
|
|
|
// Verification first: it is cheap, and it is what discovers that this
|
|
// account should stop commenting altogether.
|
|
const verification = await this.verifyPostedComments(signal);
|
|
if (verification?.pausedReason) {
|
|
return { kind: 'paused', reason: verification.pausedReason };
|
|
}
|
|
|
|
if (!allowPosting) {
|
|
return verification
|
|
? { kind: 'verified', checked: verification.checked, removed: verification.removed }
|
|
: { kind: 'idle', reason: 'Scan mode: growth is verifying only' };
|
|
}
|
|
|
|
const current = this.repository.getSettings(this.accountId, this.now());
|
|
if (current.pausedReason) return { kind: 'paused', reason: current.pausedReason };
|
|
|
|
if (!this.options.auth.isFullyAuthenticated()
|
|
&& !(await this.options.auth.ensureFullyAuthenticated())) {
|
|
return { kind: 'idle', reason: 'Account session is not authenticated' };
|
|
}
|
|
|
|
const now = this.now();
|
|
const outcomes = this.repository.listOutcomesSince(this.accountId, now - OUTCOME_HISTORY_MS);
|
|
const inWindow = outcomes.filter((outcome) => outcome.postedAt >= now - DAY_MS);
|
|
const decision = evaluateGrowthWindow({
|
|
settings: current,
|
|
postedLast24h: inWindow.length,
|
|
oldestPostInWindowAt: inWindow[0]?.postedAt ?? null,
|
|
lastPostedAt: this.repository.getLastPostedAt(this.accountId),
|
|
now,
|
|
hourOfDay: this.hourOfDay,
|
|
});
|
|
if (!decision.allowed) return { kind: 'idle', reason: decision.reason };
|
|
|
|
this.repository.expireStale(this.accountId, now - this.threadPolicy.maxAgeMs, now);
|
|
|
|
let claimed = this.repository.claimNext(this.accountId, this.workerId, LEASE_MS, now);
|
|
if (!claimed) {
|
|
if (this.repository.hasPendingQueueWork(this.accountId)) {
|
|
return { kind: 'idle', reason: 'Existing growth queue is waiting for its retry time' };
|
|
}
|
|
const discovery = await this.discover(signal);
|
|
if (discovery.kind === 'idle') return discovery;
|
|
claimed = this.repository.claimNext(this.accountId, this.workerId, LEASE_MS, this.now());
|
|
if (!claimed) return { kind: 'idle', reason: 'No thread is ready to comment on yet' };
|
|
}
|
|
|
|
return await this.prepareAndPost(claimed, current, signal);
|
|
}
|
|
|
|
/** Polls one eligible community and queues the threads worth commenting on. */
|
|
private async discover(signal?: AbortSignal): Promise<GrowthTickOutcome> {
|
|
const now = this.now();
|
|
const configured = this.repository.listActiveSubreddits(this.accountId, now);
|
|
if (configured.length === 0) {
|
|
return { kind: 'idle', reason: 'No subreddits are configured for this account' };
|
|
}
|
|
|
|
const withCapacity = configured.filter((entry) => (
|
|
this.repository.countPostedInSubredditSince(this.accountId, entry.subreddit, now - DAY_MS)
|
|
< entry.dailyCap
|
|
));
|
|
if (withCapacity.length === 0) {
|
|
return { kind: 'idle', reason: 'Every configured subreddit has hit its daily cap' };
|
|
}
|
|
|
|
const subreddit = this.weightedPick(withCapacity);
|
|
let posts: RedditPost[];
|
|
try {
|
|
posts = await this.options.reddit.getHotPosts(subreddit.subreddit, 25);
|
|
} catch (error) {
|
|
return this.handleDiscoveryError(error, subreddit);
|
|
}
|
|
if (signal?.aborted) return { kind: 'idle', reason: 'Shutting down' };
|
|
|
|
const eligible: RedditPost[] = [];
|
|
const rejections = new Map<string, number>();
|
|
for (const post of posts) {
|
|
const screening = screenGrowthThread(post, {
|
|
...this.threadPolicy,
|
|
allowNsfw: this.repository.getSettings(this.accountId, now).allowNsfw,
|
|
}, now);
|
|
if (screening.eligible) eligible.push(post);
|
|
else rejections.set(screening.reason, (rejections.get(screening.reason) ?? 0) + 1);
|
|
}
|
|
|
|
// Queue one thread at a time. Subreddit selection is randomized on every
|
|
// new unit of work, rather than one random community producing a long,
|
|
// predictable run of comments from a ten-thread batch.
|
|
const intake = this.repository.enqueueDiscovered(this.accountId, eligible, now, 1);
|
|
this.logger.info(
|
|
`Growth intake r/${subreddit.subreddit} for u/${this.options.redditUsername}: `
|
|
+ `${posts.length} fetched; ${eligible.length} eligible; ${intake.discovered} queued; `
|
|
+ `${intake.duplicate} already seen; ${intake.ownedByOtherAccount} held by another account`
|
|
+ (rejections.size > 0
|
|
? `; rejected — ${[...rejections].map(([reason, count]) => `${count} ${reason.toLowerCase()}`).join(', ')}`
|
|
: ''),
|
|
);
|
|
|
|
return intake.discovered > 0
|
|
? { kind: 'verified', checked: 0, removed: 0 }
|
|
: { kind: 'idle', reason: `No eligible threads in r/${subreddit.subreddit} right now` };
|
|
}
|
|
|
|
private handleDiscoveryError(error: unknown, subreddit: GrowthSubredditRecord): GrowthTickOutcome {
|
|
const now = this.now();
|
|
if (error instanceof RedditSubredditUnavailableError) {
|
|
this.repository.disableSubreddit(
|
|
this.accountId,
|
|
subreddit.subreddit,
|
|
'Reddit reports this community is unavailable',
|
|
now,
|
|
);
|
|
return { kind: 'idle', reason: `Disabled r/${subreddit.subreddit}: Reddit returned HTTP 404` };
|
|
}
|
|
if (error instanceof RedditAccessDeniedError) {
|
|
this.repository.cooldownSubreddit(this.accountId, subreddit.subreddit, now + 6 * 60 * 60_000, now);
|
|
return { kind: 'idle', reason: `r/${subreddit.subreddit} denied access; cooling it down for 6h` };
|
|
}
|
|
if (error instanceof RedditRateLimitError) {
|
|
return { kind: 'idle', reason: `Reddit rate limit; retrying in ${Math.ceil(error.retryAfterMs / 1_000)}s` };
|
|
}
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
this.logger.warn(`Growth discovery failed for r/${subreddit.subreddit}: ${message}`);
|
|
return { kind: 'idle', reason: `Could not read r/${subreddit.subreddit}: ${message}` };
|
|
}
|
|
|
|
/**
|
|
* Generates, screens and posts a comment for one claimed thread.
|
|
*
|
|
* Both screening stages fail closed: a deterministic refusal rejects the
|
|
* candidate outright, and an unavailable classifier defers it rather than
|
|
* letting unreviewed text reach Reddit.
|
|
*/
|
|
private async prepareAndPost(
|
|
claimed: GrowthCommentRecord,
|
|
settings: GrowthSettingsRecord,
|
|
signal?: AbortSignal,
|
|
): Promise<GrowthTickOutcome> {
|
|
const { postId, subreddit } = claimed;
|
|
const thread: GrowthCommentThread = {
|
|
subreddit: claimed.subreddit,
|
|
title: claimed.threadTitle,
|
|
selftext: claimed.post.selftext ?? '',
|
|
flair: claimed.post.link_flair_text ?? null,
|
|
};
|
|
|
|
const cap = this.repository.listSubreddits(this.accountId)
|
|
.find((entry) => entry.subreddit.toLowerCase() === subreddit.toLowerCase());
|
|
if (cap) {
|
|
const postedHere = this.repository.countPostedInSubredditSince(
|
|
this.accountId,
|
|
subreddit,
|
|
this.now() - DAY_MS,
|
|
);
|
|
if (postedHere >= cap.dailyCap) {
|
|
return this.deferred(claimed, `Daily cap for r/${subreddit} is already used`, this.now() + DAY_MS);
|
|
}
|
|
}
|
|
|
|
let existingComments: readonly string[] = [];
|
|
if (this.options.useExistingCommentContext) {
|
|
try {
|
|
existingComments = await this.options.reddit.getThreadTopComments(subreddit, postId, 8);
|
|
} catch {
|
|
existingComments = [];
|
|
}
|
|
}
|
|
if (signal?.aborted) return this.deferred(claimed, 'Shutting down', this.now() + 60_000);
|
|
|
|
let comment: string;
|
|
try {
|
|
comment = await this.options.ai.generateThreadComment(thread, existingComments);
|
|
} catch (error) {
|
|
const reason = `Comment generation failed: ${errorMessage(error)}`;
|
|
return this.deferred(claimed, reason, this.now() + 15 * 60_000);
|
|
}
|
|
|
|
const gate = screenGrowthComment(comment);
|
|
if (!gate.safe) {
|
|
this.logger.info(`Growth: discarded a generated comment for ${postId} — ${gate.reason}`);
|
|
return this.rejected(claimed, gate.reason, comment);
|
|
}
|
|
|
|
let screening: GrowthCommentScreening;
|
|
try {
|
|
screening = await this.options.ai.screenThreadComment(thread, comment);
|
|
} catch (error) {
|
|
// Fail closed. Nothing reviews these comments before they go public, so an
|
|
// unavailable classifier has to stall growth rather than wave text through.
|
|
return this.deferred(
|
|
claimed,
|
|
`Comment screening unavailable: ${errorMessage(error)}`,
|
|
this.now() + 10 * 60_000,
|
|
);
|
|
}
|
|
if (!screening.approved || screening.confidence < this.screeningMinConfidence) {
|
|
const reason = screening.approved
|
|
? `Screening confidence ${screening.confidence.toFixed(2)} below ${this.screeningMinConfidence}`
|
|
: `Screening rejected the comment: ${screening.reason}`;
|
|
return this.rejected(claimed, reason, comment);
|
|
}
|
|
|
|
if (this.options.dryRun) {
|
|
return this.rejected(claimed, 'Dry run: comment prepared but not posted', comment);
|
|
}
|
|
|
|
const threadFullname = claimed.post.name && /^t3_/i.test(claimed.post.name)
|
|
? claimed.post.name
|
|
: `t3_${postId}`;
|
|
const result = await this.options.commentClient.postComment(threadFullname, subreddit, comment);
|
|
const now = this.now();
|
|
|
|
switch (result.status) {
|
|
case 'posted': {
|
|
const recorded = this.repository.markPosted(this.accountId, postId, this.workerId, {
|
|
generatedComment: comment,
|
|
modelId: 'chat',
|
|
commentFullname: result.fullname,
|
|
commentPermalink: result.permalink,
|
|
nextVerifyAt: now + VERIFY_SCHEDULE_MS[0],
|
|
}, now);
|
|
if (!recorded) {
|
|
// The comment is live but the lease moved on. Surface loudly: the
|
|
// audit trail is now incomplete and only a human can reconcile it.
|
|
this.logger.error(
|
|
`Growth: posted comment ${result.fullname} to r/${subreddit} but could not record it `
|
|
+ `against ${postId} — the lease was no longer held`,
|
|
);
|
|
}
|
|
this.logger.info(`Growth: commented on r/${subreddit} (${postId}) as u/${this.options.redditUsername}`);
|
|
this.options.onActivity?.({
|
|
kind: 'posted',
|
|
subreddit,
|
|
postId,
|
|
permalink: result.permalink,
|
|
comment,
|
|
});
|
|
return { kind: 'posted', postId, subreddit, permalink: result.permalink };
|
|
}
|
|
|
|
case 'rate-limited': {
|
|
if (result.scope === 'subreddit') {
|
|
this.repository.cooldownSubreddit(this.accountId, subreddit, now + result.retryAfterMs, now);
|
|
}
|
|
return this.deferred(claimed, result.reason, now + result.retryAfterMs);
|
|
}
|
|
|
|
case 'thread-closed':
|
|
return this.rejected(claimed, result.reason, comment);
|
|
|
|
case 'not-allowed':
|
|
this.repository.disableSubreddit(this.accountId, subreddit, result.reason, now);
|
|
return this.rejected(claimed, result.reason, comment);
|
|
|
|
case 'session-invalid':
|
|
this.options.auth.invalidate?.(`Reddit rejected a comment as signed out: ${result.reason}`);
|
|
this.options.commentClient.invalidateSession();
|
|
return this.deferred(claimed, result.reason, now + 5 * 60_000);
|
|
|
|
case 'blocked': {
|
|
this.repository.defer(this.accountId, postId, this.workerId, result.reason, now + DAY_MS, now);
|
|
this.repository.pause(this.accountId, result.reason, now);
|
|
this.logger.error(`Growth paused for u/${this.options.redditUsername}: ${result.reason}`);
|
|
this.options.onActivity?.({ kind: 'paused', reason: result.reason });
|
|
return { kind: 'paused', reason: result.reason };
|
|
}
|
|
|
|
default:
|
|
return this.deferred(claimed, result.reason, now + 10 * 60_000);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks what happened to comments already posted, then re-reads this
|
|
* account's standing from the result.
|
|
*/
|
|
async verifyPostedComments(signal?: AbortSignal): Promise<GrowthVerificationSummary | null> {
|
|
const now = this.now();
|
|
const due = this.repository.listDueForVerification(this.accountId, 100, now);
|
|
if (due.length === 0) return null;
|
|
|
|
const fullnames = due
|
|
.map((record) => record.commentFullname)
|
|
.filter((name): name is string => name !== null);
|
|
if (fullnames.length === 0) return null;
|
|
|
|
let states;
|
|
try {
|
|
states = await this.options.commentClient.fetchCommentStates(fullnames);
|
|
} catch (error) {
|
|
this.logger.warn(`Growth verification sweep failed: ${errorMessage(error)}`);
|
|
return null;
|
|
}
|
|
if (signal?.aborted) return null;
|
|
const byFullname = new Map(states.map((state) => [state.fullname, state]));
|
|
|
|
// The public-visibility probe is the shadowban test. It costs one signed-out
|
|
// request, so it runs once per comment, on the second sweep, by which point
|
|
// an automated filter will have acted.
|
|
const needsPublicCheck = due.filter((record) => record.verifyAttempts === 1);
|
|
let publiclyVisible: Set<string> | null = null;
|
|
if (needsPublicCheck.length > 0) {
|
|
try {
|
|
publiclyVisible = await this.options.commentClient.fetchPubliclyVisible(
|
|
needsPublicCheck
|
|
.map((record) => record.commentFullname)
|
|
.filter((name): name is string => name !== null),
|
|
);
|
|
} catch (error) {
|
|
// A failed probe proves nothing; never pause an account on a network blip.
|
|
this.logger.debug(`Growth public-visibility probe failed: ${errorMessage(error)}`);
|
|
publiclyVisible = null;
|
|
}
|
|
}
|
|
|
|
let removed = 0;
|
|
for (const record of due) {
|
|
const state = record.commentFullname ? byFullname.get(record.commentFullname) : undefined;
|
|
if (!state) continue;
|
|
const isRemoved = state.removed || state.missing;
|
|
if (isRemoved) removed += 1;
|
|
|
|
const shadowBanned = publiclyVisible !== null
|
|
&& record.commentFullname !== null
|
|
&& record.verifyAttempts === 1
|
|
&& !isRemoved
|
|
? !publiclyVisible.has(record.commentFullname)
|
|
: null;
|
|
|
|
this.repository.recordVerification(this.accountId, record.postId, {
|
|
score: state.score,
|
|
removed: isRemoved,
|
|
shadowBanned,
|
|
nextVerifyAt: isRemoved ? null : this.nextVerifyAt(record),
|
|
}, now);
|
|
|
|
if (isRemoved) {
|
|
this.logger.warn(`Growth: comment on r/${record.subreddit} (${record.postId}) was removed`);
|
|
this.options.onActivity?.({ kind: 'removed', subreddit: record.subreddit, postId: record.postId });
|
|
}
|
|
}
|
|
|
|
const pausedReason = this.applyTrustAssessment();
|
|
return { checked: due.length, removed, pausedReason };
|
|
}
|
|
|
|
/** Re-derives the trust level and pause state from recent evidence. */
|
|
private applyTrustAssessment(): string | null {
|
|
const now = this.now();
|
|
const settings = this.repository.getSettings(this.accountId, now);
|
|
const outcomes: GrowthOutcomeSample[] = this.repository.listOutcomesSince(
|
|
this.accountId,
|
|
now - OUTCOME_HISTORY_MS,
|
|
);
|
|
const assessment = assessTrust(settings, outcomes, now);
|
|
|
|
if (assessment.consecutiveRemovals !== settings.consecutiveRemovals) {
|
|
this.repository.setConsecutiveRemovals(this.accountId, assessment.consecutiveRemovals, now);
|
|
}
|
|
|
|
if (assessment.pauseReason) {
|
|
if (!settings.pausedReason) {
|
|
this.repository.pause(this.accountId, assessment.pauseReason, now);
|
|
this.logger.error(
|
|
`Growth paused for u/${this.options.redditUsername}: ${assessment.pauseReason}`,
|
|
);
|
|
this.options.onActivity?.({ kind: 'paused', reason: assessment.pauseReason });
|
|
}
|
|
return assessment.pauseReason;
|
|
}
|
|
|
|
if (assessment.levelChanged) {
|
|
this.repository.setTrustLevel(this.accountId, assessment.trustLevel, now);
|
|
this.logger.info(
|
|
`Growth allowance for u/${this.options.redditUsername}: level ${settings.trustLevel} → `
|
|
+ `${assessment.trustLevel} (${dailyAllowance(assessment.trustLevel)}/day) — ${assessment.rationale}`,
|
|
);
|
|
this.options.onActivity?.({
|
|
kind: 'trust-changed',
|
|
from: settings.trustLevel,
|
|
to: assessment.trustLevel,
|
|
rationale: assessment.rationale,
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private nextVerifyAt(record: GrowthCommentRecord): number | null {
|
|
const postedAt = record.postedAt ?? this.now();
|
|
const nextIndex = record.verifyAttempts + 1;
|
|
const offset = VERIFY_SCHEDULE_MS[nextIndex];
|
|
return offset === undefined ? null : postedAt + offset;
|
|
}
|
|
|
|
private rejected(
|
|
claimed: GrowthCommentRecord,
|
|
reason: string,
|
|
comment: string | null,
|
|
): GrowthTickOutcome {
|
|
this.repository.reject(this.accountId, claimed.postId, this.workerId, reason, comment, this.now());
|
|
this.options.onActivity?.({
|
|
kind: 'rejected',
|
|
subreddit: claimed.subreddit,
|
|
postId: claimed.postId,
|
|
reason,
|
|
});
|
|
return { kind: 'rejected', postId: claimed.postId, subreddit: claimed.subreddit, reason };
|
|
}
|
|
|
|
private deferred(
|
|
claimed: GrowthCommentRecord,
|
|
reason: string,
|
|
availableAt: number,
|
|
): GrowthTickOutcome {
|
|
this.repository.defer(
|
|
this.accountId,
|
|
claimed.postId,
|
|
this.workerId,
|
|
reason,
|
|
availableAt,
|
|
this.now(),
|
|
);
|
|
return { kind: 'deferred', postId: claimed.postId, subreddit: claimed.subreddit, reason };
|
|
}
|
|
|
|
/**
|
|
* Weighted random choice (Efraimidis-Spirakis), matching how the outreach
|
|
* monitor orders subreddits: heavier entries come up more often but every
|
|
* enabled community keeps a real chance of being picked.
|
|
*/
|
|
private weightedPick(entries: readonly GrowthSubredditRecord[]): GrowthSubredditRecord {
|
|
let best = entries[0];
|
|
let bestKey = Number.NEGATIVE_INFINITY;
|
|
for (const entry of entries) {
|
|
const weight = entry.weight > 0 ? entry.weight : 1;
|
|
const roll = this.random();
|
|
const key = roll <= 0 ? Number.NEGATIVE_INFINITY : Math.log(roll) / weight;
|
|
if (key > bestKey) {
|
|
bestKey = key;
|
|
best = entry;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
}
|
|
|
|
function errorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|