Files
RedditBot/src/database/growth-repository.ts
T

863 lines
31 KiB
TypeScript

import type Database from 'better-sqlite3';
import type { RedditPost } from '../reddit/api.js';
export type GrowthCommentStatus =
| 'discovered'
| 'posting'
| 'posted'
| 'verified'
| 'rejected'
| 'retryable';
/**
* Hours during which an account never comments. Expressed in the host's local
* time because the operator reasons about "overnight", not about UTC.
*/
export interface GrowthQuietHours {
enabled: boolean;
startHour: number;
endHour: number;
}
export interface GrowthSettingsRecord {
accountId: string;
enabled: boolean;
/** Rung on the adaptive allowance ladder. Higher means more comments per day. */
trustLevel: number;
/**
* Operator override for comments per day. Null leaves the allowance to the
* adaptive ladder. A value is enforced verbatim — the ladder keeps tracking
* the account underneath, and the removal/shadowban pause still applies.
*/
dailyLimit: number | null;
levelChangedAt: number | null;
consecutiveRemovals: number;
/** Non-null means growth is hard-paused until an operator clears it. */
pausedReason: string | null;
pausedAt: number | null;
minDelayMs: number;
jitterMs: number;
quietHours: GrowthQuietHours;
allowNsfw: boolean;
updatedAt: number;
}
export interface GrowthSettingsPatch {
enabled?: boolean;
/** Pass null to hand the allowance back to the adaptive ladder. */
dailyLimit?: number | null;
minDelayMs?: number;
jitterMs?: number;
quietHours?: GrowthQuietHours;
allowNsfw?: boolean;
}
export interface GrowthSubredditRecord {
accountId: string;
subreddit: string;
enabled: boolean;
weight: number;
dailyCap: number;
disabledReason: string | null;
cooldownUntil: number | null;
addedAt: number;
updatedAt: number;
}
export interface GrowthCommentRecord {
accountId: string;
postId: string;
subreddit: string;
status: GrowthCommentStatus;
threadTitle: string;
threadUrl: string;
threadCreatedAt: number;
threadNumComments: number;
post: RedditPost;
generatedComment: string | null;
modelId: string | null;
commentFullname: string | null;
commentPermalink: string | null;
firstScore: number | null;
lastScore: number | null;
removedAt: number | null;
shadowCheckedAt: number | null;
shadowBanned: boolean | null;
verifyAttempts: number;
nextVerifyAt: number | null;
discoveredAt: number;
postedAt: number | null;
updatedAt: number;
availableAt: number;
leaseOwner: string | null;
leaseExpiresAt: number | null;
attemptCount: number;
lastError: string | null;
}
export interface GrowthEnqueueResult {
discovered: number;
/** Already queued or posted by this account. */
duplicate: number;
/** Claimed by a different account, so this one must leave it alone. */
ownedByOtherAccount: number;
}
export interface GrowthPostedOutcome {
generatedComment: string;
modelId: string;
commentFullname: string;
commentPermalink: string | null;
nextVerifyAt: number;
}
export interface GrowthVerificationInput {
score: number | null;
removed: boolean;
shadowBanned?: boolean | null;
nextVerifyAt: number | null;
}
/** One posted comment reduced to the evidence the adaptive pacer needs. */
export interface GrowthOutcomeSample {
postId: string;
subreddit: string;
postedAt: number;
status: GrowthCommentStatus;
lastScore: number | null;
removed: boolean;
shadowBanned: boolean | null;
}
export interface GrowthStats {
byStatus: Record<GrowthCommentStatus, number>;
postedLast24h: number;
postedTotal: number;
removedTotal: number;
medianScore: number | null;
}
const ALL_STATUSES: GrowthCommentStatus[] = [
'discovered', 'posting', 'posted', 'verified', 'rejected', 'retryable',
];
const DEFAULT_QUIET_HOURS: GrowthQuietHours = { enabled: true, startHour: 0, endHour: 7 };
/**
* Per-account growth state: whether commenting is on, which communities it may
* comment in, and the durable record of every comment it prepared or posted.
*
* Unlike the outreach candidate funnel this is deliberately account-scoped —
* karma, comment history and trust level all belong to one identity — so the
* only cross-account rule enforced here is that two accounts never comment on
* the same thread.
*/
export class AccountGrowthRepository {
constructor(private readonly db: Database.Database) {}
// ---------- settings ----------
/** Returns defaults for an account that has never been configured. */
getSettings(accountId: string, now: number = Date.now()): GrowthSettingsRecord {
const row = this.db.prepare('SELECT * FROM account_growth_settings WHERE account_id = ?')
.get(accountId) as Record<string, unknown> | undefined;
if (!row) {
return {
accountId,
enabled: false,
trustLevel: 0,
dailyLimit: null,
levelChangedAt: null,
consecutiveRemovals: 0,
pausedReason: null,
pausedAt: null,
minDelayMs: 3_600_000,
jitterMs: 900_000,
quietHours: { ...DEFAULT_QUIET_HOURS },
allowNsfw: false,
updatedAt: now,
};
}
return mapSettings(row);
}
saveSettings(
accountId: string,
patch: GrowthSettingsPatch,
now: number = Date.now(),
): GrowthSettingsRecord {
requireText(accountId, 'accountId');
if (patch.minDelayMs !== undefined && (!Number.isInteger(patch.minDelayMs) || patch.minDelayMs < 0)) {
throw new Error('Growth minimum delay must be a non-negative integer');
}
if (patch.jitterMs !== undefined && (!Number.isInteger(patch.jitterMs) || patch.jitterMs < 0)) {
throw new Error('Growth jitter must be a non-negative integer');
}
if (patch.dailyLimit !== undefined && patch.dailyLimit !== null
&& (!Number.isInteger(patch.dailyLimit) || patch.dailyLimit < 1 || patch.dailyLimit > 50)) {
throw new Error('Daily comment limit must be a whole number between 1 and 50');
}
if (patch.quietHours) validateQuietHours(patch.quietHours);
const current = this.getSettings(accountId, now);
const merged: GrowthSettingsRecord = {
...current,
...patch,
quietHours: patch.quietHours ?? current.quietHours,
// `null` is a meaningful value here — it hands the allowance back to the
// adaptive ladder — so only an explicitly present key may change it.
dailyLimit: 'dailyLimit' in patch ? patch.dailyLimit ?? null : current.dailyLimit,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO account_growth_settings (
account_id, enabled, trust_level, daily_limit, level_changed_at, consecutive_removals,
paused_reason, paused_at, min_delay_ms, jitter_ms, quiet_hours_json,
allow_nsfw, updated_at
) VALUES (
@accountId, @enabled, @trustLevel, @dailyLimit, @levelChangedAt, @consecutiveRemovals,
@pausedReason, @pausedAt, @minDelayMs, @jitterMs, @quietHoursJson,
@allowNsfw, @updatedAt
)
ON CONFLICT(account_id) DO UPDATE SET
enabled = excluded.enabled,
daily_limit = excluded.daily_limit,
min_delay_ms = excluded.min_delay_ms,
jitter_ms = excluded.jitter_ms,
quiet_hours_json = excluded.quiet_hours_json,
allow_nsfw = excluded.allow_nsfw,
updated_at = excluded.updated_at
`).run({
accountId,
enabled: merged.enabled ? 1 : 0,
trustLevel: merged.trustLevel,
dailyLimit: merged.dailyLimit,
levelChangedAt: merged.levelChangedAt,
consecutiveRemovals: merged.consecutiveRemovals,
pausedReason: merged.pausedReason,
pausedAt: merged.pausedAt,
minDelayMs: merged.minDelayMs,
jitterMs: merged.jitterMs,
quietHoursJson: JSON.stringify(merged.quietHours),
allowNsfw: merged.allowNsfw ? 1 : 0,
updatedAt: now,
});
return this.getSettings(accountId, now);
}
setTrustLevel(accountId: string, trustLevel: number, now: number = Date.now()): GrowthSettingsRecord {
if (!Number.isInteger(trustLevel) || trustLevel < 0) {
throw new Error('Growth trust level must be a non-negative integer');
}
this.ensureSettingsRow(accountId, now);
this.db.prepare(`
UPDATE account_growth_settings
SET trust_level = ?, level_changed_at = ?, updated_at = ?
WHERE account_id = ?
`).run(trustLevel, now, now, accountId);
return this.getSettings(accountId, now);
}
/**
* Hard-pauses growth for this account. Deliberately requires an operator to
* clear it: every path that sets this has seen evidence that continuing would
* damage the account.
*/
pause(accountId: string, reason: string, now: number = Date.now()): GrowthSettingsRecord {
requireText(reason, 'reason');
this.ensureSettingsRow(accountId, now);
this.db.prepare(`
UPDATE account_growth_settings
SET paused_reason = ?, paused_at = ?, updated_at = ?
WHERE account_id = ?
`).run(reason, now, now, accountId);
return this.getSettings(accountId, now);
}
resume(accountId: string, now: number = Date.now()): GrowthSettingsRecord {
this.ensureSettingsRow(accountId, now);
this.db.prepare(`
UPDATE account_growth_settings
SET paused_reason = NULL, paused_at = NULL, consecutive_removals = 0, updated_at = ?
WHERE account_id = ?
`).run(now, accountId);
return this.getSettings(accountId, now);
}
setConsecutiveRemovals(accountId: string, count: number, now: number = Date.now()): void {
if (!Number.isInteger(count) || count < 0) {
throw new Error('Consecutive removal count must be a non-negative integer');
}
this.ensureSettingsRow(accountId, now);
this.db.prepare(`
UPDATE account_growth_settings SET consecutive_removals = ?, updated_at = ? WHERE account_id = ?
`).run(count, now, accountId);
}
listEnabledAccountIds(): string[] {
const rows = this.db.prepare(`
SELECT account_id FROM account_growth_settings
WHERE enabled = 1 AND paused_reason IS NULL
ORDER BY account_id
`).all() as Array<{ account_id: string }>;
return rows.map((row) => row.account_id);
}
// ---------- subreddits ----------
listSubreddits(accountId: string): GrowthSubredditRecord[] {
const rows = this.db.prepare(`
SELECT * FROM account_growth_subreddits WHERE account_id = ?
ORDER BY subreddit COLLATE NOCASE
`).all(accountId) as Array<Record<string, unknown>>;
return rows.map(mapSubreddit);
}
/** Subreddits eligible to be polled right now. */
listActiveSubreddits(accountId: string, now: number = Date.now()): GrowthSubredditRecord[] {
return this.listSubreddits(accountId).filter((entry) => (
entry.enabled && (entry.cooldownUntil === null || entry.cooldownUntil <= now)
));
}
addSubreddit(
accountId: string,
subreddit: string,
options: { weight?: number; dailyCap?: number } = {},
now: number = Date.now(),
): GrowthSubredditRecord {
requireText(accountId, 'accountId');
const name = normalizeSubreddit(subreddit);
const weight = options.weight ?? 1;
const dailyCap = options.dailyCap ?? 2;
if (!Number.isFinite(weight) || weight <= 0) throw new Error('Subreddit weight must be positive');
if (!Number.isInteger(dailyCap) || dailyCap < 1) throw new Error('Subreddit daily cap must be a positive integer');
this.db.prepare(`
INSERT INTO account_growth_subreddits (
account_id, subreddit, enabled, weight, daily_cap, added_at, updated_at
) VALUES (?, ?, 1, ?, ?, ?, ?)
ON CONFLICT(account_id, subreddit) DO UPDATE SET
weight = excluded.weight,
daily_cap = excluded.daily_cap,
updated_at = excluded.updated_at
`).run(accountId, name, weight, dailyCap, now, now);
const record = this.listSubreddits(accountId)
.find((entry) => entry.subreddit.toLowerCase() === name.toLowerCase());
if (!record) throw new Error(`Growth subreddit ${name} was not persisted`);
return record;
}
setSubredditEnabled(
accountId: string,
subreddit: string,
enabled: boolean,
now: number = Date.now(),
): boolean {
return this.db.prepare(`
UPDATE account_growth_subreddits
SET enabled = ?, disabled_reason = CASE WHEN ? = 1 THEN NULL ELSE disabled_reason END,
updated_at = ?
WHERE account_id = ? AND subreddit = ? COLLATE NOCASE
`).run(enabled ? 1 : 0, enabled ? 1 : 0, now, accountId, normalizeSubreddit(subreddit)).changes === 1;
}
/** Reddit refused this account in this community; stop polling it entirely. */
disableSubreddit(
accountId: string,
subreddit: string,
reason: string,
now: number = Date.now(),
): boolean {
requireText(reason, 'reason');
return this.db.prepare(`
UPDATE account_growth_subreddits
SET enabled = 0, disabled_reason = ?, updated_at = ?
WHERE account_id = ? AND subreddit = ? COLLATE NOCASE
`).run(reason, now, accountId, normalizeSubreddit(subreddit)).changes === 1;
}
cooldownSubreddit(
accountId: string,
subreddit: string,
until: number,
now: number = Date.now(),
): boolean {
return this.db.prepare(`
UPDATE account_growth_subreddits
SET cooldown_until = MAX(COALESCE(cooldown_until, 0), ?), updated_at = ?
WHERE account_id = ? AND subreddit = ? COLLATE NOCASE
`).run(until, now, accountId, normalizeSubreddit(subreddit)).changes === 1;
}
removeSubreddit(accountId: string, subreddit: string): boolean {
return this.db.prepare(`
DELETE FROM account_growth_subreddits
WHERE account_id = ? AND subreddit = ? COLLATE NOCASE
`).run(accountId, normalizeSubreddit(subreddit)).changes === 1;
}
// ---------- comment queue ----------
enqueueDiscovered(
accountId: string,
posts: readonly RedditPost[],
now: number = Date.now(),
maxNew: number = Number.POSITIVE_INFINITY,
): GrowthEnqueueResult {
requireText(accountId, 'accountId');
const result: GrowthEnqueueResult = { discovered: 0, duplicate: 0, ownedByOtherAccount: 0 };
const existing = this.db.prepare(
'SELECT account_id FROM account_growth_comments WHERE post_id = ?',
);
const insert = this.db.prepare(`
INSERT INTO account_growth_comments (
account_id, post_id, subreddit, status, thread_title, thread_url,
thread_created_at, thread_num_comments, payload_json,
discovered_at, updated_at, available_at
) VALUES (?, ?, ?, 'discovered', ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.db.transaction(() => {
for (const post of posts) {
if (result.discovered >= maxNew) break;
const owner = existing.get(post.id) as { account_id: string } | undefined;
if (owner) {
if (owner.account_id === accountId) result.duplicate += 1;
else result.ownedByOtherAccount += 1;
continue;
}
insert.run(
accountId,
post.id,
post.subreddit,
post.title,
`https://www.reddit.com${post.permalink}`,
Math.round(post.created_utc * 1_000),
numComments(post),
JSON.stringify(post),
now,
now,
now,
);
result.discovered += 1;
}
})();
return result;
}
/**
* Leases the next thread this account should comment on. Generation and
* posting share one lease because an auto-posting run has no operator pause
* between them.
*/
claimNext(
accountId: string,
workerId: string,
leaseMs: number,
now: number = Date.now(),
): GrowthCommentRecord | null {
requireText(workerId, 'workerId');
if (!Number.isFinite(leaseMs) || leaseMs <= 0) throw new Error('Growth lease must be positive');
return this.db.transaction(() => {
const row = this.db.prepare(`
SELECT post_id FROM account_growth_comments
WHERE account_id = ? AND status IN ('discovered', 'retryable') AND available_at <= ?
ORDER BY
CASE status WHEN 'retryable' THEN 0 ELSE 1 END,
CASE status WHEN 'retryable' THEN available_at END ASC,
thread_created_at DESC,
discovered_at ASC
LIMIT 1
`).get(accountId, now) as { post_id: string } | undefined;
if (!row) return null;
const changed = this.db.prepare(`
UPDATE account_growth_comments
SET status = 'posting', lease_owner = ?, lease_expires_at = ?,
updated_at = ?, attempt_count = attempt_count + 1
WHERE account_id = ? AND post_id = ? AND status IN ('discovered', 'retryable')
`).run(workerId, now + leaseMs, now, accountId, row.post_id).changes;
return changed === 1 ? this.get(accountId, row.post_id) : null;
})();
}
/**
* Includes retries whose cooldown is still active. Discovery uses this to
* avoid growing the backlog while existing work is waiting to recover.
*/
hasPendingQueueWork(accountId: string): boolean {
return this.db.prepare(`
SELECT 1 FROM account_growth_comments
WHERE account_id = ? AND status IN ('discovered', 'retryable')
LIMIT 1
`).get(accountId) !== undefined;
}
/**
* Clears only work that has not reached Reddit. A posting lease is excluded
* because its remote outcome may be uncertain, and posted audit history is
* never touched.
*/
clearPendingQueue(accountId: string): number {
return this.db.prepare(`
DELETE FROM account_growth_comments
WHERE account_id = ? AND status IN ('discovered', 'retryable')
`).run(accountId).changes;
}
markPosted(
accountId: string,
postId: string,
workerId: string,
outcome: GrowthPostedOutcome,
now: number = Date.now(),
): boolean {
return this.db.prepare(`
UPDATE account_growth_comments
SET status = 'posted', generated_comment = ?, model_id = ?, comment_fullname = ?,
comment_permalink = ?, posted_at = ?, next_verify_at = ?, updated_at = ?,
lease_owner = NULL, lease_expires_at = NULL, last_error = NULL
WHERE account_id = ? AND post_id = ? AND status = 'posting' AND lease_owner = ?
`).run(
outcome.generatedComment,
outcome.modelId,
outcome.commentFullname,
outcome.commentPermalink,
now,
outcome.nextVerifyAt,
now,
accountId,
postId,
workerId,
).changes === 1;
}
/**
* Terminal rejection. The generated text is kept when there was one so a
* safety-gate refusal stays auditable rather than vanishing.
*/
reject(
accountId: string,
postId: string,
workerId: string,
reason: string,
generatedComment: string | null = null,
now: number = Date.now(),
): boolean {
requireText(reason, 'reason');
return this.db.prepare(`
UPDATE account_growth_comments
SET status = 'rejected', last_error = ?,
generated_comment = COALESCE(?, generated_comment), updated_at = ?,
lease_owner = NULL, lease_expires_at = NULL
WHERE account_id = ? AND post_id = ? AND status = 'posting' AND lease_owner = ?
`).run(reason, generatedComment, now, accountId, postId, workerId).changes === 1;
}
defer(
accountId: string,
postId: string,
workerId: string,
reason: string,
availableAt: number,
now: number = Date.now(),
): boolean {
requireText(reason, 'reason');
return this.db.prepare(`
UPDATE account_growth_comments
SET status = 'retryable', last_error = ?, available_at = ?, updated_at = ?,
lease_owner = NULL, lease_expires_at = NULL
WHERE account_id = ? AND post_id = ? AND status = 'posting' AND lease_owner = ?
`).run(reason, availableAt, now, accountId, postId, workerId).changes === 1;
}
/**
* An interrupted worker may or may not have reached Reddit before dying, so a
* recovered row is never retried automatically — it is rejected and left in
* the audit trail. Retrying risks a duplicate comment, which is both a
* spam signal and impossible to undo.
*/
recoverLeases(accountId: string, now: number = Date.now()): number {
return this.db.prepare(`
UPDATE account_growth_comments
SET status = 'rejected', lease_owner = NULL, lease_expires_at = NULL, updated_at = ?,
last_error = 'Interrupted while posting; not retried to avoid a duplicate comment'
WHERE account_id = ? AND status = 'posting'
`).run(now, accountId).changes;
}
countPostedSince(accountId: string, since: number): number {
return (this.db.prepare(`
SELECT COUNT(*) AS count FROM account_growth_comments
WHERE account_id = ? AND posted_at IS NOT NULL AND posted_at >= ?
`).get(accountId, since) as { count: number }).count;
}
countPostedInSubredditSince(accountId: string, subreddit: string, since: number): number {
return (this.db.prepare(`
SELECT COUNT(*) AS count FROM account_growth_comments
WHERE account_id = ? AND subreddit = ? COLLATE NOCASE
AND posted_at IS NOT NULL AND posted_at >= ?
`).get(accountId, normalizeSubreddit(subreddit), since) as { count: number }).count;
}
getLastPostedAt(accountId: string): number | null {
return (this.db.prepare(`
SELECT MAX(posted_at) AS at FROM account_growth_comments WHERE account_id = ?
`).get(accountId) as { at: number | null }).at;
}
/** Posted comments whose next verification sweep is due. */
listDueForVerification(
accountId: string,
limit = 100,
now: number = Date.now(),
): GrowthCommentRecord[] {
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new Error('Verification limit must be an integer between 1 and 100');
}
const rows = this.db.prepare(`
SELECT * FROM account_growth_comments
WHERE account_id = ? AND status = 'posted' AND comment_fullname IS NOT NULL
AND next_verify_at IS NOT NULL AND next_verify_at <= ?
ORDER BY next_verify_at ASC
LIMIT ?
`).all(accountId, now, limit) as Array<Record<string, unknown>>;
return rows.map(mapComment);
}
recordVerification(
accountId: string,
postId: string,
input: GrowthVerificationInput,
now: number = Date.now(),
): boolean {
const status = input.removed || input.nextVerifyAt === null ? 'verified' : 'posted';
return this.db.prepare(`
UPDATE account_growth_comments
SET status = ?,
first_score = COALESCE(first_score, ?),
last_score = COALESCE(?, last_score),
removed_at = CASE WHEN ? = 1 THEN COALESCE(removed_at, ?) ELSE removed_at END,
shadow_banned = COALESCE(?, shadow_banned),
shadow_checked_at = CASE WHEN ? IS NULL THEN shadow_checked_at ELSE ? END,
verify_attempts = verify_attempts + 1,
next_verify_at = ?,
updated_at = ?
WHERE account_id = ? AND post_id = ? AND status IN ('posted', 'verified')
`).run(
status,
input.score,
input.score,
input.removed ? 1 : 0,
now,
input.shadowBanned === undefined || input.shadowBanned === null ? null : (input.shadowBanned ? 1 : 0),
input.shadowBanned === undefined || input.shadowBanned === null ? null : now,
now,
input.nextVerifyAt,
now,
accountId,
postId,
).changes === 1;
}
/** Evidence for the adaptive pacer: what happened to recent posted comments. */
listOutcomesSince(accountId: string, since: number): GrowthOutcomeSample[] {
const rows = this.db.prepare(`
SELECT post_id, subreddit, posted_at, status, last_score, removed_at, shadow_banned
FROM account_growth_comments
WHERE account_id = ? AND posted_at IS NOT NULL AND posted_at >= ?
ORDER BY posted_at ASC
`).all(accountId, since) as Array<Record<string, unknown>>;
return rows.map((row) => ({
postId: row.post_id as string,
subreddit: row.subreddit as string,
postedAt: row.posted_at as number,
status: row.status as GrowthCommentStatus,
lastScore: (row.last_score as number | null) ?? null,
removed: row.removed_at !== null,
shadowBanned: row.shadow_banned === null ? null : row.shadow_banned === 1,
}));
}
listRecent(accountId: string, limit = 50): GrowthCommentRecord[] {
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
throw new Error('Growth history limit must be an integer between 1 and 200');
}
const rows = this.db.prepare(`
SELECT * FROM account_growth_comments WHERE account_id = ?
ORDER BY COALESCE(posted_at, discovered_at) DESC
LIMIT ?
`).all(accountId, limit) as Array<Record<string, unknown>>;
return rows.map(mapComment);
}
get(accountId: string, postId: string): GrowthCommentRecord | null {
const row = this.db.prepare(
'SELECT * FROM account_growth_comments WHERE account_id = ? AND post_id = ?',
).get(accountId, postId) as Record<string, unknown> | undefined;
return row ? mapComment(row) : null;
}
getStats(accountId: string, now: number = Date.now()): GrowthStats {
const byStatus = Object.fromEntries(
ALL_STATUSES.map((status) => [status, 0]),
) as Record<GrowthCommentStatus, number>;
const rows = this.db.prepare(`
SELECT status, COUNT(*) AS count FROM account_growth_comments
WHERE account_id = ? GROUP BY status
`).all(accountId) as Array<{ status: GrowthCommentStatus; count: number }>;
for (const row of rows) byStatus[row.status] = row.count;
const scores = (this.db.prepare(`
SELECT last_score FROM account_growth_comments
WHERE account_id = ? AND last_score IS NOT NULL
ORDER BY last_score ASC
`).all(accountId) as Array<{ last_score: number }>).map((row) => row.last_score);
return {
byStatus,
postedLast24h: this.countPostedSince(accountId, now - 86_400_000),
postedTotal: (this.db.prepare(`
SELECT COUNT(*) AS count FROM account_growth_comments
WHERE account_id = ? AND posted_at IS NOT NULL
`).get(accountId) as { count: number }).count,
removedTotal: (this.db.prepare(`
SELECT COUNT(*) AS count FROM account_growth_comments
WHERE account_id = ? AND removed_at IS NOT NULL
`).get(accountId) as { count: number }).count,
medianScore: median(scores),
};
}
/** Drops stale unposted candidates so the queue never serves an ancient thread. */
expireStale(accountId: string, cutoff: number, now: number = Date.now()): number {
return this.db.prepare(`
UPDATE account_growth_comments
SET status = 'rejected', last_error = 'Thread aged out before a comment was posted',
updated_at = ?, lease_owner = NULL, lease_expires_at = NULL
WHERE account_id = ? AND status IN ('discovered', 'retryable') AND thread_created_at < ?
`).run(now, accountId, cutoff).changes;
}
private ensureSettingsRow(accountId: string, now: number): void {
requireText(accountId, 'accountId');
this.db.prepare(`
INSERT INTO account_growth_settings (account_id, updated_at) VALUES (?, ?)
ON CONFLICT(account_id) DO NOTHING
`).run(accountId, now);
}
}
function numComments(post: RedditPost): number {
const value = (post as unknown as { num_comments?: unknown }).num_comments;
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
}
function median(sorted: readonly number[]): number | null {
if (sorted.length === 0) return null;
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1
? sorted[middle]
: (sorted[middle - 1] + sorted[middle]) / 2;
}
function mapSettings(row: Record<string, unknown>): GrowthSettingsRecord {
return {
accountId: row.account_id as string,
enabled: row.enabled === 1,
trustLevel: row.trust_level as number,
dailyLimit: (row.daily_limit as number | null) ?? null,
levelChangedAt: (row.level_changed_at as number | null) ?? null,
consecutiveRemovals: row.consecutive_removals as number,
pausedReason: (row.paused_reason as string | null) ?? null,
pausedAt: (row.paused_at as number | null) ?? null,
minDelayMs: row.min_delay_ms as number,
jitterMs: row.jitter_ms as number,
quietHours: parseQuietHours(row.quiet_hours_json as string),
allowNsfw: row.allow_nsfw === 1,
updatedAt: row.updated_at as number,
};
}
function mapSubreddit(row: Record<string, unknown>): GrowthSubredditRecord {
return {
accountId: row.account_id as string,
subreddit: row.subreddit as string,
enabled: row.enabled === 1,
weight: row.weight as number,
dailyCap: row.daily_cap as number,
disabledReason: (row.disabled_reason as string | null) ?? null,
cooldownUntil: (row.cooldown_until as number | null) ?? null,
addedAt: row.added_at as number,
updatedAt: row.updated_at as number,
};
}
function mapComment(row: Record<string, unknown>): GrowthCommentRecord {
return {
accountId: row.account_id as string,
postId: row.post_id as string,
subreddit: row.subreddit as string,
status: row.status as GrowthCommentStatus,
threadTitle: row.thread_title as string,
threadUrl: row.thread_url as string,
threadCreatedAt: row.thread_created_at as number,
threadNumComments: row.thread_num_comments as number,
post: JSON.parse(row.payload_json as string) as RedditPost,
generatedComment: (row.generated_comment as string | null) ?? null,
modelId: (row.model_id as string | null) ?? null,
commentFullname: (row.comment_fullname as string | null) ?? null,
commentPermalink: (row.comment_permalink as string | null) ?? null,
firstScore: (row.first_score as number | null) ?? null,
lastScore: (row.last_score as number | null) ?? null,
removedAt: (row.removed_at as number | null) ?? null,
shadowCheckedAt: (row.shadow_checked_at as number | null) ?? null,
shadowBanned: row.shadow_banned === null || row.shadow_banned === undefined
? null
: row.shadow_banned === 1,
verifyAttempts: row.verify_attempts as number,
nextVerifyAt: (row.next_verify_at as number | null) ?? null,
discoveredAt: row.discovered_at as number,
postedAt: (row.posted_at as number | null) ?? null,
updatedAt: row.updated_at as number,
availableAt: row.available_at as number,
leaseOwner: (row.lease_owner as string | null) ?? null,
leaseExpiresAt: (row.lease_expires_at as number | null) ?? null,
attemptCount: row.attempt_count as number,
lastError: (row.last_error as string | null) ?? null,
};
}
function parseQuietHours(json: string): GrowthQuietHours {
try {
const parsed = JSON.parse(json) as Partial<GrowthQuietHours>;
const quietHours = {
enabled: parsed.enabled !== false,
startHour: typeof parsed.startHour === 'number' ? parsed.startHour : DEFAULT_QUIET_HOURS.startHour,
endHour: typeof parsed.endHour === 'number' ? parsed.endHour : DEFAULT_QUIET_HOURS.endHour,
};
validateQuietHours(quietHours);
return quietHours;
} catch {
return { ...DEFAULT_QUIET_HOURS };
}
}
function validateQuietHours(quietHours: GrowthQuietHours): void {
for (const hour of [quietHours.startHour, quietHours.endHour]) {
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
throw new Error('Quiet hours must be whole hours between 0 and 23');
}
}
}
export function normalizeSubreddit(value: string): string {
const trimmed = value.trim().replace(/^\/?r\//i, '');
if (!/^[A-Za-z0-9][A-Za-z0-9_]{1,20}$/.test(trimmed)) {
throw new Error(`"${value}" is not a valid subreddit name`);
}
return trimmed;
}
function requireText(value: string, name: string): void {
if (!value.trim()) throw new Error(`${name} must not be empty`);
}