This commit is contained in:
jjbrunton committed 2026-01-04 19:16:31 +00:00
commit c552956516
23 files changed
+5193

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
import OpenAI from 'openai';
import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js';
export type DetectedGender = 'male' | 'female' | 'other' | 'unknown';
export interface ImageAnalysisResult {
description: string;
detectedGender: DetectedGender;
}
export class OpenRouterClient {
private client: OpenAI;
constructor(
private config: Config,
private logger: Logger
) {
this.client = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: config.openrouter.apiKey,
defaultHeaders: {
'HTTP-Referer': 'https://github.com/reddit-bot',
'X-Title': 'Reddit Bot',
},
});
}
async detectGenderOnly(imageBase64: string, mimeType: string): Promise<DetectedGender> {
try {
this.logger.debug('Quick gender detection...');
const response = await this.client.chat.completions.create({
model: this.config.openrouter.model,
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'What is the gender of the person in this image? Reply with ONLY one word: male, female, other, or unknown',
},
{
type: 'image_url',
image_url: {
url: `data:${mimeType};base64,${imageBase64}`,
},
},
],
},
],
max_tokens: 10,
});
const result = response.choices[0]?.message?.content?.toLowerCase().trim() || 'unknown';
if (result.includes('female')) return 'female';
if (result.includes('male') && !result.includes('female')) return 'male';
if (result.includes('other')) return 'other';
return 'unknown';
} catch (error) {
this.logger.error('Gender detection failed:', error);
return 'unknown';
}
}
async analyzeImage(imageBase64: string, mimeType: string): Promise<ImageAnalysisResult> {
try {
this.logger.debug('Analyzing image with AI...');
const genderPrompt = `First, identify the gender of the person in this image. Respond with EXACTLY one of these on the first line: GENDER:male, GENDER:female, GENDER:other, or GENDER:unknown
Then provide your analysis:
${this.config.openrouter.imageAnalysisPrompt}`;
const response = await this.client.chat.completions.create({
model: this.config.openrouter.model,
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: genderPrompt,
},
{
type: 'image_url',
image_url: {
url: `data:${mimeType};base64,${imageBase64}`,
},
},
],
},
],
max_tokens: 600,
});
const fullResponse = response.choices[0]?.message?.content || '';
// Parse gender from response
let detectedGender: DetectedGender = 'unknown';
const genderMatch = fullResponse.match(/GENDER:(male|female|other|unknown)/i);
if (genderMatch) {
detectedGender = genderMatch[1].toLowerCase() as DetectedGender;
}
// Remove gender line from description
const description = fullResponse.replace(/GENDER:(male|female|other|unknown)\n?/i, '').trim();
this.logger.debug(`Image analysis complete - Gender: ${detectedGender}`);
this.logger.debug(`Description: ${description}`);
return { description, detectedGender };
} catch (error) {
this.logger.error('Image analysis failed:', error);
throw error;
}
}
async generateMessage(
imageAnalysis: string,
postTitle: string,
authorName: string,
successfulExamples: string[] = []
): Promise<string> {
try {
this.logger.debug('Generating message...');
let prompt = this.config.openrouter.messageGenerationPrompt
.replace('{analysis}', imageAnalysis)
.replace('{title}', postTitle)
.replace('{author}', authorName);
// Add successful examples if available
if (successfulExamples.length > 0) {
const examplesText = successfulExamples
.map((ex, i) => `Example ${i + 1}:\n${ex}`)
.join('\n\n');
prompt = `Here are some examples of messages that got positive responses:\n\n${examplesText}\n\n---\n\nNow generate a new message in a similar style:\n\n${prompt}`;
this.logger.debug(`Including ${successfulExamples.length} successful examples in prompt`);
}
const response = await this.client.chat.completions.create({
model: this.config.openrouter.model,
messages: [{ role: 'user', content: prompt }],
max_tokens: 300,
temperature: 0.8, // Add some creativity
});
const message = response.choices[0]?.message?.content || '';
this.logger.debug(`Message generated: ${message}`);
return message.trim();
} catch (error) {
this.logger.error('Message generation failed:', error);
throw error;
}
}
async analyzeAndGenerateMessage(
imageBase64: string,
mimeType: string,
postTitle: string,
authorName: string,
successfulExamples: string[] = []
): Promise<{ analysis: ImageAnalysisResult; message: string }> {
const analysis = await this.analyzeImage(imageBase64, mimeType);
const message = await this.generateMessage(analysis.description, postTitle, authorName, successfulExamples);
return { analysis, message };
}
}
+85
View File
@@ -0,0 +1,85 @@
import { chromium, BrowserContext, Page } from 'playwright';
import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js';
import path from 'path';
import fs from 'fs';
export class BrowserClient {
private context: BrowserContext | null = null;
constructor(
private config: Config,
private logger: Logger
) {}
async initialize(): Promise<BrowserContext> {
const sessionDir = path.resolve(process.cwd(), this.config.browser.sessionDir);
// Ensure session directory exists
if (!fs.existsSync(sessionDir)) {
fs.mkdirSync(sessionDir, { recursive: true });
}
this.logger.info(`Launching browser with session dir: ${sessionDir}`);
// Use launchPersistentContext to maintain cookies/localStorage
this.context = await chromium.launchPersistentContext(sessionDir, {
headless: this.config.browser.headless,
viewport: { width: 1280, height: 720 },
userAgent: this.config.reddit.userAgent,
args: [
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
],
});
return this.context;
}
async getPage(): Promise<Page> {
if (!this.context) {
await this.initialize();
}
const pages = this.context!.pages();
return pages.length > 0 ? pages[0] : await this.context!.newPage();
}
getContext(): BrowserContext | null {
return this.context;
}
async close(): Promise<void> {
if (this.context) {
this.logger.info('Closing browser');
await this.context.close();
this.context = null;
}
}
async isSessionValid(): Promise<boolean> {
try {
const page = await this.getPage();
await page.goto('https://www.reddit.com', { waitUntil: 'domcontentloaded', timeout: 30000 });
// Check for various login indicators
const isLoggedIn = await page.evaluate(() => {
// Check for user menu button
const userButton = document.querySelector('[data-testid="user-drawer-button"]');
if (userButton) return true;
// Check for login/signup buttons which indicate NOT logged in
const loginButton = document.querySelector('a[href*="login"]');
const signupButton = document.querySelector('a[href*="register"]');
// If we find login/signup prominently, we're not logged in
// But we need to be careful - some elements may exist in DOM
return !loginButton && !signupButton;
});
return isLoggedIn;
} catch (error) {
this.logger.error('Failed to check session validity:', error);
return false;
}
}
}
+184
View File
@@ -0,0 +1,184 @@
import type { Page } from 'playwright';
import type { Logger } from '../utils/logger.js';
export class RedditMessagePage {
private dryRun: boolean = false;
constructor(
private page: Page,
private logger: Logger
) {}
setDryRun(enabled: boolean): void {
this.dryRun = enabled;
if (enabled) {
this.logger.info('Dry run mode enabled - messages will be filled but not sent');
}
}
async sendMessage(recipient: string, subject: string, body: string): Promise<boolean> {
try {
this.logger.info(`Sending message to u/${recipient}`);
// Navigate to compose page
await this.page.goto('https://www.reddit.com/message/compose/', {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
// Wait for the form to load - try multiple possible selectors
await this.page.waitForSelector('input[name="to"], #to, [data-testid="message-to"]', {
timeout: 30000,
});
// Wait for React to hydrate
await this.page.waitForTimeout(2000);
// Fill recipient field
const toInput = await this.page.$('input[name="to"]') || await this.page.$('#to');
if (toInput) {
await toInput.click();
await toInput.fill(recipient);
} else {
throw new Error('Could not find recipient input field');
}
// Fill subject field
const subjectInput = await this.page.$('input[name="subject"]') || await this.page.$('#subject');
if (subjectInput) {
await subjectInput.click();
await subjectInput.fill(subject);
} else {
throw new Error('Could not find subject input field');
}
// Fill message body - Reddit uses various editor implementations
// Try contenteditable div first (new Reddit)
let bodyFilled = false;
// Try markdown textarea
const textarea = await this.page.$('textarea[name="text"], textarea#text, textarea.md');
if (textarea) {
await textarea.click();
await this.typeWithDelay(body);
bodyFilled = true;
}
// Try contenteditable div (fancy editor)
if (!bodyFilled) {
const contentEditable = await this.page.$('[contenteditable="true"]');
if (contentEditable) {
await contentEditable.click();
await this.typeWithDelay(body);
bodyFilled = true;
}
}
// Try data-testid selector
if (!bodyFilled) {
const markdownInput = await this.page.$('[data-testid="markdown-input"]');
if (markdownInput) {
await markdownInput.click();
await this.typeWithDelay(body);
bodyFilled = true;
}
}
if (!bodyFilled) {
throw new Error('Could not find message body input');
}
// Wait a bit before submitting
await this.page.waitForTimeout(500);
// Dry run mode - don't actually send
if (this.dryRun) {
this.logger.info(`[DRY RUN] Message filled out for u/${recipient} - NOT sending`);
this.logger.info(`[DRY RUN] Subject: ${subject}`);
this.logger.info(`[DRY RUN] Body: ${body.slice(0, 100)}...`);
this.logger.info(`[DRY RUN] Pausing 10 seconds so you can review the form...`);
await this.page.waitForTimeout(10000);
return true;
}
// Find and click submit button
const submitButton =
(await this.page.$('button[type="submit"]')) ||
(await this.page.$('button:has-text("Send")')) ||
(await this.page.$('input[type="submit"]'));
if (!submitButton) {
throw new Error('Could not find submit button');
}
await submitButton.click();
// Wait for success indication
try {
await Promise.race([
this.page.waitForURL(/\/message\/sent/, { timeout: 15000 }),
this.page.waitForSelector('.success, [class*="success"], [class*="Success"]', { timeout: 15000 }),
]);
this.logger.info(`Message sent successfully to u/${recipient}`);
return true;
} catch {
// Check if we're on a success page or got any error
const currentUrl = this.page.url();
if (currentUrl.includes('/message/sent') || currentUrl.includes('success')) {
this.logger.info(`Message sent successfully to u/${recipient}`);
return true;
}
// Check for error messages
const errorElement = await this.page.$('.error, [class*="error"], [class*="Error"]');
if (errorElement) {
const errorText = await errorElement.textContent();
throw new Error(`Reddit error: ${errorText}`);
}
throw new Error('Message send status unclear');
}
} catch (error) {
this.logger.error(`Failed to send message to u/${recipient}:`, error);
return false;
}
}
private async typeWithDelay(text: string): Promise<void> {
// Type with human-like delays
for (const char of text) {
await this.page.keyboard.type(char, { delay: 20 + Math.random() * 30 });
}
}
async isLoggedIn(): Promise<boolean> {
try {
await this.page.goto('https://www.reddit.com', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
// Wait for page to stabilize
await this.page.waitForTimeout(2000);
// Check for user menu button (indicates logged in)
const userButton = await this.page.$('[data-testid="user-drawer-button"]');
if (userButton) {
const isVisible = await userButton.isVisible();
return isVisible;
}
// Alternative check - look for avatar or user icon
const avatar = await this.page.$('[class*="avatar"], [class*="Avatar"]');
if (avatar) {
return true;
}
return false;
} catch (error) {
this.logger.error('Failed to check login status:', error);
return false;
}
}
}
+73
View File
@@ -0,0 +1,73 @@
import { z } from 'zod';
import fs from 'fs';
import path from 'path';
import YAML from 'yaml';
import dotenv from 'dotenv';
dotenv.config();
export const ConfigSchema = z.object({
reddit: z.object({
subreddits: z.array(z.string()).min(1),
pollIntervalMs: z.number().default(120000),
userAgent: z.string().default('RedditBot/1.0'),
targetGenders: z.array(z.enum(['male', 'female', 'other', 'unknown'])).default(['female']),
}),
openrouter: z.object({
apiKey: z.string(),
model: z.string().default('google/gemini-3-flash-preview'),
imageAnalysisPrompt: z.string(),
messageGenerationPrompt: z.string(),
}),
rateLimit: z.object({
minDelayBetweenMessagesMs: z.number().default(300000),
maxMessagesPerHour: z.number().default(8),
jitterMs: z.number().default(60000),
}),
browser: z.object({
sessionDir: z.string().default('./data/browser-session'),
headless: z.boolean().default(false),
dryRun: z.boolean().default(false),
}),
database: z.object({
path: z.string().default('./data/reddit-bot.db'),
}),
web: z.object({
enabled: z.boolean().default(true),
port: z.number().default(3000),
}),
});
export type Config = z.infer<typeof ConfigSchema>;
export function loadConfig(): Config {
const configPath = path.resolve(process.cwd(), 'config.yaml');
if (!fs.existsSync(configPath)) {
throw new Error(`Config file not found at ${configPath}`);
}
const configFile = fs.readFileSync(configPath, 'utf-8');
const rawConfig = YAML.parse(configFile);
// Inject API key from environment
if (!rawConfig.openrouter) {
rawConfig.openrouter = {};
}
rawConfig.openrouter.apiKey = process.env.OPENROUTER_API_KEY || rawConfig.openrouter.apiKey;
if (!rawConfig.openrouter.apiKey) {
throw new Error('OPENROUTER_API_KEY environment variable is required');
}
const result = ConfigSchema.safeParse(rawConfig);
if (!result.success) {
const errors = result.error.issues
.map((e) => ` - ${String(e.path.join('.'))}: ${e.message}`)
.join('\n');
throw new Error(`Invalid configuration:\n${errors}`);
}
return result.data;
}
+83
View File
@@ -0,0 +1,83 @@
import Database from 'better-sqlite3';
import type { Config } from '../config/index.js';
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 dbDir = path.dirname(dbPath);
// Ensure directory exists
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const db = new Database(dbPath);
// Enable WAL mode for better concurrency
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
return db;
}
export function runMigrations(db: Database.Database): void {
// Create tables if they don't exist
db.exec(`
CREATE TABLE IF NOT EXISTS processed_posts (
id TEXT PRIMARY KEY,
subreddit TEXT NOT NULL,
author TEXT NOT NULL,
title TEXT NOT NULL,
image_url TEXT,
image_analysis TEXT,
generated_message TEXT,
processed_at INTEGER NOT NULL,
message_sent INTEGER DEFAULT 0,
error TEXT
);
CREATE TABLE IF NOT EXISTS sent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id TEXT NOT NULL,
recipient TEXT NOT NULL,
subject TEXT NOT NULL,
message TEXT NOT NULL,
sent_at INTEGER NOT NULL,
FOREIGN KEY (post_id) REFERENCES processed_posts(id)
);
CREATE TABLE IF NOT EXISTS successful_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sent_message_id INTEGER NOT NULL,
original_message TEXT NOT NULL,
image_analysis TEXT,
post_title TEXT,
response_snippet TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (sent_message_id) REFERENCES sent_messages(id)
);
CREATE INDEX IF NOT EXISTS idx_posts_subreddit
ON processed_posts(subreddit);
CREATE INDEX IF NOT EXISTS idx_posts_processed_at
ON processed_posts(processed_at);
CREATE INDEX IF NOT EXISTS idx_posts_message_sent
ON processed_posts(message_sent);
`);
// Add new columns to existing tables (safe to run multiple times)
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
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)`);
}
+267
View File
@@ -0,0 +1,267 @@
import type Database from 'better-sqlite3';
export interface ProcessedPostRecord {
id: string;
subreddit: string;
author: string;
title: string;
imageUrl: string | null;
imageAnalysis: string | null;
generatedMessage: string | null;
processedAt: number;
messageSent: boolean;
error: string | null;
}
export interface SentMessageRecord {
id: number;
postId: string;
recipient: string;
subject: string;
message: string;
sentAt: number;
gotResponse: boolean;
responseCheckedAt: number | null;
}
export interface SuccessfulMessageRecord {
id: number;
sentMessageId: number;
originalMessage: string;
imageAnalysis: string | null;
postTitle: string | null;
responseSnippet: string | null;
createdAt: number;
}
export class PostsRepository {
private insertStmt: Database.Statement;
private existsStmt: Database.Statement;
private markSentStmt: Database.Statement;
private updateAnalysisStmt: Database.Statement;
private setErrorStmt: Database.Statement;
private getByIdStmt: Database.Statement;
constructor(private db: Database.Database) {
this.insertStmt = db.prepare(`
INSERT INTO processed_posts (id, subreddit, author, title, image_url, processed_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
this.existsStmt = db.prepare(`SELECT 1 FROM processed_posts WHERE id = ?`);
this.markSentStmt = db.prepare(`UPDATE processed_posts SET message_sent = 1 WHERE id = ?`);
this.updateAnalysisStmt = db.prepare(`
UPDATE processed_posts
SET image_analysis = ?, generated_message = ?
WHERE id = ?
`);
this.setErrorStmt = db.prepare(`UPDATE processed_posts SET error = ? WHERE id = ?`);
this.getByIdStmt = db.prepare(`SELECT * FROM processed_posts WHERE id = ?`);
}
isProcessed(postId: string): boolean {
return this.existsStmt.get(postId) !== undefined;
}
insert(post: {
id: string;
subreddit: string;
author: string;
title: string;
imageUrl: string | null;
processedAt: number;
}): void {
this.insertStmt.run(
post.id,
post.subreddit,
post.author,
post.title,
post.imageUrl,
post.processedAt
);
}
updateAnalysis(postId: string, analysis: string, message: string): void {
this.updateAnalysisStmt.run(analysis, message, postId);
}
markMessageSent(postId: string): void {
this.markSentStmt.run(postId);
}
setError(postId: string, error: string): void {
this.setErrorStmt.run(error, postId);
}
getById(postId: string): ProcessedPostRecord | undefined {
const row = this.getByIdStmt.get(postId) as Record<string, unknown> | undefined;
if (!row) return undefined;
return {
id: row.id as string,
subreddit: row.subreddit as string,
author: row.author as string,
title: row.title as string,
imageUrl: row.image_url as string | null,
imageAnalysis: row.image_analysis as string | null,
generatedMessage: row.generated_message as string | null,
processedAt: row.processed_at as number,
messageSent: Boolean(row.message_sent),
error: row.error as string | null,
};
}
}
export class MessagesRepository {
private insertStmt: Database.Statement;
private getByPostIdStmt: Database.Statement;
private getRecentStmt: Database.Statement;
private getUncheckedStmt: Database.Statement;
private markResponseStmt: Database.Statement;
private markCheckedStmt: Database.Statement;
constructor(private db: Database.Database) {
this.insertStmt = db.prepare(`
INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at)
VALUES (?, ?, ?, ?, ?)
`);
this.getByPostIdStmt = db.prepare(`SELECT * FROM sent_messages WHERE post_id = ?`);
this.getRecentStmt = db.prepare(`
SELECT * FROM sent_messages ORDER BY sent_at DESC LIMIT ?
`);
this.getUncheckedStmt = db.prepare(`
SELECT * FROM sent_messages
WHERE got_response = 0
AND (response_checked_at IS NULL OR response_checked_at < ?)
AND sent_at < ?
ORDER BY sent_at ASC
LIMIT ?
`);
this.markResponseStmt = db.prepare(`
UPDATE sent_messages SET got_response = 1, response_checked_at = ? WHERE id = ?
`);
this.markCheckedStmt = db.prepare(`
UPDATE sent_messages SET response_checked_at = ? WHERE id = ?
`);
}
insert(message: {
postId: string;
recipient: string;
subject: string;
message: string;
sentAt: number;
}): number {
const result = this.insertStmt.run(
message.postId,
message.recipient,
message.subject,
message.message,
message.sentAt
);
return result.lastInsertRowid as number;
}
getByPostId(postId: string): SentMessageRecord | undefined {
const row = this.getByPostIdStmt.get(postId) as Record<string, unknown> | undefined;
if (!row) return undefined;
return this.mapRow(row);
}
getRecent(limit: number = 10): SentMessageRecord[] {
const rows = this.getRecentStmt.all(limit) as Record<string, unknown>[];
return rows.map((row) => this.mapRow(row));
}
getUncheckedMessages(minAgeMs: number, recheckAfterMs: number, limit: number = 10): SentMessageRecord[] {
const now = Date.now();
const minSentAt = now - minAgeMs;
const recheckBefore = now - recheckAfterMs;
const rows = this.getUncheckedStmt.all(recheckBefore, minSentAt, limit) as Record<string, unknown>[];
return rows.map((row) => this.mapRow(row));
}
markGotResponse(messageId: number): void {
this.markResponseStmt.run(Date.now(), messageId);
}
markChecked(messageId: number): void {
this.markCheckedStmt.run(Date.now(), messageId);
}
private mapRow(row: Record<string, unknown>): SentMessageRecord {
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,
sentAt: row.sent_at as number,
gotResponse: Boolean(row.got_response),
responseCheckedAt: row.response_checked_at as number | null,
};
}
}
export class SuccessfulMessagesRepository {
private insertStmt: Database.Statement;
private getRecentStmt: Database.Statement;
private countStmt: Database.Statement;
constructor(private db: Database.Database) {
this.insertStmt = db.prepare(`
INSERT INTO successful_messages (sent_message_id, original_message, image_analysis, post_title, response_snippet, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
this.getRecentStmt = db.prepare(`
SELECT * FROM successful_messages ORDER BY created_at DESC LIMIT ?
`);
this.countStmt = db.prepare(`SELECT COUNT(*) as count FROM successful_messages`);
}
insert(data: {
sentMessageId: number;
originalMessage: string;
imageAnalysis: string | null;
postTitle: string | null;
responseSnippet: string | null;
}): void {
this.insertStmt.run(
data.sentMessageId,
data.originalMessage,
data.imageAnalysis,
data.postTitle,
data.responseSnippet,
Date.now()
);
}
getRecent(limit: number = 5): SuccessfulMessageRecord[] {
const rows = this.getRecentStmt.all(limit) as Record<string, unknown>[];
return rows.map((row) => ({
id: row.id as number,
sentMessageId: row.sent_message_id as number,
originalMessage: row.original_message as string,
imageAnalysis: row.image_analysis as string | null,
postTitle: row.post_title as string | null,
responseSnippet: row.response_snippet as string | null,
createdAt: row.created_at as number,
}));
}
count(): number {
const result = this.countStmt.get() as { count: number };
return result.count;
}
}
+244
View File
@@ -0,0 +1,244 @@
import { loadConfig } from './config/index.js';
import { createDatabase, runMigrations } from './database/client.js';
import { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from './database/repositories.js';
import { BrowserClient } from './browser/client.js';
import { RedditMessagePage } from './browser/message-page.js';
import { RedditApiClient } from './reddit/api.js';
import { OpenRouterClient } from './ai/openrouter.js';
import { MonitorService } from './services/monitor.js';
import { ProcessorService } from './services/processor.js';
import { ResponseCheckerService } from './services/response-checker.js';
import { RateLimiter } from './utils/rate-limiter.js';
import { createLogger } from './utils/logger.js';
import { withRetry } from './utils/retry.js';
import { WebServer } from './web/server.js';
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function main(): Promise<void> {
const logger = createLogger();
logger.info('='.repeat(60));
logger.info('Reddit Bot Starting');
logger.info('='.repeat(60));
// Load configuration
let config;
try {
config = loadConfig();
logger.info(`Loaded configuration for ${config.reddit.subreddits.length} subreddits`);
} catch (error) {
logger.error('Failed to load configuration:', error);
process.exit(1);
}
// Initialize database
const db = createDatabase(config);
runMigrations(db);
const postsRepo = new PostsRepository(db);
const messagesRepo = new MessagesRepository(db);
const successfulMessagesRepo = new SuccessfulMessagesRepository(db);
logger.info('Database initialized');
// Initialize browser
const browserClient = new BrowserClient(config, logger);
await browserClient.initialize();
const page = await browserClient.getPage();
const messagePage = new RedditMessagePage(page, logger);
// Set dry run mode if configured
messagePage.setDryRun(config.browser.dryRun);
// Verify login
logger.info('Verifying Reddit login status...');
const isLoggedIn = await messagePage.isLoggedIn();
if (!isLoggedIn) {
logger.error('Not logged in to Reddit!');
logger.error('Please run "npm run login" first to authenticate.');
await browserClient.close();
process.exit(1);
}
logger.info('Reddit login verified');
// Initialize services
const redditApi = new RedditApiClient(config, logger);
redditApi.setBrowserContext(browserClient.getContext()!);
const aiClient = new OpenRouterClient(config, logger);
const monitorService = new MonitorService(redditApi, postsRepo, config, logger);
const processorService = new ProcessorService(redditApi, aiClient, config, logger);
const responseChecker = new ResponseCheckerService(
page,
messagesRepo,
successfulMessagesRepo,
postsRepo,
logger
);
const rateLimiter = new RateLimiter(config, logger);
// Initialize web dashboard
let webServer: WebServer | null = null;
if (config.web.enabled) {
webServer = new WebServer(
config.web.port,
config,
postsRepo,
messagesRepo,
successfulMessagesRepo,
logger
);
webServer.start();
}
// Load any existing successful examples
const existingExamples = responseChecker.getSuccessfulExamples(3);
processorService.setSuccessfulExamples(existingExamples);
const successCount = responseChecker.getSuccessCount();
if (successCount > 0) {
logger.info(`Loaded ${successCount} successful message(s) from history`);
}
// Graceful shutdown handler
let isShuttingDown = false;
const shutdown = async (): Promise<void> => {
if (isShuttingDown) return;
isShuttingDown = true;
logger.info('Shutting down gracefully...');
webServer?.stop();
await browserClient.close();
db.close();
logger.info('Goodbye!');
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
logger.info('');
logger.info('Bot is now running!');
logger.info(`Monitoring subreddits: ${config.reddit.subreddits.join(', ')}`);
logger.info(`Poll interval: ${config.reddit.pollIntervalMs / 1000} seconds`);
logger.info(`Min delay between messages: ${config.rateLimit.minDelayBetweenMessagesMs / 1000} seconds`);
logger.info(`Max messages per hour: ${config.rateLimit.maxMessagesPerHour}`);
logger.info('');
// Track poll cycles for periodic response checking
let pollCycle = 0;
const RESPONSE_CHECK_INTERVAL = 5; // Check for responses every 5 poll cycles
// Main loop
while (!isShuttingDown) {
try {
pollCycle++;
// Periodically check for responses to learn from
if (pollCycle % RESPONSE_CHECK_INTERVAL === 0) {
logger.debug('Checking for responses to sent messages...');
const responses = await responseChecker.checkForResponses();
if (responses.some((r) => r.gotResponse)) {
// Reload successful examples
const updatedExamples = responseChecker.getSuccessfulExamples(3);
processorService.setSuccessfulExamples(updatedExamples);
}
}
// Get unprocessed posts
const posts = await monitorService.getUnprocessedPosts();
if (posts.length > 0) {
logger.info(`Found ${posts.length} unprocessed posts`);
}
for (const post of posts) {
if (isShuttingDown) break;
try {
// Mark as processed immediately to avoid duplicates on restart
postsRepo.insert({
id: post.id,
subreddit: post.subreddit,
author: post.author,
title: post.title,
imageUrl: redditApi.getImageUrl(post),
processedAt: Date.now(),
});
// Process post (download image, analyze, generate message)
const processed = await withRetry(
() => processorService.processPost(post),
{ logger, retries: 2, operationName: 'Post processing' }
);
if (!processed) {
logger.debug(`Skipping post ${post.id} - processing returned null`);
webServer?.logActivity('skipped', `Skipped post by u/${post.author}`, post.author);
continue;
}
// Update database with analysis
postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage);
// Create subject line
const subject = `Re: ${post.title.slice(0, 50)}${post.title.length > 50 ? '...' : ''}`;
// Send message with rate limiting
await rateLimiter.throttle(async () => {
logger.info(`Sending message to u/${post.author}...`);
const success = await messagePage.sendMessage(
post.author,
subject,
processed.generatedMessage
);
if (success) {
postsRepo.markMessageSent(post.id);
messagesRepo.insert({
postId: post.id,
recipient: post.author,
subject,
message: processed.generatedMessage,
sentAt: Date.now(),
});
const stats = rateLimiter.getStats();
logger.info(
`Message sent! (${stats.messagesThisHour}/${config.rateLimit.maxMessagesPerHour} this hour)`
);
webServer?.logActivity('sent', `Sent message to u/${post.author}`, post.author);
webServer?.updateStats(stats.messagesThisHour);
} else {
postsRepo.setError(post.id, 'Failed to send message');
webServer?.logActivity('error', `Failed to send to u/${post.author}`, post.author);
}
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Error processing post ${post.id}:`, error);
postsRepo.setError(post.id, errorMessage);
}
}
if (!isShuttingDown) {
// Wait before next poll
logger.debug(`Waiting ${config.reddit.pollIntervalMs / 1000} seconds before next poll...`);
await sleep(config.reddit.pollIntervalMs);
}
} catch (error) {
logger.error('Error in main loop:', error);
// Wait a minute on error before retrying
await sleep(60000);
}
}
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
+186
View File
@@ -0,0 +1,186 @@
import axios, { AxiosInstance } from 'axios';
import type { BrowserContext } from 'playwright';
import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js';
export interface RedditPost {
id: string;
name: string; // fullname like "t3_xxxxx"
subreddit: string;
author: string;
title: string;
url: string;
permalink: string;
thumbnail: string;
is_self: boolean;
post_hint?: string;
preview?: {
images: Array<{
source: { url: string; width: number; height: number };
resolutions: Array<{ url: string; width: number; height: number }>;
}>;
};
created_utc: number;
over_18: boolean;
spoiler: boolean;
}
export class RedditApiClient {
private client: AxiosInstance;
private browserContext: BrowserContext | null = null;
constructor(
config: Config,
private logger: Logger
) {
this.client = axios.create({
baseURL: 'https://www.reddit.com',
headers: {
'User-Agent': config.reddit.userAgent,
Accept: 'application/json',
},
timeout: 30000,
});
}
setBrowserContext(context: BrowserContext): void {
this.browserContext = context;
this.logger.debug('Browser context set for authenticated API requests');
}
private async getCookieHeader(): Promise<string> {
if (!this.browserContext) {
return '';
}
try {
const cookies = await this.browserContext.cookies('https://www.reddit.com');
return cookies.map((c) => `${c.name}=${c.value}`).join('; ');
} catch (error) {
this.logger.warn('Failed to get cookies from browser context:', error);
return '';
}
}
async getNewPosts(subreddit: string, limit = 25): Promise<RedditPost[]> {
try {
const cookieHeader = await this.getCookieHeader();
const headers: Record<string, string> = {};
if (cookieHeader) {
headers['Cookie'] = cookieHeader;
}
const response = await this.client.get(`/r/${subreddit}/new.json`, {
params: { limit, raw_json: 1 },
headers,
});
const posts: RedditPost[] = response.data.data.children
.map((child: { data: RedditPost }) => child.data)
.filter((post: RedditPost) => this.isValidPost(post));
this.logger.debug(`Fetched ${posts.length} valid image posts from r/${subreddit}`);
return posts;
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response?.status === 404) {
this.logger.warn(`Subreddit r/${subreddit} not found`);
return [];
}
if (error.response?.status === 403) {
this.logger.warn(`Subreddit r/${subreddit} is private or quarantined`);
return [];
}
if (error.response?.status === 429) {
this.logger.warn(`Rate limited by Reddit, waiting...`);
throw error;
}
}
this.logger.error(`Error fetching posts from r/${subreddit}:`, error);
throw error;
}
}
private isValidPost(post: RedditPost): boolean {
// Skip self posts (text only)
if (post.is_self) return false;
// Skip deleted/removed authors
if (post.author === '[deleted]' || post.author === 'AutoModerator') return false;
// Skip NSFW if we want to be safe (configurable later)
// if (post.over_18) return false;
// Check if it's an image post
return this.hasImage(post);
}
private hasImage(post: RedditPost): boolean {
// Check post_hint
if (post.post_hint === 'image') return true;
// Check for preview images
if (post.preview?.images?.length) return true;
// Check URL patterns for direct image links
const url = post.url.toLowerCase();
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
if (imageExtensions.some((ext) => url.includes(ext))) return true;
// Check for known image hosts
const imageHosts = ['i.redd.it', 'i.imgur.com', 'imgur.com'];
if (imageHosts.some((host) => url.includes(host))) return true;
return false;
}
getImageUrl(post: RedditPost): string | null {
// Prefer high-quality preview images (already processed by Reddit)
if (post.preview?.images?.[0]?.source?.url) {
// Reddit encodes URLs in preview, need to decode HTML entities
return this.decodeRedditUrl(post.preview.images[0].source.url);
}
// Direct i.redd.it links
if (post.url.includes('i.redd.it')) {
return post.url;
}
// Direct imgur links
if (post.url.includes('i.imgur.com')) {
return post.url;
}
// Imgur album/page links - try to get direct image
if (post.url.includes('imgur.com') && !post.url.includes('i.imgur.com')) {
// Try adding .jpg extension
const imgurId = post.url.split('/').pop()?.split('.')[0];
if (imgurId) {
return `https://i.imgur.com/${imgurId}.jpg`;
}
}
// Check if URL has image extension
const url = post.url.toLowerCase();
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
if (imageExtensions.some((ext) => url.includes(ext))) {
return post.url;
}
// Use thumbnail as last resort if it's a valid URL
if (post.thumbnail && post.thumbnail.startsWith('http')) {
return post.thumbnail;
}
return null;
}
private decodeRedditUrl(url: string): string {
return url.replace(/&amp;/g, '&');
}
getPostUrl(post: RedditPost): string {
return `https://www.reddit.com${post.permalink}`;
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { RedditApiClient, RedditPost } from '../reddit/api.js';
import type { PostsRepository } from '../database/repositories.js';
import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js';
export class MonitorService {
constructor(
private redditApi: RedditApiClient,
private postsRepo: PostsRepository,
private config: Config,
private logger: Logger
) {}
async getUnprocessedPosts(): Promise<RedditPost[]> {
const unprocessedPosts: RedditPost[] = [];
for (const subreddit of this.config.reddit.subreddits) {
try {
this.logger.debug(`Checking r/${subreddit} for new posts...`);
const posts = await this.redditApi.getNewPosts(subreddit);
let newCount = 0;
for (const post of posts) {
if (!this.postsRepo.isProcessed(post.id)) {
unprocessedPosts.push(post);
newCount++;
}
}
if (newCount > 0) {
this.logger.info(`Found ${newCount} new posts in r/${subreddit}`);
}
} catch (error) {
this.logger.error(`Failed to fetch posts from r/${subreddit}:`, error);
}
// Small delay between subreddit fetches to be nice to Reddit
await this.sleep(1000);
}
// Sort by creation time (oldest first, so we process in order)
unprocessedPosts.sort((a, b) => a.created_utc - b.created_utc);
return unprocessedPosts;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
+101
View File
@@ -0,0 +1,101 @@
import type { RedditApiClient, RedditPost } from '../reddit/api.js';
import type { OpenRouterClient, DetectedGender } from '../ai/openrouter.js';
import type { Logger } from '../utils/logger.js';
import type { Config } from '../config/index.js';
import { downloadImage, bufferToBase64, isValidImageMimeType } from '../utils/image.js';
export interface ProcessedPost {
post: RedditPost;
imageAnalysis: string;
detectedGender: DetectedGender;
generatedMessage: string;
}
export class ProcessorService {
private successfulExamples: string[] = [];
constructor(
private redditApi: RedditApiClient,
private aiClient: OpenRouterClient,
private config: Config,
private logger: Logger
) {}
setSuccessfulExamples(examples: string[]): void {
this.successfulExamples = examples;
if (examples.length > 0) {
this.logger.info(`Loaded ${examples.length} successful message examples for learning`);
}
}
async processPost(post: RedditPost): Promise<ProcessedPost | null> {
const imageUrl = this.redditApi.getImageUrl(post);
if (!imageUrl) {
this.logger.warn(`No image URL found for post ${post.id}`);
return null;
}
this.logger.info(`Processing post: "${post.title.slice(0, 50)}..." by u/${post.author}`);
// Download image
let imageData;
try {
imageData = await downloadImage(imageUrl, this.config.reddit.userAgent);
} catch (error) {
this.logger.error(`Failed to download image from ${imageUrl}:`, error);
return null;
}
// Validate image type
if (!isValidImageMimeType(imageData.mimeType)) {
this.logger.warn(`Invalid image type ${imageData.mimeType} for post ${post.id}`);
return null;
}
// Check image size (skip very small images like thumbnails)
if (imageData.buffer.length < 5000) {
this.logger.warn(`Image too small (${imageData.buffer.length} bytes) for post ${post.id}`);
return null;
}
// Convert to base64
const base64 = bufferToBase64(imageData.buffer);
// Quick gender check first (saves API costs on non-matching posts)
const targetGenders = this.config.reddit.targetGenders;
if (targetGenders.length > 0) {
const detectedGender = await this.aiClient.detectGenderOnly(base64, imageData.mimeType);
if (!targetGenders.includes(detectedGender)) {
this.logger.info(
`Skipping post ${post.id} - detected gender "${detectedGender}" not in target list [${targetGenders.join(', ')}]`
);
return null;
}
this.logger.debug(`Gender check passed: ${detectedGender}`);
}
// Full analysis and message generation (only for matching posts)
const { analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
base64,
imageData.mimeType,
post.title,
post.author,
this.successfulExamples
);
if (!message || message.length < 10) {
this.logger.warn(`Generated message too short or empty for post ${post.id}`);
return null;
}
return {
post,
imageAnalysis: analysis.description,
detectedGender: analysis.detectedGender,
generatedMessage: message,
};
}
}
+164
View File
@@ -0,0 +1,164 @@
import type { Page } from 'playwright';
import type { Logger } from '../utils/logger.js';
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord } from '../database/repositories.js';
import type { PostsRepository } from '../database/repositories.js';
export interface ResponseCheckResult {
messageId: number;
recipient: string;
gotResponse: boolean;
responseSnippet?: string;
}
export class ResponseCheckerService {
constructor(
private page: Page,
private messagesRepo: MessagesRepository,
private successfulMessagesRepo: SuccessfulMessagesRepository,
private postsRepo: PostsRepository,
private logger: Logger
) {}
async checkForResponses(): Promise<ResponseCheckResult[]> {
const results: ResponseCheckResult[] = [];
// Get messages that haven't been checked recently (at least 1 hour old, recheck every 6 hours)
const minAgeMs = 60 * 60 * 1000; // 1 hour
const recheckAfterMs = 6 * 60 * 60 * 1000; // 6 hours
const uncheckedMessages = this.messagesRepo.getUncheckedMessages(minAgeMs, recheckAfterMs, 5);
if (uncheckedMessages.length === 0) {
this.logger.debug('No messages to check for responses');
return results;
}
this.logger.info(`Checking ${uncheckedMessages.length} messages for responses...`);
// Navigate to inbox
try {
await this.page.goto('https://www.reddit.com/message/inbox/', {
waitUntil: 'networkidle',
timeout: 30000,
});
await this.page.waitForTimeout(2000);
// Get all message threads/conversations
const conversations = await this.getInboxConversations();
for (const message of uncheckedMessages) {
const result = await this.checkMessageResponse(message, conversations);
results.push(result);
if (result.gotResponse) {
// Mark as got response
this.messagesRepo.markGotResponse(message.id);
// Get the original post info
const post = this.postsRepo.getById(message.postId);
// Store as successful message for learning
this.successfulMessagesRepo.insert({
sentMessageId: message.id,
originalMessage: message.message,
imageAnalysis: post?.imageAnalysis || null,
postTitle: post?.title || null,
responseSnippet: result.responseSnippet || null,
});
this.logger.info(`Got response from u/${message.recipient}! Stored for learning.`);
} else {
// Just mark as checked
this.messagesRepo.markChecked(message.id);
}
}
} catch (error) {
this.logger.error('Failed to check inbox for responses:', error);
}
return results;
}
private async getInboxConversations(): Promise<Map<string, { hasReply: boolean; snippet: string }>> {
const conversations = new Map<string, { hasReply: boolean; snippet: string }>();
try {
// Try to get conversation items from inbox
const messageItems = await this.page.$$('.message, [data-testid="message"], .thing.message');
for (const item of messageItems) {
try {
// Get the author/recipient of the conversation
const authorEl = await item.$('.author, [data-testid="message-author"], .sender a');
const author = authorEl ? await authorEl.textContent() : null;
if (!author) continue;
const cleanAuthor = author.replace(/^u\//, '').replace(/^\/u\//, '').trim();
// Check if this is a reply (not from us)
const isReply = await item.evaluate((el) => {
// Check various indicators that this is a received message, not sent
return !el.classList.contains('sent') && !el.querySelector('.sent');
});
// Get message snippet
const bodyEl = await item.$('.md, .message-body, [data-testid="message-body"]');
const snippet = bodyEl ? (await bodyEl.textContent())?.slice(0, 200) || '' : '';
if (isReply && cleanAuthor) {
conversations.set(cleanAuthor.toLowerCase(), {
hasReply: true,
snippet: snippet.trim(),
});
}
} catch {
// Skip problematic items
}
}
} catch (error) {
this.logger.warn('Failed to parse inbox conversations:', error);
}
return conversations;
}
private async checkMessageResponse(
message: SentMessageRecord,
conversations: Map<string, { hasReply: boolean; snippet: string }>
): Promise<ResponseCheckResult> {
const recipientLower = message.recipient.toLowerCase();
const conversation = conversations.get(recipientLower);
if (conversation?.hasReply) {
return {
messageId: message.id,
recipient: message.recipient,
gotResponse: true,
responseSnippet: conversation.snippet,
};
}
return {
messageId: message.id,
recipient: message.recipient,
gotResponse: false,
};
}
getSuccessfulExamples(limit: number = 3): string[] {
const successful = this.successfulMessagesRepo.getRecent(limit);
return successful.map((s) => {
let example = `Message: "${s.originalMessage}"`;
if (s.postTitle) {
example = `Post: "${s.postTitle}"\n${example}`;
}
return example;
});
}
getSuccessCount(): number {
return this.successfulMessagesRepo.count();
}
}
+34
View File
@@ -0,0 +1,34 @@
import axios from 'axios';
export interface DownloadedImage {
buffer: Buffer;
mimeType: string;
}
export async function downloadImage(url: string, userAgent: string): Promise<DownloadedImage> {
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 30000,
headers: {
'User-Agent': userAgent,
Accept: 'image/*',
},
maxRedirects: 5,
});
const contentType = response.headers['content-type'] || 'image/jpeg';
return {
buffer: Buffer.from(response.data),
mimeType: contentType.split(';')[0].trim(),
};
}
export function bufferToBase64(buffer: Buffer): string {
return buffer.toString('base64');
}
export function isValidImageMimeType(mimeType: string): boolean {
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
return validTypes.includes(mimeType);
}
+32
View File
@@ -0,0 +1,32 @@
import winston from 'winston';
export type Logger = winston.Logger;
export function createLogger(): Logger {
return winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ level, message, timestamp, ...meta }) => {
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
return `${timestamp} [${level}]: ${message}${metaStr}`;
})
),
}),
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
}),
new winston.transports.File({
filename: 'logs/combined.log',
}),
],
});
}
+66
View File
@@ -0,0 +1,66 @@
import PQueue from 'p-queue';
import type { Config } from '../config/index.js';
import type { Logger } from './logger.js';
export class RateLimiter {
private queue: PQueue;
private lastMessageTime: number = 0;
private messagesThisHour: number = 0;
private hourStartTime: number = Date.now();
constructor(
private config: Config,
private logger: Logger
) {
this.queue = new PQueue({ concurrency: 1 });
}
async throttle<T>(fn: () => Promise<T>): Promise<T> {
return this.queue.add(async () => {
const now = Date.now();
// Reset hourly counter if needed
if (now - this.hourStartTime > 3600000) {
this.messagesThisHour = 0;
this.hourStartTime = now;
this.logger.debug('Hourly rate limit counter reset');
}
// Check hourly limit
if (this.messagesThisHour >= this.config.rateLimit.maxMessagesPerHour) {
const waitTime = 3600000 - (now - this.hourStartTime);
this.logger.info(`Hourly limit reached, waiting ${Math.round(waitTime / 60000)} minutes`);
await this.sleep(waitTime);
this.messagesThisHour = 0;
this.hourStartTime = Date.now();
}
// Enforce minimum delay with jitter
const timeSinceLastMessage = now - this.lastMessageTime;
const minDelay = this.config.rateLimit.minDelayBetweenMessagesMs;
const jitter = Math.random() * this.config.rateLimit.jitterMs;
if (this.lastMessageTime > 0 && timeSinceLastMessage < minDelay) {
const waitTime = minDelay - timeSinceLastMessage + jitter;
this.logger.debug(`Rate limiting: waiting ${Math.round(waitTime / 1000)} seconds`);
await this.sleep(waitTime);
}
this.lastMessageTime = Date.now();
this.messagesThisHour++;
return fn();
}) as Promise<T>;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
getStats(): { messagesThisHour: number; minutesSinceLastMessage: number } {
return {
messagesThisHour: this.messagesThisHour,
minutesSinceLastMessage: Math.round((Date.now() - this.lastMessageTime) / 60000),
};
}
}
+28
View File
@@ -0,0 +1,28 @@
import pRetry from 'p-retry';
import type { Logger } from './logger.js';
export async function withRetry<T>(
fn: () => Promise<T>,
options: {
retries?: number;
logger?: Logger;
operationName?: string;
} = {}
): Promise<T> {
const { retries = 3, logger, operationName = 'operation' } = options;
return pRetry(fn, {
retries,
minTimeout: 5000,
maxTimeout: 60000,
factor: 2,
randomize: true,
onFailedAttempt: (context) => {
const errorMessage = context.error instanceof Error ? context.error.message : String(context.error);
logger?.warn(
`${operationName} attempt ${context.attemptNumber} failed: ${errorMessage}. ` +
`${context.retriesLeft} retries left.`
);
},
});
}
+140
View File
@@ -0,0 +1,140 @@
import express from 'express';
import path from 'path';
import type { Server } from 'http';
import type { Logger } from '../utils/logger.js';
import type { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from '../database/repositories.js';
import type { Config } from '../config/index.js';
export interface BotStats {
isRunning: boolean;
dryRunMode: boolean;
totalProcessed: number;
totalMessagesSent: number;
totalResponses: number;
responseRate: number;
messagesThisHour: number;
maxMessagesPerHour: number;
subreddits: string[];
targetGenders: string[];
recentActivity: ActivityItem[];
}
export interface ActivityItem {
timestamp: number;
type: 'sent' | 'skipped' | 'response' | 'error';
message: string;
recipient?: string;
}
export class WebServer {
private app: express.Application;
private server: Server | null = null;
private activityLog: ActivityItem[] = [];
private botStats = {
isRunning: true,
messagesThisHour: 0,
};
constructor(
private port: number,
private config: Config,
private postsRepo: PostsRepository,
private messagesRepo: MessagesRepository,
private successfulMessagesRepo: SuccessfulMessagesRepository,
private logger: Logger
) {
this.app = express();
this.setupRoutes();
}
private setupRoutes(): void {
// Serve static files
this.app.use(express.static(path.join(process.cwd(), 'public')));
this.app.use(express.json());
// API: Get stats
this.app.get('/api/stats', (req, res) => {
const stats = this.getStats();
res.json(stats);
});
// API: Get recent activity
this.app.get('/api/activity', (req, res) => {
res.json(this.activityLog.slice(-50).reverse());
});
// API: Get recent messages
this.app.get('/api/messages', (req, res) => {
const messages = this.messagesRepo.getRecent(20);
res.json(messages);
});
// API: Get successful messages (ones that got responses)
this.app.get('/api/successful', (req, res) => {
const successful = this.successfulMessagesRepo.getRecent(10);
res.json(successful);
});
// Serve dashboard for all other routes
this.app.get('*', (req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
});
}
private getStats(): BotStats {
const messages = this.messagesRepo.getRecent(1000);
const totalSent = messages.length;
const totalResponses = messages.filter((m) => m.gotResponse).length;
return {
isRunning: this.botStats.isRunning,
dryRunMode: this.config.browser.dryRun,
totalProcessed: this.getTotalProcessed(),
totalMessagesSent: totalSent,
totalResponses,
responseRate: totalSent > 0 ? Math.round((totalResponses / totalSent) * 100) : 0,
messagesThisHour: this.botStats.messagesThisHour,
maxMessagesPerHour: this.config.rateLimit.maxMessagesPerHour,
subreddits: this.config.reddit.subreddits,
targetGenders: this.config.reddit.targetGenders,
recentActivity: this.activityLog.slice(-10).reverse(),
};
}
private getTotalProcessed(): number {
// This is a simple count - in production you'd query the DB
return this.activityLog.filter((a) => a.type === 'sent' || a.type === 'skipped').length;
}
logActivity(type: ActivityItem['type'], message: string, recipient?: string): void {
const item: ActivityItem = {
timestamp: Date.now(),
type,
message,
recipient,
};
this.activityLog.push(item);
// Keep only last 200 items
if (this.activityLog.length > 200) {
this.activityLog = this.activityLog.slice(-200);
}
}
updateStats(messagesThisHour: number): void {
this.botStats.messagesThisHour = messagesThisHour;
}
start(): void {
this.server = this.app.listen(this.port, () => {
this.logger.info(`Web dashboard running at http://localhost:${this.port}`);
});
}
stop(): void {
if (this.server) {
this.server.close();
this.server = null;
}
}
}