Files
RedditBot/src/database/candidate-queue-repository.ts
T
Jamie BruntonandClaude Opus 5 14d92e14ee feat: add scan mode so conversations stay in sync without sending
Stopping the bot previously halted conversation syncing too. On the
multi-account path the run-flag check sat above both the reply-sync and
response-check branches, so a stop left only MediaSyncService's coarse
30-minute chat refresh. The legacy single-account path did the opposite
and kept syncing while stopped — one boolean meaning two things.

Replace the boolean with an explicit three-way mode:

  running — discover, screen, send, automate replies
  scan    — refresh chats, detect replies, check responses; send nothing
             and discover nothing; actionable replies surface as backlog
  stopped — everything halts, including background media sync

scan differs from browser.dryRun, which still discovers and pays for AI
screening before suppressing the send.

The mode persists in runtime_state.bot_mode; a migration backfills it
from bot_running, and rows written by an older build still read
correctly. Both loops now gate on the mode, the account runtime loop
reconciles background media sync on transitions only, and the dashboard
and agent API expose all three states.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:16:24 +01:00

463 lines
18 KiB
TypeScript

import type Database from 'better-sqlite3';
import type { PhysiqueTargeting } from '../config/index.js';
import type { RedditPost } from '../reddit/api.js';
import { allowsSending, parseBotMode, type BotMode } from '../services/bot-mode.js';
import { evaluatePhysiqueTargeting } from '../services/physique-targeting.js';
import type { ProcessedPost } from '../services/processor.js';
export type CandidateStatus =
| 'discovered'
| 'screening'
| 'ready'
| 'sending'
| 'retryable'
| 'sent'
| 'rejected';
export interface CandidateQueueRecord {
postId: string;
subreddit: string;
author: string;
sourceType: 'image' | 'chat';
post: RedditPost;
prepared: ProcessedPost | null;
priority: number;
status: CandidateStatus;
discoveredAt: number;
updatedAt: number;
availableAt: number;
leaseOwner: string | null;
leaseExpiresAt: number | null;
assignedAccountId: string | null;
attemptCount: number;
lastError: string | null;
}
export interface CandidateQueueStats {
total: number;
byStatus: Record<CandidateStatus, number>;
ready: number;
oldestReadyAt: number | null;
rejectionReasons: Array<{ reason: string; count: number }>;
}
export interface CandidateEnqueueResult {
discovered: number;
duplicatePost: number;
duplicateAuthor: number;
previouslyProcessed: number;
previouslyContacted: number;
deferredAtCapacity: number;
}
export interface PhysiqueTargetingReconciliation {
rejected: number;
retained: number;
}
const ACTIVE_STATUSES: CandidateStatus[] = ['discovered', 'screening', 'ready', 'sending', 'retryable'];
/** Global, durable funnel from Reddit listing discovery through outbound delivery. */
export class CandidateQueueRepository {
constructor(private readonly db: Database.Database) {}
enqueue(
posts: RedditPost[],
sourceType: 'image' | 'chat',
priority: number,
now: number = Date.now(),
maxNewCandidates: number = Number.POSITIVE_INFINITY,
): CandidateEnqueueResult {
if ((!Number.isInteger(maxNewCandidates) && maxNewCandidates !== Number.POSITIVE_INFINITY)
|| maxNewCandidates < 0) {
throw new Error('Candidate intake capacity must be a non-negative integer');
}
const result: CandidateEnqueueResult = {
discovered: 0,
duplicatePost: 0,
duplicateAuthor: 0,
previouslyProcessed: 0,
previouslyContacted: 0,
deferredAtCapacity: 0,
};
const existingPost = this.db.prepare('SELECT 1 FROM candidate_queue WHERE post_id = ?');
const processedPost = this.db.prepare('SELECT 1 FROM processed_posts WHERE id = ? LIMIT 1');
const contacted = this.db.prepare(`
SELECT 1 FROM (
SELECT recipient FROM recipient_suppressions WHERE recipient = ? COLLATE NOCASE
UNION ALL
SELECT recipient FROM sent_messages WHERE recipient = ? COLLATE NOCASE
UNION ALL
SELECT username AS recipient FROM reddit_conversations WHERE username = ? COLLATE NOCASE
) LIMIT 1
`);
const activeAuthor = this.db.prepare(`
SELECT 1 FROM candidate_queue
WHERE author = ? COLLATE NOCASE
AND status IN (${ACTIVE_STATUSES.map(() => '?').join(', ')})
LIMIT 1
`);
const insert = this.db.prepare(`
INSERT INTO candidate_queue (
post_id, subreddit, author, source_type, payload_json, post_created_at, priority,
status, discovered_at, updated_at, available_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'discovered', ?, ?, ?)
`);
const newestPostsFirst = [...posts].sort((left, right) => right.created_utc - left.created_utc);
this.db.transaction(() => {
for (const [index, post] of newestPostsFirst.entries()) {
if (result.discovered >= maxNewCandidates) {
result.deferredAtCapacity += newestPostsFirst.length - index;
break;
}
if (existingPost.get(post.id)) {
result.duplicatePost += 1;
continue;
}
if (processedPost.get(post.id)) {
result.previouslyProcessed += 1;
continue;
}
if (contacted.get(post.author, post.author, post.author)) {
result.previouslyContacted += 1;
continue;
}
if (activeAuthor.get(post.author, ...ACTIVE_STATUSES)) {
result.duplicateAuthor += 1;
continue;
}
insert.run(
post.id,
post.subreddit,
post.author,
sourceType,
JSON.stringify(post),
post.created_utc * 1_000,
priority,
now,
now,
now,
);
result.discovered += 1;
}
})();
return result;
}
claimForScreening(workerId: string, leaseMs: number, now: number = Date.now()): CandidateQueueRecord | null {
return this.claim('discovered', 'screening', workerId, leaseMs, now, false);
}
claimReady(accountId: string, workerId: string, leaseMs: number, now: number = Date.now()): CandidateQueueRecord | null {
const claimed = this.claim('ready', 'sending', workerId, leaseMs, now, true, accountId);
return claimed;
}
markReady(postId: string, workerId: string, prepared: ProcessedPost, now: number = Date.now()): boolean {
return this.db.prepare(`
UPDATE candidate_queue
SET status = 'ready', prepared_json = ?, available_at = ?, updated_at = ?,
lease_owner = NULL, lease_expires_at = NULL, last_error = NULL
WHERE post_id = ? AND status = 'screening' AND lease_owner = ?
`).run(JSON.stringify(prepared), now, now, postId, workerId).changes === 1;
}
reject(postId: string, workerId: string, reason: string, now: number = Date.now()): boolean {
return this.db.prepare(`
UPDATE candidate_queue
SET status = 'rejected', last_error = ?, updated_at = ?,
lease_owner = NULL, lease_expires_at = NULL, assigned_account_id = NULL
WHERE post_id = ? AND status IN ('screening', 'sending') AND lease_owner = ?
`).run(reason, now, postId, workerId).changes === 1;
}
deferScreening(
postId: string,
workerId: string,
reason: string,
availableAt: number,
now: number = Date.now(),
): boolean {
return this.db.prepare(`
UPDATE candidate_queue
SET status = 'discovered', last_error = ?, available_at = ?, updated_at = ?,
lease_owner = NULL, lease_expires_at = NULL
WHERE post_id = ? AND status = 'screening' AND lease_owner = ?
`).run(reason, availableAt, now, postId, workerId).changes === 1;
}
deferDelivery(
postId: string,
workerId: string,
reason: string,
availableAt: number,
now: number = Date.now(),
): boolean {
return this.db.prepare(`
UPDATE candidate_queue
SET status = 'retryable', last_error = ?, available_at = ?, updated_at = ?,
lease_owner = NULL, lease_expires_at = NULL, assigned_account_id = NULL
WHERE post_id = ? AND status = 'sending' AND lease_owner = ?
`).run(reason, availableAt, now, postId, workerId).changes === 1;
}
markSent(postId: string, workerId: string, now: number = Date.now()): boolean {
return this.db.prepare(`
UPDATE candidate_queue
SET status = 'sent', last_error = NULL, updated_at = ?,
lease_owner = NULL, lease_expires_at = NULL
WHERE post_id = ? AND status = 'sending' AND lease_owner = ?
`).run(now, postId, workerId).changes === 1;
}
promoteDueRetries(maxReadyCandidates: number, now: number = Date.now()): number {
if (!Number.isInteger(maxReadyCandidates) || maxReadyCandidates < 1) {
throw new Error('Maximum ready candidates must be a positive integer');
}
const capacity = maxReadyCandidates - this.countReady();
if (capacity <= 0) return 0;
return this.db.prepare(`
UPDATE candidate_queue
SET status = 'ready', updated_at = ?, assigned_account_id = NULL
WHERE post_id IN (
SELECT post_id FROM candidate_queue
WHERE status = 'retryable' AND available_at <= ?
ORDER BY post_created_at DESC, priority DESC, discovered_at ASC
LIMIT ?
)
`).run(now, now, capacity).changes;
}
recoverExpiredLeases(now: number = Date.now(), sendingDelayMs = 10 * 60_000): number {
return this.db.prepare(`
UPDATE candidate_queue
SET status = CASE status WHEN 'screening' THEN 'discovered' ELSE 'retryable' END,
lease_owner = NULL, lease_expires_at = NULL, assigned_account_id = NULL,
available_at = CASE status WHEN 'screening' THEN ? ELSE ? END,
updated_at = ?, last_error = 'Recovered after interrupted worker'
WHERE status IN ('screening', 'sending') AND lease_expires_at <= ?
`).run(now, now + sendingDelayMs, now, now).changes;
}
recoverAllLeases(now: number = Date.now(), sendingDelayMs = 10 * 60_000): number {
return this.db.prepare(`
UPDATE candidate_queue
SET status = CASE status WHEN 'screening' THEN 'discovered' ELSE 'retryable' END,
lease_owner = NULL, lease_expires_at = NULL, assigned_account_id = NULL,
available_at = CASE status WHEN 'screening' THEN ? ELSE ? END,
updated_at = ?, last_error = 'Recovered after process restart'
WHERE status IN ('screening', 'sending')
`).run(now, now + sendingDelayMs, now).changes;
}
/** Re-evaluates prepared image candidates that have not yet been leased for sending. */
reconcilePhysiqueTargeting(
targeting: PhysiqueTargeting,
now: number = Date.now(),
): PhysiqueTargetingReconciliation {
const rows = this.db.prepare(`
SELECT post_id, prepared_json FROM candidate_queue
WHERE source_type = 'image'
AND status IN ('ready', 'retryable')
AND prepared_json IS NOT NULL
`).all() as Array<{ post_id: string; prepared_json: string }>;
const preparedRows = rows.map((row) => ({
postId: row.post_id,
prepared: JSON.parse(row.prepared_json) as ProcessedPost,
}));
const reject = this.db.prepare(`
UPDATE candidate_queue
SET status = 'rejected', last_error = ?, updated_at = ?,
lease_owner = NULL, lease_expires_at = NULL, assigned_account_id = NULL
WHERE post_id = ? AND status IN ('ready', 'retryable')
`);
const retain = this.db.prepare(`
UPDATE candidate_queue
SET prepared_json = ?, updated_at = ?
WHERE post_id = ? AND status IN ('ready', 'retryable')
`);
return this.db.transaction(() => {
const result: PhysiqueTargetingReconciliation = { rejected: 0, retained: 0 };
for (const row of preparedRows) {
const score = typeof row.prepared.physiqueScore === 'number'
? row.prepared.physiqueScore
: null;
const decision = evaluatePhysiqueTargeting(score, targeting);
if (!decision.accepted) {
result.rejected += reject.run(decision.reason, now, row.postId).changes;
continue;
}
const refreshed: ProcessedPost = {
...row.prepared,
physiqueTargeting: { ...targeting },
};
result.retained += 1;
const previous = row.prepared.physiqueTargeting;
const snapshotMatches = previous?.enabled === targeting.enabled
&& previous.minScore === targeting.minScore
&& previous.maxScore === targeting.maxScore;
if (!snapshotMatches) retain.run(JSON.stringify(refreshed), now, row.postId);
}
return result;
})();
}
expireStale(cutoff: number, now: number = Date.now()): number {
return this.db.prepare(`
UPDATE candidate_queue
SET status = 'rejected', last_error = 'Candidate expired before delivery',
lease_owner = NULL, lease_expires_at = NULL, assigned_account_id = NULL, updated_at = ?
WHERE status IN ('discovered', 'ready', 'retryable') AND post_created_at < ?
`).run(now, cutoff).changes;
}
countActive(): number {
const row = this.db.prepare(`
SELECT COUNT(*) AS count FROM candidate_queue
WHERE status IN (${ACTIVE_STATUSES.map(() => '?').join(', ')})
`).get(...ACTIVE_STATUSES) as { count: number };
return row.count;
}
countReady(): number {
return (this.db.prepare(`SELECT COUNT(*) AS count FROM candidate_queue WHERE status = 'ready'`)
.get() as { count: number }).count;
}
getOldestActiveAt(): number | null {
const row = this.db.prepare(`
SELECT MIN(discovered_at) AS at FROM candidate_queue
WHERE status IN (${ACTIVE_STATUSES.map(() => '?').join(', ')})
`).get(...ACTIVE_STATUSES) as { at: number | null };
return row.at;
}
getStats(): CandidateQueueStats {
const statuses: CandidateStatus[] = ['discovered', 'screening', 'ready', 'sending', 'retryable', 'sent', 'rejected'];
const byStatus = Object.fromEntries(statuses.map((status) => [status, 0])) as Record<CandidateStatus, number>;
const rows = this.db.prepare('SELECT status, COUNT(*) AS count FROM candidate_queue GROUP BY status')
.all() as Array<{ status: CandidateStatus; count: number }>;
for (const row of rows) byStatus[row.status] = row.count;
const oldest = this.db.prepare(`SELECT MIN(discovered_at) AS at FROM candidate_queue WHERE status = 'ready'`)
.get() as { at: number | null };
const rejectionReasons = this.db.prepare(`
SELECT COALESCE(last_error, 'Unknown') AS reason, COUNT(*) AS count
FROM candidate_queue WHERE status = 'rejected'
GROUP BY reason ORDER BY count DESC LIMIT 20
`).all() as Array<{ reason: string; count: number }>;
return {
total: Object.values(byStatus).reduce((sum, count) => sum + count, 0),
byStatus,
ready: byStatus.ready,
oldestReadyAt: oldest.at,
rejectionReasons,
};
}
listActive(limit = 500): CandidateQueueRecord[] {
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
throw new Error('Candidate queue limit must be an integer between 1 and 500');
}
const rows = this.db.prepare(`
SELECT * FROM candidate_queue
WHERE status IN (${ACTIVE_STATUSES.map(() => '?').join(', ')})
ORDER BY
post_created_at DESC,
priority DESC,
discovered_at ASC
LIMIT ?
`).all(...ACTIVE_STATUSES, limit) as Array<Record<string, unknown>>;
return rows.map((row) => this.mapRow(row));
}
private claim(
from: 'discovered' | 'ready',
to: 'screening' | 'sending',
workerId: string,
leaseMs: number,
now: number,
requirePrepared: boolean,
accountId?: string,
): CandidateQueueRecord | null {
if (!workerId.trim()) throw new Error('Candidate worker ID is required');
if (!Number.isFinite(leaseMs) || leaseMs <= 0) throw new Error('Candidate lease must be positive');
return this.db.transaction(() => {
const row = this.db.prepare(`
SELECT post_id FROM candidate_queue
WHERE status = ? AND available_at <= ? ${requirePrepared ? 'AND prepared_json IS NOT NULL' : ''}
ORDER BY post_created_at DESC, priority DESC, discovered_at ASC
LIMIT 1
`).get(from, now) as { post_id: string } | undefined;
if (!row) return null;
const changed = this.db.prepare(`
UPDATE candidate_queue
SET status = ?, lease_owner = ?, lease_expires_at = ?, updated_at = ?,
assigned_account_id = ?, attempt_count = attempt_count + 1
WHERE post_id = ? AND status = ?
`).run(to, workerId, now + leaseMs, now, accountId ?? null, row.post_id, from).changes;
return changed === 1 ? this.get(row.post_id) : null;
})();
}
private get(postId: string): CandidateQueueRecord | null {
const row = this.db.prepare('SELECT * FROM candidate_queue WHERE post_id = ?')
.get(postId) as Record<string, unknown> | undefined;
if (!row) return null;
return this.mapRow(row);
}
private mapRow(row: Record<string, unknown>): CandidateQueueRecord {
return {
postId: row.post_id as string,
subreddit: row.subreddit as string,
author: row.author as string,
sourceType: row.source_type as 'image' | 'chat',
post: JSON.parse(row.payload_json as string) as RedditPost,
prepared: row.prepared_json ? JSON.parse(row.prepared_json as string) as ProcessedPost : null,
priority: row.priority as number,
status: row.status as CandidateStatus,
discoveredAt: row.discovered_at as number,
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,
assignedAccountId: (row.assigned_account_id as string | null) ?? null,
attemptCount: row.attempt_count as number,
lastError: (row.last_error as string | null) ?? null,
};
}
}
/** Persists the operator's intended run/scan/stop state across container restarts. */
export class RuntimeStateRepository {
constructor(private readonly db: Database.Database) {}
getBotMode(): BotMode {
const row = this.db.prepare('SELECT bot_running, bot_mode FROM runtime_state WHERE id = 1')
.get() as { bot_running: number; bot_mode: string | null } | undefined;
if (!row) return 'stopped';
// `bot_mode` is null for rows written by an older build; fall back to the boolean.
return parseBotMode(row.bot_mode, row.bot_running === 1 ? 'running' : 'stopped');
}
setBotMode(mode: BotMode, now: number = Date.now()): void {
this.db.prepare(`
INSERT INTO runtime_state (id, bot_running, bot_mode, updated_at) VALUES (1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
bot_running = excluded.bot_running,
bot_mode = excluded.bot_mode,
updated_at = excluded.updated_at
`).run(allowsSending(mode) ? 1 : 0, mode, now);
}
isBotRunning(): boolean {
return allowsSending(this.getBotMode());
}
setBotRunning(running: boolean, now: number = Date.now()): void {
this.setBotMode(running ? 'running' : 'stopped', now);
}
}