feat: replace browser login with imported Reddit sessions

This commit is contained in:
jjbrunton committed 2026-08-08 16:48:59 +01:00
1 parent 2b0545afcf
commit c483c7b8be
76 files changed
+2048 -2828

No files matched your search

+83 -28
View File
@@ -4,7 +4,9 @@ import path from 'path';
import fs from 'fs';
export function createDatabase(config: Config): Database.Database {
const dbPath = path.resolve(process.cwd(), config.database.path);
const dbPath = config.database.path === ':memory:'
? ':memory:'
: path.resolve(process.cwd(), config.database.path);
const dbDir = path.dirname(dbPath);
// Ensure directory exists
@@ -22,8 +24,8 @@ export function createDatabase(config: Config): Database.Database {
}
export function runMigrations(db: Database.Database): void {
// Create tables if they don't exist
db.exec(`
const migrations: ReadonlyArray<() => void> = [
() => db.exec(`
CREATE TABLE IF NOT EXISTS processed_posts (
id TEXT PRIMARY KEY,
subreddit TEXT NOT NULL,
@@ -83,36 +85,89 @@ export function runMigrations(db: Database.Database): void {
ON processed_posts(message_sent);
CREATE INDEX IF NOT EXISTS idx_activity_log_timestamp
ON activity_log(timestamp);
`);
`),
// Add new columns to existing tables (safe to run multiple times)
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
() => {
addColumnIfNotExists(db, 'sent_messages', 'got_response', 'INTEGER DEFAULT 0');
addColumnIfNotExists(db, 'sent_messages', 'response_checked_at', 'INTEGER');
addColumnIfNotExists(db, 'sent_messages', 'strategy_id', 'TEXT');
addColumnIfNotExists(db, 'sent_messages', 'chat_room_id', 'TEXT');
db.exec(`
CREATE INDEX IF NOT EXISTS idx_sent_messages_response ON sent_messages(got_response);
CREATE INDEX IF NOT EXISTS idx_sent_messages_recipient ON sent_messages(recipient);
CREATE INDEX IF NOT EXISTS idx_sent_messages_chat_room ON sent_messages(chat_room_id);
`);
makeSentMessagesPostIdNullable(db);
},
() => {
addColumnIfNotExists(db, 'successful_messages', 'subreddit', 'TEXT');
makeSuccessfulMessageIdNullable(db);
},
() => db.exec(`
CREATE TABLE IF NOT EXISTS delivery_outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id TEXT NOT NULL UNIQUE,
recipient TEXT NOT NULL,
subject TEXT NOT NULL,
message TEXT NOT NULL,
strategy_id TEXT,
idempotency_key TEXT NOT NULL UNIQUE,
status TEXT NOT NULL CHECK(status IN ('discovered', 'prepared', 'sending', 'sent', 'failed')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
lease_owner TEXT,
lease_expires_at INTEGER,
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
sent_message_id INTEGER,
FOREIGN KEY (post_id) REFERENCES processed_posts(id),
FOREIGN KEY (sent_message_id) REFERENCES sent_messages(id)
);
CREATE INDEX IF NOT EXISTS idx_delivery_outbox_claim
ON delivery_outbox(status, lease_expires_at, created_at);
`),
() => db.exec(`
CREATE TABLE IF NOT EXISTS conversation_states (
conversation_id TEXT PRIMARY KEY,
state_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`),
() => db.exec(`
CREATE TABLE IF NOT EXISTS conversation_outcomes (
sent_message_id INTEGER PRIMARY KEY,
outcome TEXT NOT NULL CHECK(outcome IN ('engaged', 'declined', 'boundary', 'upsell', 'unknown')),
response_snippet TEXT,
recorded_at INTEGER NOT NULL,
FOREIGN KEY (sent_message_id) REFERENCES sent_messages(id)
);
`),
];
let currentVersion = db.pragma('user_version', { simple: true }) as number;
if (currentVersion > migrations.length) {
throw new Error(`Database schema version ${currentVersion} is newer than this application supports`);
}
for (let index = currentVersion; index < migrations.length; index += 1) {
const version = index + 1;
db.transaction(() => {
migrations[index]();
db.pragma(`user_version = ${version}`);
})();
currentVersion = version;
}
}
function addColumnIfNotExists(db: Database.Database, table: string, column: string, definition: string): void {
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
const exists = columns.some((c) => c.name === column);
if (!exists) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
};
}
addColumnIfNotExists('sent_messages', 'got_response', 'INTEGER DEFAULT 0');
addColumnIfNotExists('sent_messages', 'response_checked_at', 'INTEGER');
// Create index for response checking
db.exec(`CREATE INDEX IF NOT EXISTS idx_sent_messages_response ON sent_messages(got_response)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_sent_messages_recipient ON sent_messages(recipient)`);
// Add strategy_id column for A/B testing
addColumnIfNotExists('sent_messages', 'strategy_id', 'TEXT');
// Add subreddit column to successful_messages for smarter example selection
addColumnIfNotExists('successful_messages', 'subreddit', 'TEXT');
// Track chats sent via chat.reddit.com so dashboard stats include them.
addColumnIfNotExists('sent_messages', 'chat_room_id', 'TEXT');
db.exec(`CREATE INDEX IF NOT EXISTS idx_sent_messages_chat_room ON sent_messages(chat_room_id)`);
// Migration: Make post_id nullable in sent_messages (chat-originated rows
// have no associated processed_post). SQLite can't ALTER COLUMN, so recreate.
function makeSentMessagesPostIdNullable(db: Database.Database): void {
const sentMsgColumns = db.prepare(`PRAGMA table_info(sent_messages)`).all() as { name: string; notnull: number }[];
const postIdCol = sentMsgColumns.find((c) => c.name === 'post_id');
if (postIdCol && postIdCol.notnull === 1) {
@@ -143,9 +198,9 @@ export function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_sent_messages_chat_room ON sent_messages(chat_room_id);
`);
}
}
// Migration: Make sent_message_id nullable in successful_messages
// SQLite doesn't support ALTER COLUMN, so we need to recreate the table
function makeSuccessfulMessageIdNullable(db: Database.Database): void {
const successfulMsgColumns = db.prepare(`PRAGMA table_info(successful_messages)`).all() as { name: string; notnull: number }[];
const sentMsgIdCol = successfulMsgColumns.find((c) => c.name === 'sent_message_id');
+213
View File
@@ -1,4 +1,10 @@
import type Database from 'better-sqlite3';
import {
parseConversationState,
serializeConversationState,
type ConversationState,
} from '../services/conversation-state.js';
import type { ConversationOutcome } from '../services/conversation-outcome.js';
export interface ProcessedPostRecord {
id: string;
@@ -70,6 +76,155 @@ export interface ActivityLogRecord {
imageUrl: string | null;
}
export type DeliveryStatus = 'discovered' | 'prepared' | 'sending' | 'sent' | 'failed';
export interface DeliveryOutboxRecord {
id: number;
postId: string;
recipient: string;
subject: string;
message: string;
strategyId: string | null;
idempotencyKey: string;
status: DeliveryStatus;
createdAt: number;
updatedAt: number;
leaseOwner: string | null;
leaseExpiresAt: number | null;
attemptCount: number;
lastError: string | null;
sentMessageId: number | null;
}
/**
* Durable state for an outbound delivery. The caller must only perform the
* remote send after claiming an item, then acknowledge it using the same
* lease owner. A crashed worker's lease can safely be recovered on startup.
*/
export class DeliveryOutboxRepository {
constructor(private readonly db: Database.Database) {}
discover(input: {
postId: string;
recipient: string;
subject: string;
message: string;
strategyId?: string | null;
idempotencyKey: string;
now?: number;
}): DeliveryOutboxRecord {
const now = input.now ?? Date.now();
const insert = this.db.prepare(`
INSERT INTO delivery_outbox
(post_id, recipient, subject, message, strategy_id, idempotency_key, status, created_at, updated_at)
VALUES (@postId, @recipient, @subject, @message, @strategyId, @idempotencyKey, 'discovered', @now, @now)
ON CONFLICT(idempotency_key) DO NOTHING
`);
const select = this.db.prepare('SELECT * FROM delivery_outbox WHERE idempotency_key = ?');
const transaction = this.db.transaction(() => {
insert.run({ ...input, strategyId: input.strategyId ?? null, now });
const row = select.get(input.idempotencyKey) as Record<string, unknown> | undefined;
if (!row) throw new Error('Outbox delivery was not persisted');
return this.mapRow(row);
});
return transaction();
}
get(id: number): DeliveryOutboxRecord | null {
const row = this.db.prepare('SELECT * FROM delivery_outbox WHERE id = ?').get(id) as Record<string, unknown> | undefined;
return row ? this.mapRow(row) : null;
}
count(): number {
return (this.db.prepare('SELECT COUNT(*) AS count FROM delivery_outbox').get() as { count: number }).count;
}
markPrepared(id: number, now: number = Date.now()): boolean {
return this.db.prepare(`
UPDATE delivery_outbox SET status = 'prepared', updated_at = ?, last_error = NULL
WHERE id = ? AND status IN ('discovered', 'failed')
`).run(now, id).changes === 1;
}
claimNext(workerId: string, leaseMs: number, now: number = Date.now()): DeliveryOutboxRecord | null {
if (!workerId) throw new Error('A non-empty worker ID is required to claim delivery');
if (!Number.isFinite(leaseMs) || leaseMs <= 0) throw new Error('leaseMs must be a positive number');
const transaction = this.db.transaction(() => {
const row = this.db.prepare(`
SELECT id FROM delivery_outbox
WHERE status = 'prepared'
ORDER BY created_at ASC, id ASC
LIMIT 1
`).get() as { id: number } | undefined;
if (!row) return null;
const changed = this.db.prepare(`
UPDATE delivery_outbox
SET status = 'sending', lease_owner = ?, lease_expires_at = ?, updated_at = ?, attempt_count = attempt_count + 1
WHERE id = ? AND status = 'prepared'
`).run(workerId, now + leaseMs, now, row.id).changes;
if (changed !== 1) return null;
return this.get(row.id);
});
return transaction();
}
/** Claim one known delivery; useful when a freshly prepared item is dispatched immediately. */
claim(id: number, workerId: string, leaseMs: number, now: number = Date.now()): DeliveryOutboxRecord | null {
if (!workerId) throw new Error('A non-empty worker ID is required to claim delivery');
if (!Number.isFinite(leaseMs) || leaseMs <= 0) throw new Error('leaseMs must be a positive number');
const changed = this.db.prepare(`
UPDATE delivery_outbox
SET status = 'sending', lease_owner = ?, lease_expires_at = ?, updated_at = ?, attempt_count = attempt_count + 1
WHERE id = ? AND status = 'prepared'
`).run(workerId, now + leaseMs, now, id).changes;
return changed === 1 ? this.get(id) : null;
}
recoverExpiredLeases(now: number = Date.now()): number {
return this.db.prepare(`
UPDATE delivery_outbox
SET status = 'prepared', lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
WHERE status = 'sending' AND lease_expires_at <= ?
`).run(now, now).changes;
}
markSent(id: number, workerId: string, sentMessageId: number, now: number = Date.now()): boolean {
return this.db.prepare(`
UPDATE delivery_outbox
SET status = 'sent', sent_message_id = ?, lease_owner = NULL, lease_expires_at = NULL, updated_at = ?, last_error = NULL
WHERE id = ? AND status = 'sending' AND lease_owner = ?
`).run(sentMessageId, now, id, workerId).changes === 1;
}
markFailed(id: number, workerId: string, error: string, now: number = Date.now()): boolean {
return this.db.prepare(`
UPDATE delivery_outbox
SET status = 'failed', last_error = ?, lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
WHERE id = ? AND status = 'sending' AND lease_owner = ?
`).run(error, now, id, workerId).changes === 1;
}
private mapRow(row: Record<string, unknown>): DeliveryOutboxRecord {
return {
id: row.id as number,
postId: row.post_id as string,
recipient: row.recipient as string,
subject: row.subject as string,
message: row.message as string,
strategyId: (row.strategy_id as string | null) ?? null,
idempotencyKey: row.idempotency_key as string,
status: row.status as DeliveryStatus,
createdAt: row.created_at as number,
updatedAt: row.updated_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,
sentMessageId: (row.sent_message_id as number | null) ?? null,
};
}
}
export class ActivityLogRepository {
private insertStmt: Database.Statement;
private getRecentStmt: Database.Statement;
@@ -373,6 +528,14 @@ export class MessagesRepository {
return rows.map((row) => this.mapRow(row));
}
/** Successful-send history adapter for durable rolling rate limits. */
getSentTimestampsSince(since: number): number[] {
const rows = this.db.prepare(`
SELECT sent_at FROM sent_messages WHERE sent_at > ? ORDER BY sent_at ASC
`).all(since) as Array<{ sent_at: number }>;
return rows.map((row) => row.sent_at);
}
getUncheckedMessages(minAgeMs: number, recheckAfterMs: number, limit: number = 10): SentMessageRecord[] {
const now = Date.now();
const minSentAt = now - minAgeMs;
@@ -567,6 +730,56 @@ export class SuccessfulMessagesRepository {
}
}
/** Durable, minimal memory for one chat conversation. */
export class ConversationStateRepository {
private upsertStmt: Database.Statement;
private getStmt: Database.Statement;
constructor(private db: Database.Database) {
this.upsertStmt = db.prepare(`
INSERT INTO conversation_states (conversation_id, state_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(conversation_id) DO UPDATE SET
state_json = excluded.state_json,
updated_at = excluded.updated_at
`);
this.getStmt = db.prepare(`
SELECT state_json FROM conversation_states WHERE conversation_id = ?
`);
}
save(conversationId: string, state: ConversationState): void {
this.upsertStmt.run(conversationId, serializeConversationState(state), Date.now());
}
get(conversationId: string): ConversationState | undefined {
const row = this.getStmt.get(conversationId) as { state_json: string } | undefined;
return row ? parseConversationState(row.state_json) ?? undefined : undefined;
}
}
/** Outcome labels are recorded independently from a raw inbox response. */
export class ConversationOutcomesRepository {
constructor(private db: Database.Database) {}
record(sentMessageId: number, outcome: ConversationOutcome, responseSnippet: string | null): void {
this.db.prepare(`
INSERT INTO conversation_outcomes (sent_message_id, outcome, response_snippet, recorded_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(sent_message_id) DO UPDATE SET
outcome = excluded.outcome,
response_snippet = excluded.response_snippet,
recorded_at = excluded.recorded_at
`).run(sentMessageId, outcome, responseSnippet, Date.now());
}
get(sentMessageId: number): { outcome: ConversationOutcome; responseSnippet: string | null } | undefined {
const row = this.db.prepare(`SELECT outcome, response_snippet FROM conversation_outcomes WHERE sent_message_id = ?`)
.get(sentMessageId) as { outcome: ConversationOutcome; response_snippet: string | null } | undefined;
return row ? { outcome: row.outcome, responseSnippet: row.response_snippet } : undefined;
}
}
export interface RedditUserRecord {
t2Id: string;
username: string;