This commit is contained in:
jjbrunton committed 2026-01-05 00:17:06 +00:00
1 parent b780bff850
commit 7b3ea37127
1752 files changed
+1341 -166

No files matched your search

+604
View File
@@ -0,0 +1,604 @@
import type { Page } from 'playwright';
import type { Logger } from '../utils/logger.js';
import type { SuccessfulMessagesRepository } from '../database/repositories.js';
export interface ChatMessage {
sender: string;
text: string;
timestamp: number;
isSelf: boolean;
}
export interface ActiveChat {
username: string;
roomName: string;
lastMessageTime: number;
lastMessageSnippet: string;
isFromUs: boolean;
unread: boolean;
url?: string;
roomId?: string;
messages?: ChatMessage[];
originMessage?: string; // The first message we sent that started the conversation
}
export class ChatService {
private cachedChats: ActiveChat[] = [];
private lastFetchTime: number = 0;
private readonly CACHE_DURATION_MS = 60 * 1000; // 1 minute cache
constructor(
private page: Page,
private logger: Logger,
private successfulMessagesRepo?: SuccessfulMessagesRepository
) { }
async getActiveChats(): Promise<ActiveChat[]> {
// Return cached data if fresh enough
const now = Date.now();
if (now - this.lastFetchTime < this.CACHE_DURATION_MS && this.cachedChats.length > 0) {
return this.cachedChats;
}
try {
this.logger.debug('Fetching active chats from chat.reddit.com...');
// Navigate to chat.reddit.com
await this.page.goto('https://chat.reddit.com/', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
// Wait for the chat app to initialize
await this.page.waitForTimeout(5000);
const chats = await this.scrapeChats();
// Fetch full history for each chat
this.logger.info(`Fetching full history for ${chats.length} chats...`);
for (const chat of chats) {
try {
await this.loadConversationDetails(chat);
} catch (err) {
this.logger.error(`Failed to load conversation for ${chat.roomName}`, err);
}
}
// Filter to last 96 hours for the "active" view
const cutoffTime = now - (96 * 60 * 60 * 1000);
this.cachedChats = chats.filter(chat => chat.lastMessageTime >= cutoffTime);
this.lastFetchTime = now;
this.logger.info(`Found ${this.cachedChats.length} active chats`);
return this.cachedChats;
} catch (error) {
this.logger.error('Failed to fetch active chats:', error);
return this.cachedChats;
}
}
async loadConversationDetails(chat: ActiveChat): Promise<void> {
if (!chat.url) {
this.logger.warn(`No URL for chat ${chat.roomName}, skipping details.`);
return;
}
this.logger.debug(`Loading conversation: ${chat.roomName} (${chat.url})`);
try {
await this.page.goto(chat.url, { waitUntil: 'domcontentloaded', timeout: 15000 });
} catch (navError) {
this.logger.warn(`Navigation to ${chat.url} timed out or failed`);
throw navError;
}
await this.page.waitForTimeout(2500); // Wait for messages to load
// First, try to extract the actual recipient username from the conversation header
// In Reddit chat, the header typically shows the other user's name
const recipientUsername = await this.extractRecipientUsername();
if (recipientUsername && recipientUsername !== chat.roomName) {
chat.username = recipientUsername;
this.logger.debug(`Extracted recipient username: ${recipientUsername} (room: ${chat.roomName})`);
}
// Now scrape all messages in the conversation
const messages = await this.scrapeConversationMessages(chat.username);
if (messages.length > 0) {
chat.messages = messages;
chat.lastMessageSnippet = messages[messages.length - 1].text;
// Find the origin message - the first message we sent
const firstSentMessage = messages.find(m => m.isSelf);
if (firstSentMessage) {
chat.originMessage = firstSentMessage.text;
this.logger.debug(`[ORIGIN MESSAGE] To: ${chat.username} | Room: "${chat.roomName}" | First message: "${firstSentMessage.text.slice(0, 100)}..."`);
}
// Check if they replied (any non-self message means they responded)
const hasReply = messages.some(m => !m.isSelf);
// If we have an origin message AND they replied, store as successful example
if (firstSentMessage && hasReply && this.successfulMessagesRepo) {
// Check if we already stored this message
if (!this.successfulMessagesRepo.existsByMessage(firstSentMessage.text)) {
// Get the first reply snippet for context
const firstReply = messages.find(m => !m.isSelf);
const replySnippet = firstReply?.text.slice(0, 200) || null;
// Store as successful message
try {
this.successfulMessagesRepo.insert({
sentMessageId: null, // No linked sent message record for chat-originated messages
originalMessage: firstSentMessage.text,
imageAnalysis: null, // We don't have image analysis for chat messages
postTitle: `Chat with ${chat.username}`,
responseSnippet: replySnippet,
});
this.logger.info(`[SUCCESS] Stored successful chat example from ${chat.username}!`);
} catch (err) {
this.logger.warn(`Failed to store successful chat example:`, err);
}
}
}
// Log full conversation for debugging
this.logger.debug(`[CONVERSATION] ${chat.username} (${messages.length} messages):${hasReply ? ' [HAS REPLY]' : ''}`);
for (const msg of messages) {
const prefix = msg.isSelf ? ' [ME]' : ` [${msg.sender}]`;
this.logger.debug(`${prefix}: ${msg.text.slice(0, 80)}${msg.text.length > 80 ? '...' : ''}`);
}
} else {
this.logger.debug(`No messages found in conversation ${chat.roomName}`);
}
}
async verifyMessageSent(recipient: string, messageText: string): Promise<boolean> {
this.logger.info(`Verifying message to ${recipient} in chat...`);
try {
await this.page.goto('https://chat.reddit.com/', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await this.page.waitForTimeout(5000);
const chats = await this.scrapeChats();
// Look for chat with recipient
const chat = chats.find(c =>
(c.username && c.username.toLowerCase() === recipient.toLowerCase()) ||
(c.roomName && c.roomName.toLowerCase() === recipient.toLowerCase())
);
if (!chat) {
this.logger.warn(`Verification failed: Could not find chat with ${recipient}`);
return false;
}
// Verify the last message is from us
if (!chat.isFromUs) {
this.logger.warn(`Verification failed: Last message is not from us (to ${recipient})`);
return false;
}
// Verify content (loose match as snippet might be truncated)
const snippet = chat.lastMessageSnippet.toLowerCase().trim();
const expected = messageText.toLowerCase().trim();
// Check if one contains the other (handling truncation)
if (expected.includes(snippet) || snippet.includes(expected) || snippet.includes(expected.slice(0, 20))) {
this.logger.info(`Message verified in chat for ${recipient}`);
return true;
}
this.logger.warn(`Verification warning: Text mismatch. Snippet: "${chat.lastMessageSnippet}", Expected: "${messageText.slice(0, 50)}..."`);
// Even if text mismatch slightly, if it's from us and time is recent, likely it's the one.
// But let's be strict or lenient?
// If checking "straight after", assume it's the one if timestamp is very recent?
// "lastMessageTime" is in chat object.
const now = Date.now();
if (now - chat.lastMessageTime < 60000) { // < 1 minute ago
this.logger.info(`Message text mismatch but timestamp is very recent (${Math.round((now - chat.lastMessageTime) / 1000)}s ago). Assuming success.`);
return true;
}
return false;
} catch (error) {
this.logger.error(`Error verifying message to ${recipient}:`, error);
return false;
}
}
private async extractRecipientUsername(): Promise<string | null> {
try {
// Use raw JavaScript to avoid TypeScript transformation issues
// Reddit chat uses Shadow DOM, so we traverse shadow roots
const recipientUsername = await this.page.evaluate(`
(function() {
var myUsername = 'Upset_Marsupial1006';
// Find the rs-room element which contains only the message area (not sidebar)
function findRsRoom() {
var rsApp = document.querySelector('rs-app');
if (!rsApp || !rsApp.shadowRoot) return null;
return rsApp.shadowRoot.querySelector('rs-room');
}
function findAllSpans(root) {
if (!root) return [];
var spans = Array.from(root.querySelectorAll('span'));
var allElements = root.querySelectorAll('*');
for (var i = 0; i < allElements.length; i++) {
var el = allElements[i];
if (el.shadowRoot) {
spans = spans.concat(findAllSpans(el.shadowRoot));
}
}
return spans;
}
var rsRoom = findRsRoom();
if (!rsRoom) return null;
// Start from rs-room's shadow root if it has one
var searchRoot = rsRoom.shadowRoot || rsRoom;
var allSpans = findAllSpans(searchRoot);
// Find spans with user-name class
for (var i = 0; i < allSpans.length; i++) {
var span = allSpans[i];
var text = (span.textContent || '').trim();
if (text.length === 0 || text.length > 40) continue;
if (!/^[A-Za-z0-9_-]+$/.test(text)) continue;
if (text === myUsername) continue;
if (span.className && (span.className.includes('user-name') || span.className.includes('username'))) {
return text;
}
}
// Second pass: find any alphanumeric span that's not ours
for (var i = 0; i < allSpans.length; i++) {
var span = allSpans[i];
var text = (span.textContent || '').trim();
if (text.length === 0 || text.length > 40) continue;
if (!/^[A-Za-z0-9_-]+$/.test(text)) continue;
if (text === myUsername) continue;
if (text.includes('_') || text.includes('-') || (text.length > 3 && text.length < 25)) {
return text;
}
}
return null;
})()
`);
return recipientUsername as string | null;
} catch (error) {
this.logger.warn('Failed to extract recipient username:', error);
return null;
}
}
private async scrapeConversationMessages(recipientUsername: string): Promise<ChatMessage[]> {
const messages: ChatMessage[] = [];
try {
// Use raw JavaScript string to avoid TypeScript transformation issues
const extractedMessages = await this.page.evaluate(`
(function() {
var myUsername = 'Upset_Marsupial1006';
var results = [];
// Find the rs-room element which contains only the message area (not sidebar)
function findRsRoom() {
var rsApp = document.querySelector('rs-app');
if (!rsApp || !rsApp.shadowRoot) return null;
return rsApp.shadowRoot.querySelector('rs-room');
}
function findAllSpans(root) {
if (!root) return [];
var spans = Array.from(root.querySelectorAll('span'));
var allElements = root.querySelectorAll('*');
for (var i = 0; i < allElements.length; i++) {
var el = allElements[i];
if (el.shadowRoot) {
spans = spans.concat(findAllSpans(el.shadowRoot));
}
}
return spans;
}
var rsRoom = findRsRoom();
if (!rsRoom) return [];
// Start from rs-room's shadow root if it has one
var searchRoot = rsRoom.shadowRoot || rsRoom;
var allSpans = findAllSpans(searchRoot);
// Find username spans
var usernameSpans = allSpans.filter(function(span) {
var text = (span.textContent || '').trim();
if (text.length === 0 || text.length > 40) return false;
if (!/^[A-Za-z0-9_-]+$/.test(text)) return false;
if (span.className && (span.className.includes('user-name') || span.className.includes('username'))) {
return true;
}
if (text.includes('_') || text.includes('-') || (text.length > 3 && text.length < 25)) {
return true;
}
return false;
});
// For each username span, find associated message text
for (var j = 0; j < usernameSpans.length; j++) {
var userSpan = usernameSpans[j];
var sender = (userSpan.textContent || '').trim();
if (!sender) continue;
// Walk up to find container with content
var container = userSpan.parentElement;
var attempts = 0;
while (container && attempts < 10) {
var containerText = container.innerText || container.textContent || '';
var senderEscaped = sender.replace(/[-_]/g, '\\\\$&');
var cleaned = containerText
.replace(new RegExp(senderEscaped, 'g'), '')
.replace(/\\d{1,2}:\\d{2}\\s*(AM|PM)?/gi, '')
.replace(/\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}/gi, '')
.replace(/yesterday|today|just now/gi, '')
.trim();
if (cleaned.length > 10 && cleaned.length < 3000) {
break;
}
container = container.parentElement;
attempts++;
}
if (container) {
var fullText = container.innerText || container.textContent || '';
var senderEscaped = sender.replace(/[-_]/g, '\\\\$&');
var messageText = fullText
.replace(new RegExp(senderEscaped, 'g'), '')
.replace(/\\d{1,2}:\\d{2}\\s*(AM|PM)?/gi, '')
.replace(/\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}/gi, '')
.replace(/yesterday|today|just now/gi, '')
.replace(/\\s+/g, ' ')
.replace(/^\\s*[:|-]\\s*/, '')
.trim();
if (messageText.length > 5) {
results.push({
sender: sender,
text: messageText,
isSelf: sender === myUsername
});
}
}
}
// Deduplicate
var unique = [];
for (var k = 0; k < results.length; k++) {
var msg = results[k];
var existingIdx = -1;
for (var m = 0; m < unique.length; m++) {
var u = unique[m];
if (u.sender === msg.sender &&
(u.text === msg.text || u.text.includes(msg.text) || msg.text.includes(u.text))) {
existingIdx = m;
break;
}
}
if (existingIdx === -1) {
unique.push(msg);
} else if (msg.text.length > unique[existingIdx].text.length) {
unique[existingIdx] = msg;
}
}
return unique;
})()
`) as { sender: string; text: string; isSelf: boolean }[];
// Convert to ChatMessage format
for (const msg of extractedMessages) {
messages.push({
sender: msg.isSelf ? 'Me' : msg.sender,
text: msg.text,
timestamp: Date.now(),
isSelf: msg.isSelf,
});
}
this.logger.debug(`Extracted ${messages.length} messages via JS injection`);
} catch (error) {
this.logger.warn('Error scraping conversation messages:', error);
}
return messages;
}
private async scrapeChats(): Promise<ActiveChat[]> {
const chats: ActiveChat[] = [];
try {
// Look for room links in the sidebar
this.logger.debug('Looking for chat room links...');
const roomLinks = await this.page.$$('a[href^="/room/"]');
this.logger.info(`Found ${roomLinks.length} room links`);
if (roomLinks.length === 0) {
this.logger.warn('No chat rooms found!');
const content = await this.page.content();
this.logger.debug(`Page Content Snippet: ${content.slice(0, 2000)}...`);
return [];
}
// Log first few items for debugging
for (let i = 0; i < Math.min(5, roomLinks.length); i++) {
const html = await roomLinks[i].evaluate(el => el.outerHTML);
this.logger.debug(`[ROOM_DEBUG_${i}] HTML: ${html.slice(0, 500)}`);
}
for (const item of roomLinks) {
try {
// Skip "create new chat" links
const href = await item.getAttribute('href');
if (!href || href.includes('/create')) continue;
const url = `https://chat.reddit.com${href}`;
// Extract room ID from URL
const roomIdMatch = href.match(/\/room\/([^/]+)/);
const roomId = roomIdMatch ? decodeURIComponent(roomIdMatch[1]) : undefined;
// Get aria-label which typically has the room name
const ariaLabel = await item.getAttribute('aria-label');
// Get the room name from .room-name element
const roomNameEl = await item.$('.room-name, [class*="room-name"]');
let roomName = roomNameEl ? (await roomNameEl.textContent())?.trim() : null;
// Fallback to aria-label if room-name not found
if (!roomName) {
roomName = ariaLabel || 'Unknown';
}
// Get last message preview
const lastMsgEl = await item.$('.last-message, [class*="last-message"]');
let lastMessage = lastMsgEl ? (await lastMsgEl.textContent())?.trim() : '';
// Check if last message is from us
const isFromUs = lastMessage?.startsWith('You:') || false;
if (isFromUs && lastMessage) {
lastMessage = lastMessage.replace(/^You:\s*/, '').trim();
}
// Get timestamp
const timeEl = await item.$('.last-message-time, [class*="time"]');
let timestamp = Date.now();
if (timeEl) {
const timeText = await timeEl.textContent();
if (timeText) {
timestamp = this.parseRelativeTime(timeText);
}
}
// Check for unread indicator
const unread = await item.evaluate((el) => {
const badges = el.querySelectorAll('[class*="badge"], [class*="unread"]');
return badges.length > 0;
});
// Initially, username = roomName (will be updated when we load conversation)
const chat: ActiveChat = {
username: roomName, // Will be updated with actual username later
roomName,
lastMessageTime: timestamp,
lastMessageSnippet: lastMessage || '',
isFromUs,
unread,
url,
roomId,
};
this.logger.debug(`Scraped room: "${roomName}" | Last: "${(lastMessage || '').slice(0, 30)}..." | FromUs: ${isFromUs}`);
chats.push(chat);
} catch (e) {
this.logger.error('Error processing chat item:', e);
}
}
// Deduplicate by URL
const chatMap = new Map<string, ActiveChat>();
for (const chat of chats) {
if (chat.url && !chatMap.has(chat.url)) {
chatMap.set(chat.url, chat);
}
}
this.logger.info(`Scraped ${chats.length} raw rooms, ${chatMap.size} unique rooms`);
return Array.from(chatMap.values())
.sort((a, b) => b.lastMessageTime - a.lastMessageTime);
} catch (error) {
this.logger.error('Error scraping chats from chat.reddit.com:', error);
return [];
}
}
private parseRelativeTime(text: string): number {
const now = Date.now();
const clean = text.toLowerCase().trim();
if (clean.includes('just now')) return now;
// "5m", "2h", "1d" compact format
const compactMatch = clean.match(/^(\d+)([mhdy])$/);
if (compactMatch) {
const val = parseInt(compactMatch[1]);
const unit = compactMatch[2];
if (unit === 'm') return now - val * 60 * 1000;
if (unit === 'h') return now - val * 60 * 60 * 1000;
if (unit === 'd') return now - val * 24 * 60 * 60 * 1000;
if (unit === 'y') return now - val * 365 * 24 * 60 * 60 * 1000;
}
// Standard relative time
const minuteMatch = clean.match(/(\d+)\s*(?:minute|min)/);
if (minuteMatch) return now - parseInt(minuteMatch[1]) * 60 * 1000;
const hourMatch = clean.match(/(\d+)\s*(?:hour|hr)/);
if (hourMatch) return now - parseInt(hourMatch[1]) * 60 * 60 * 1000;
const dayMatch = clean.match(/(\d+)\s*(?:day)/);
if (dayMatch) return now - parseInt(dayMatch[1]) * 24 * 60 * 60 * 1000;
// Time like "7:56 PM" - assume today
const timeMatch = clean.match(/(\d{1,2}):(\d{2})\s*(am|pm)?/i);
if (timeMatch) {
const date = new Date();
let hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const period = timeMatch[3]?.toLowerCase();
if (period === 'pm' && hours < 12) hours += 12;
if (period === 'am' && hours === 12) hours = 0;
date.setHours(hours, minutes, 0, 0);
// If the resulting time is in the future, it must be yesterday
if (date.getTime() > now) {
date.setDate(date.getDate() - 1);
}
return date.getTime();
}
// Date formats like "Aug 1, 2025" or "Jan 4"
const dateMatch = clean.match(/([a-z]{3})\s+(\d{1,2})(?:,?\s*(\d{4}))?/i);
if (dateMatch) {
const months: { [key: string]: number } = {
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11
};
const month = months[dateMatch[1].toLowerCase()];
const day = parseInt(dateMatch[2]);
const year = dateMatch[3] ? parseInt(dateMatch[3]) : new Date().getFullYear();
if (month !== undefined) {
return new Date(year, month, day).getTime();
}
}
return now;
}
}