863 lines
31 KiB
TypeScript
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`);
|
|
}
|