Files
RedditBot/src/services/response-checker.ts
T
jjbruntonandClaude Opus 4.7 05c95db6bd Record sent chats in messages repo and blur gallery by default
Threads MessagesRepository through ChatService so first outgoing chat
messages are persisted for stats, and extends the repo + DB client to
support chat-room-keyed lookups. Adds tests covering the new behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 18:48:14 +01:00

180 lines
6.1 KiB
TypeScript

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: 'domcontentloaded',
timeout: 30000,
});
await this.page.waitForTimeout(2000);
// Get all message threads/conversations
const conversations = await this.getInboxConversations();
this.logger.debug(`Found ${conversations.size} conversations in inbox`);
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 = message.postId ? this.postsRepo.getById(message.postId) : undefined;
// 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,
subreddit: post?.subreddit || 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) || '' : '';
this.logger.debug(`Checking inbox item from: ${cleanAuthor}, isReply: ${isReply}`);
if (isReply && cleanAuthor) {
conversations.set(cleanAuthor.toLowerCase(), {
hasReply: true,
snippet: snippet.trim(),
});
this.logger.info(`Found reply from ${cleanAuthor}`);
}
} 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);
this.logger.debug(`Checking response for ${message.recipient} (key: ${recipientLower}) - Found: ${!!conversation}`);
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, subreddit?: string): string[] {
const successful = subreddit
? this.successfulMessagesRepo.getWeightedExamples(subreddit, limit)
: this.successfulMessagesRepo.getRecent(limit);
return successful.map((s) => {
let example = `Message: "${s.originalMessage}"`;
if (s.imageAnalysis) {
example = `Context (Image Analysis): "${s.imageAnalysis.slice(0, 300)}..."\n${example}`;
}
if (s.postTitle) {
example = `Post Title: "${s.postTitle}"\n${example}`;
}
if (s.responseSnippet) {
example += `\nTheir reply: "${s.responseSnippet.slice(0, 150)}"`;
}
return example;
});
}
getSuccessCount(): number {
return this.successfulMessagesRepo.count();
}
}