Files

202 lines
7.2 KiB
TypeScript

import type { Page } from 'playwright';
import type { Logger } from '../utils/logger.js';
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ConversationOutcomesRepository } from '../database/repositories.js';
import type { PostsRepository } from '../database/repositories.js';
import { recordConversationReply } from './conversation-outcome-recorder.js';
import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.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,
private accountAuth: AccountAuthGate = denyAccountAuth,
private outcomesRepo?: ConversationOutcomesRepository,
) { }
async checkForResponses(): Promise<ResponseCheckResult[]> {
const results: ResponseCheckResult[] = [];
if (!this.accountAuth.isFullyAuthenticated()) return results;
// 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) {
const hasPerformanceContext = Boolean(
message.postId || message.strategyId || message.subreddit,
);
if (!hasPerformanceContext) {
this.messagesRepo.markChecked(message.id);
this.logger.info(
`Response from u/${message.recipient} excluded from performance: no post, strategy, or subreddit context.`,
);
continue;
}
const post = message.postId ? this.postsRepo.getById(message.postId) : undefined;
const attributedMessage = {
...message,
subreddit: message.subreddit ?? post?.subreddit,
postTitle: message.postTitle ?? post?.title,
postImageAnalysis: message.postImageAnalysis ?? post?.imageAnalysis ?? undefined,
};
if (this.outcomesRepo) {
recordConversationReply(
attributedMessage,
result.responseSnippet ?? '',
{ messageCount: 2, reciprocal: false },
{
messages: this.messagesRepo,
successful: this.successfulMessagesRepo,
outcomes: this.outcomesRepo,
logger: this.logger,
},
);
} else {
this.messagesRepo.markGotResponse(message.id);
this.logger.warn('Outcome repository unavailable; response recorded but excluded from 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();
}
}