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>
180 lines
6.1 KiB
TypeScript
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();
|
|
}
|
|
}
|