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

+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();
}
}