feat: replace browser login with imported Reddit sessions
This commit is contained in:
1 parent
2b0545afcf
commit
c483c7b8be
76 files changed
+2048
-2828
No files matched your search
+83
-28
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user