Initial
This commit is contained in:
commit
c552956516
23 files changed
+5193
No files matched your search
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)`);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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(/&/g, '&');
|
||||
}
|
||||
|
||||
getPostUrl(post: RedditPost): string {
|
||||
return `https://www.reddit.com${post.permalink}`;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.`
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user