Update source code and remove runtime data from tracking

Add gitignore rules for browser session data, SQLite databases,
media downloads, and build artifacts. Remove previously tracked
runtime files from the repository.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.6 committed 2026-05-20 18:54:07 +01:00
1 parent e90213d1ff
commit dfa0580859
23 files changed
+5324 -347

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
import type { Logger } from '../utils/logger.js';
import type { Config } from '../config/index.js';
import type { OpenRouterClient } from '../ai/openrouter.js';
import type { ChatService, ActiveChat } from './chat.js';
export class AutoReplyService {
private interval: NodeJS.Timeout | null = null;
private processing = false;
// Track which conversations we've already processed to avoid re-generating replies
private processedConversations = new Set<string>();
constructor(
private chatService: ChatService,
private aiClient: OpenRouterClient,
private config: Config,
private logger: Logger,
private onReplyGenerated?: (chat: ActiveChat) => void,
private onUpsellDetected?: (chat: ActiveChat, keyword: string) => void,
private onAutoSent?: (chat: ActiveChat) => void,
) { }
async start(): Promise<void> {
if (!this.config.autoReply.enabled) {
this.logger.info('Auto-reply is disabled');
return;
}
// Seed processedConversations from cache so we don't re-generate on restart
const cached = await this.chatService.getCachedConversations();
for (const chat of cached) {
if (chat.replyStatus) {
const stateKey = `${chat.username}:${chat.lastMessageTime}:${chat.lastMessageSnippet}`;
this.processedConversations.add(stateKey);
}
}
this.logger.info(`Auto-reply: Seeded ${this.processedConversations.size} already-processed conversation(s) from cache`);
const intervalMs = this.config.autoReply.checkIntervalMs;
this.logger.info(`Auto-reply service started (interval: ${intervalMs / 1000}s, autoSend: ${this.config.autoReply.autoSend})`);
// Run immediately then schedule
this.processUnreadChats();
this.interval = setInterval(() => this.processUnreadChats(), intervalMs);
}
stop(): void {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
/**
* Manually trigger processing (e.g., after a chat refresh)
*/
async triggerProcess(): Promise<void> {
if (!this.config.autoReply.enabled) return;
await this.processUnreadChats();
}
private async processUnreadChats(): Promise<void> {
if (this.processing) return;
this.processing = true;
try {
const chats = await this.chatService.getCachedConversations();
// Find conversations where:
// 1. The last message is NOT from us (they sent a message)
// 2. The conversation is marked as unread OR the last message is from them
// 3. We haven't already processed this state (using message snippet + time as key)
const unreadChats = chats.filter(chat => {
if (!chat.messages || chat.messages.length === 0) return false;
if (chat.replyStatus) return false; // Already processed (pending, upsell_detected, or sent)
// The definitive check: the last message in the actual history must be from THEM, not us
const lastMessage = chat.messages[chat.messages.length - 1];
if (lastMessage.isSelf) return false;
// Create a unique key for this conversation state
const stateKey = `${chat.username}:${chat.lastMessageTime}:${chat.lastMessageSnippet}`;
if (this.processedConversations.has(stateKey)) return false;
return true;
});
if (unreadChats.length === 0) {
this.logger.debug('Auto-reply: No unread conversations to process');
this.processing = false;
return;
}
this.logger.info(`Auto-reply: Processing ${unreadChats.length} unread conversation(s)...`);
for (const chat of unreadChats) {
try {
await this.processChat(chat);
} catch (error) {
this.logger.error(`Auto-reply: Error processing chat with ${chat.username}:`, error);
}
}
} catch (error) {
this.logger.error('Auto-reply: Error in processUnreadChats:', error);
} finally {
this.processing = false;
}
}
private async processChat(chat: ActiveChat): Promise<void> {
const stateKey = `${chat.username}:${chat.lastMessageTime}:${chat.lastMessageSnippet}`;
this.logger.info(`Auto-reply: Checking conversation with ${chat.username}...`);
// Safety gate: verify the last message is truly from them, not us
const lastMsg = chat.messages![chat.messages!.length - 1];
if (lastMsg.isSelf) {
this.logger.info(`Auto-reply: Skipping ${chat.username} — we sent the last message`);
this.processedConversations.add(stateKey);
return;
}
// Step 1: Check for upsell/spam
const upsellResult = this.aiClient.detectUpsell(
chat.messages!.map(m => ({ text: m.text, isSelf: m.isSelf })),
this.config.autoReply.upsellKeywords
);
if (upsellResult.isUpsell) {
this.logger.info(`Auto-reply: Upsell detected from ${chat.username} (keyword: "${upsellResult.matchedKeyword}")`);
chat.replyStatus = 'upsell_detected';
chat.pendingReply = undefined;
this.processedConversations.add(stateKey);
this.onUpsellDetected?.(chat, upsellResult.matchedKeyword!);
// Save the updated status to cache
await this.chatService.saveCacheExternal();
return;
}
// Step 2: Generate a proposed reply
this.logger.info(`Auto-reply: Generating reply for ${chat.username}...`);
const suggestedReply = await this.aiClient.generateReply(
chat.messages!.map(m => ({ sender: m.sender, text: m.text, isSelf: m.isSelf })),
chat.username
);
if (!suggestedReply) {
this.logger.warn(`Auto-reply: Failed to generate reply for ${chat.username}`);
this.processedConversations.add(stateKey);
return;
}
// Step 3: Auto-send or store as pending
if (this.config.autoReply.autoSend) {
if (chat.url) {
this.logger.info(`Auto-reply: Auto-sending reply to ${chat.username}: "${suggestedReply.slice(0, 80)}..."`);
const sent = await this.chatService.sendChatMessage(chat.url, suggestedReply);
if (sent) {
chat.replyStatus = 'sent';
chat.pendingReply = suggestedReply;
this.onAutoSent?.(chat);
// Refresh chats in background so cache reflects the sent message
this.chatService.refreshTopChats(5).catch(err =>
this.logger.error('Failed to refresh chats after auto-send:', err)
);
} else {
this.logger.error(`Auto-reply: Failed to send message to ${chat.username}`);
}
} else {
this.logger.warn(`Auto-reply: No chat URL for ${chat.username}, cannot auto-send`);
}
} else {
// Store as pending approval
chat.pendingReply = suggestedReply;
chat.replyStatus = 'pending';
this.logger.info(`Auto-reply: Reply pending approval for ${chat.username}: "${suggestedReply.slice(0, 80)}..."`);
this.onReplyGenerated?.(chat);
}
this.processedConversations.add(stateKey);
// Save the updated status to cache
await this.chatService.saveCacheExternal();
}
/**
* Clear the processed state for a conversation so it can be re-evaluated
*/
clearProcessedState(username: string): void {
const keysToRemove: string[] = [];
for (const key of this.processedConversations) {
if (key.startsWith(`${username}:`)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(k => this.processedConversations.delete(k));
}
}
+714 -140
View File
@@ -1,12 +1,22 @@
import fs from 'fs/promises';
import path from 'path';
import type { Page } from 'playwright';
import type { Logger } from '../utils/logger.js';
import type { SuccessfulMessagesRepository } from '../database/repositories.js';
import type { MediaService } from './media.js';
export interface ChatMessage {
sender: string;
text: string;
timestamp: number;
isSelf: boolean;
media?: ChatMedia[];
}
export interface ChatMedia {
originalUrl: string;
localPath: string;
fileName: string;
}
export interface ActiveChat {
@@ -20,64 +30,318 @@ export interface ActiveChat {
roomId?: string;
messages?: ChatMessage[];
originMessage?: string; // The first message we sent that started the conversation
pendingReply?: string; // AI-generated reply awaiting approval
replyStatus?: 'pending' | 'upsell_detected' | 'sent'; // Status of auto-reply
}
interface ChatCacheFile {
version: number;
savedAt: number;
chats: ActiveChat[];
}
type ScrapedChatMessage = Omit<ChatMessage, 'media'> & { mediaUrls: string[] };
export class ChatService {
private cachedChats: ActiveChat[] = [];
private lastFetchTime: number = 0;
private readonly CACHE_DURATION_MS = 60 * 1000; // 1 minute cache
private readonly cacheFilePath: string;
private queue: Promise<any> = Promise.resolve();
private enqueue<T>(task: () => Promise<T>): Promise<T> {
const result = this.queue.then(() => task());
this.queue = result.catch(() => { }); // Ensure queue never breaks on error
return result;
}
constructor(
private page: Page,
private logger: Logger,
private successfulMessagesRepo?: SuccessfulMessagesRepository
) { }
private successfulMessagesRepo?: SuccessfulMessagesRepository,
private mediaService?: MediaService
) {
this.cacheFilePath = path.join(process.cwd(), 'data', 'chat-cache.json');
}
/**
* Returns cached conversations from memory or disk cache for dashboard browsing.
* Does NOT fetch from Reddit - only returns locally cached data.
*/
async getCachedConversations(): Promise<ActiveChat[]> {
// If we have cached chats in memory, return them
if (this.cachedChats.length > 0) {
return this.cachedChats;
}
// Otherwise, try to load from disk
await this.loadCache();
return this.cachedChats;
}
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);
}
return this.enqueue(async () => {
// 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;
}
// 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;
try {
this.logger.debug('Fetching active chats from chat.reddit.com...');
this.logger.info(`Found ${this.cachedChats.length} active chats`);
return this.cachedChats;
// 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;
// Save to disk cache
await this.saveCache(chats);
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 refreshTopChats(limit: number = 5): Promise<void> {
await this.enqueue(async () => {
try {
this.logger.info(`Refreshing top ${limit} chats...`);
await this.page.goto('https://chat.reddit.com/', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await this.page.waitForTimeout(5000);
const chats = await this.scrapeChats();
// Load details for the top N chats
const topChats = chats.slice(0, limit);
this.logger.info(`Loading details for top ${topChats.length} chats...`);
for (const chat of topChats) {
try {
await this.loadConversationDetails(chat);
} catch (err) {
this.logger.error(`Failed to load conversation for ${chat.roomName}`, err);
}
}
// Merge with existing cache to preserve history for chats we didn't fully load this time
const mergedChats = chats.map(newChat => {
const existing = this.cachedChats.find(c => c.url === newChat.url);
// If we didn't load messages this time (check if messages array is missing or empty on newChat)
// But wait, loadConversationDetails adds 'messages' to newChat.
// If we SKIPPED loadConversationDetails, newChat.messages will be undefined.
if (existing && existing.messages && !newChat.messages) {
newChat.messages = existing.messages;
newChat.originMessage = existing.originMessage;
}
return newChat;
});
this.cachedChats = mergedChats;
this.lastFetchTime = Date.now();
await this.saveCache(this.cachedChats);
this.logger.info(`Refreshed top ${limit} chats and updated cache.`);
} catch (error) {
this.logger.error('Failed to refresh top chats:', error);
}
});
}
async fetchAllMessages(): Promise<ActiveChat[]> {
return this.enqueue(async () => {
try {
this.logger.info('Fetching all chats and messages from chat.reddit.com...');
await this.page.goto('https://chat.reddit.com/', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await this.page.waitForTimeout(5000);
const chats = await this.scrapeChats();
this.logger.info(`Fetching full history for ${chats.length} chats (all)...`);
for (const chat of chats) {
try {
await this.loadConversationDetails(chat);
} catch (err) {
this.logger.error(`Failed to load conversation for ${chat.roomName}`, err);
}
}
this.cachedChats = chats;
this.lastFetchTime = Date.now();
await this.saveCache(chats);
return chats;
} catch (error) {
this.logger.error('Failed to fetch all messages:', error);
return this.cachedChats;
}
});
}
async loadCache(): Promise<void> {
try {
const raw = await fs.readFile(this.cacheFilePath, 'utf-8');
const parsed = JSON.parse(raw) as ChatCacheFile;
if (!parsed || !Array.isArray(parsed.chats)) {
this.logger.warn('Chat cache file is invalid, ignoring.');
return;
}
this.cachedChats = parsed.chats;
this.lastFetchTime = Date.now();
this.logger.info(`Loaded ${parsed.chats.length} chats from cache`);
} catch (error) {
this.logger.error('Failed to fetch active chats:', error);
return this.cachedChats;
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
this.logger.info('No chat cache found to load');
return;
}
this.logger.warn('Failed to load chat cache:', error);
}
}
async debugInspectDom(chatUrl: string): Promise<unknown> {
return this.enqueue(async () => {
await this.page.goto(chatUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
await this.page.waitForTimeout(3000);
return this.page.evaluate(`
(function() {
var result = { shadowChain: [], allImages: [], allMedia: [], messageElements: [], customElements: [] };
var rsApp = document.querySelector('rs-app');
if (!rsApp || !rsApp.shadowRoot) { result.error = 'No rs-app shadow root'; return result; }
var rsRoom = rsApp.shadowRoot.querySelector('rs-room');
if (!rsRoom) { result.error = 'No rs-room'; return result; }
var searchRoot = rsRoom.shadowRoot || rsRoom;
// Recursively map ALL elements through shadow DOMs
function walk(root, depth, path) {
if (!root || depth > 10) return;
var els = root.querySelectorAll('*');
for (var i = 0; i < els.length; i++) {
var el = els[i];
var tag = el.tagName.toLowerCase();
// Track images
if (tag === 'img') {
result.allImages.push({
src: (el.getAttribute('src') || '').slice(0, 300),
dataSrc: (el.getAttribute('data-src') || '').slice(0, 300),
alt: (el.getAttribute('alt') || '').slice(0, 100),
className: (el.className || '').toString().slice(0, 150),
width: el.width, height: el.height,
naturalWidth: el.naturalWidth, naturalHeight: el.naturalHeight,
parentTag: el.parentElement ? el.parentElement.tagName.toLowerCase() : 'none',
parentClass: el.parentElement ? (el.parentElement.className || '').toString().slice(0, 150) : '',
path: path,
depth: depth
});
}
// Track video, picture, source, canvas, iframe
if (['video', 'picture', 'source', 'canvas', 'iframe', 'object', 'embed'].indexOf(tag) !== -1) {
result.allMedia.push({
tag: tag,
src: (el.getAttribute('src') || el.getAttribute('data') || '').slice(0, 300),
type: el.getAttribute('type') || '',
className: (el.className || '').toString().slice(0, 150),
path: path,
depth: depth,
outerHTML: el.outerHTML.slice(0, 500)
});
}
// Track custom elements (contain hyphens) or elements with shadow roots
if (tag.indexOf('-') !== -1 || el.shadowRoot) {
var text = (el.textContent || '').trim();
result.customElements.push({
tag: tag,
hasShadow: !!el.shadowRoot,
className: (el.className || '').toString().slice(0, 150),
childCount: el.childElementCount,
path: path,
depth: depth,
textSnippet: text.slice(0, 80),
hasImgChild: el.querySelectorAll('img').length > 0,
innerHTML: el.innerHTML ? el.innerHTML.slice(0, 200) : ''
});
}
// Look for elements with background-image CSS
if (el.style && el.style.backgroundImage && el.style.backgroundImage.indexOf('url') !== -1) {
result.allMedia.push({
tag: tag + '[bg-image]',
src: el.style.backgroundImage.slice(0, 300),
type: 'css-background',
className: (el.className || '').toString().slice(0, 150),
path: path,
depth: depth,
outerHTML: ''
});
}
// Recurse into shadow roots
if (el.shadowRoot) {
walk(el.shadowRoot, depth + 1, path + ' > ' + tag + '[shadow]');
}
}
}
walk(searchRoot, 0, 'rs-room');
// Also get direct children structure for context
result.rootChildren = [];
for (var c = 0; c < searchRoot.children.length; c++) {
var child = searchRoot.children[c];
result.rootChildren.push({
tag: child.tagName.toLowerCase(),
className: (child.className || '').toString().slice(0, 200),
hasShadow: !!child.shadowRoot,
childCount: child.childElementCount
});
}
return result;
})()
`);
});
}
async loadConversationDetails(chat: ActiveChat): Promise<void> {
if (!chat.url) {
this.logger.warn(`No URL for chat ${chat.roomName}, skipping details.`);
@@ -104,7 +368,10 @@ export class ChatService {
}
// Now scrape all messages in the conversation
const messages = await this.scrapeConversationMessages(chat.username);
const scrapedMessages = await this.scrapeConversationMessages(chat.username);
const messages = this.mediaService
? await this.mediaService.attachMedia(chat, scrapedMessages)
: this.stripMediaUrls(scrapedMessages);
if (messages.length > 0) {
chat.messages = messages;
@@ -136,6 +403,7 @@ export class ChatService {
imageAnalysis: null, // We don't have image analysis for chat messages
postTitle: `Chat with ${chat.username}`,
responseSnippet: replySnippet,
subreddit: null,
});
this.logger.info(`[SUCCESS] Stored successful chat example from ${chat.username}!`);
} catch (err) {
@@ -155,6 +423,116 @@ export class ChatService {
}
}
async sendChatMessage(chatUrl: string, message: string): Promise<boolean> {
return this.enqueue(async () => {
try {
this.logger.info(`Sending chat message to ${chatUrl}...`);
await this.page.goto(chatUrl, {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await this.page.waitForTimeout(3000);
// Find and focus the chat input inside shadow DOM
const inputFound = await this.page.evaluate(`
(function() {
function collectAllElements(root) {
var elements = [];
if (!root) return elements;
var treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
var current = treeWalker.currentNode;
while (current) {
elements.push(current);
if (current.shadowRoot) {
elements = elements.concat(collectAllElements(current.shadowRoot));
}
current = treeWalker.nextNode();
}
return elements;
}
var elements = collectAllElements(document);
// Look for textarea or contenteditable input in the chat
for (var i = 0; i < elements.length; i++) {
var el = elements[i];
if (!el.tagName) continue;
var tag = el.tagName.toLowerCase();
// Match textarea or input with chat-related attributes
if (tag === 'textarea' || (tag === 'input' && el.type === 'text')) {
var placeholder = (el.getAttribute('placeholder') || '').toLowerCase();
var ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase();
if (placeholder.includes('message') || ariaLabel.includes('message') ||
placeholder.includes('chat') || ariaLabel.includes('chat') ||
placeholder.includes('type') || ariaLabel.includes('type')) {
el.focus();
return true;
}
}
// Match contenteditable divs used as chat inputs
if (el.getAttribute('contenteditable') === 'true') {
var role = (el.getAttribute('role') || '').toLowerCase();
var ariaLabel2 = (el.getAttribute('aria-label') || '').toLowerCase();
if (role === 'textbox' || ariaLabel2.includes('message') || ariaLabel2.includes('chat')) {
el.focus();
return true;
}
}
}
// Broader fallback: any textarea or contenteditable inside rs-room
var rsApp = document.querySelector('rs-app');
if (rsApp && rsApp.shadowRoot) {
var rsRoom = rsApp.shadowRoot.querySelector('rs-room');
if (rsRoom) {
var searchRoot = rsRoom.shadowRoot || rsRoom;
var inputs = collectAllElements(searchRoot);
for (var j = 0; j < inputs.length; j++) {
var inp = inputs[j];
if (!inp.tagName) continue;
if (inp.tagName.toLowerCase() === 'textarea' ||
inp.getAttribute('contenteditable') === 'true' ||
(inp.tagName.toLowerCase() === 'input' && inp.type === 'text')) {
inp.focus();
return true;
}
}
}
}
return false;
})()
`);
if (!inputFound) {
this.logger.error('Could not find chat input field');
return false;
}
// Type the message with human-like delay
for (const char of message) {
await this.page.keyboard.type(char, { delay: 15 + Math.random() * 25 });
}
await this.page.waitForTimeout(500);
// Press Enter to send
await this.page.keyboard.press('Enter');
await this.page.waitForTimeout(2000);
this.logger.info('Chat message sent successfully');
return true;
} catch (error) {
this.logger.error('Failed to send chat message:', error);
return false;
}
});
}
async verifyMessageSent(recipient: string, messageText: string): Promise<boolean> {
this.logger.info(`Verifying message to ${recipient} in chat...`);
@@ -283,8 +661,8 @@ export class ChatService {
}
}
private async scrapeConversationMessages(recipientUsername: string): Promise<ChatMessage[]> {
const messages: ChatMessage[] = [];
private async scrapeConversationMessages(recipientUsername: string): Promise<ScrapedChatMessage[]> {
const messages: ScrapedChatMessage[] = [];
try {
// Use raw JavaScript string to avoid TypeScript transformation issues
@@ -292,127 +670,154 @@ export class ChatService {
(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');
// --- Helpers ---
function getImageSrc(img) {
return img.getAttribute('src') || img.getAttribute('data-src') || img.getAttribute('data-lazy-src') || '';
}
function findAllSpans(root) {
function isAvatarImage(img) {
var src = getImageSrc(img);
if (!src || src.indexOf('http') !== 0) return true;
var srcLower = src.toLowerCase();
if (srcLower.indexOf('styles.redditmedia.com') !== -1) return true;
if (srcLower.indexOf('redditstatic.com/avatars') !== -1) return true;
if (srcLower.indexOf('/avatar/') !== -1) return true;
if (srcLower.indexOf('/avatars/') !== -1) return true;
if (srcLower.indexOf('avatar_default') !== -1) return true;
if (srcLower.indexOf('snoovatar') !== -1) return true;
var imgAlt = (img.getAttribute('alt') || '').toLowerCase();
if (imgAlt.indexOf('avatar') !== -1) return true;
if (img.width > 0 && img.height > 0 && img.width <= 64 && img.height <= 64) return true;
return false;
}
// Recursively find all images through shadow DOM boundaries
function findAllImages(root) {
if (!root) return [];
var spans = Array.from(root.querySelectorAll('span'));
var imgs = Array.from(root.querySelectorAll('img'));
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));
imgs = imgs.concat(findAllImages(el.shadowRoot));
}
}
return spans;
return imgs;
}
var rsRoom = findRsRoom();
// Recursively collect all text from a root, piercing shadow DOMs
function collectText(root) {
if (!root) return '';
var text = '';
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
var node;
while (node = walker.nextNode()) {
text += node.textContent;
}
// Also collect from shadow roots of child elements
var allElements = root.querySelectorAll('*');
for (var i = 0; i < allElements.length; i++) {
if (allElements[i].shadowRoot) {
text += collectText(allElements[i].shadowRoot);
}
}
return text;
}
// --- Navigate shadow DOM to find timeline events ---
var rsApp = document.querySelector('rs-app');
if (!rsApp || !rsApp.shadowRoot) return [];
var rsRoom = rsApp.shadowRoot.querySelector('rs-room');
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;
var roomRoot = rsRoom.shadowRoot || rsRoom;
var rsTimeline = roomRoot.querySelector('rs-timeline');
if (!rsTimeline || !rsTimeline.shadowRoot) return [];
var rsVirtualScroll = rsTimeline.shadowRoot.querySelector('rs-virtual-scroll-dynamic');
if (!rsVirtualScroll || !rsVirtualScroll.shadowRoot) return [];
var scrollRoot = rsVirtualScroll.shadowRoot;
// Find all rs-timeline-event elements (each one = a message)
var events = scrollRoot.querySelectorAll('rs-timeline-event');
var lastSender = null;
for (var i = 0; i < events.length; i++) {
var event = events[i];
var eventRoot = event.shadowRoot;
if (!eventRoot) continue;
// 1. Extract sender from rs-timeline-event-hovercard elements
var sender = null;
var hovercards = eventRoot.querySelectorAll('rs-timeline-event-hovercard');
for (var h = 0; h < hovercards.length; h++) {
var hcText = collectText(hovercards[h]).trim();
// The hovercard with the username has just alphanumeric text
if (hcText.length > 0 && hcText.length < 40 && /^[A-Za-z0-9_-]+$/.test(hcText)) {
sender = hcText;
break;
}
}
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 no sender found in this event, it's a continuation from the same sender
if (!sender) sender = lastSender;
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++;
lastSender = sender;
// 2. Extract message text - get all text, then strip sender/timestamps
var fullText = collectText(eventRoot);
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(/send\\s*message/gi, '')
.replace(/\\s+/g, ' ')
.replace(/^\\s*[:|-]\\s*/, '')
.trim();
// 3. Extract media images from rs-image elements and any other non-avatar images
var mediaUrls = [];
var allImgs = findAllImages(eventRoot);
for (var mi = 0; mi < allImgs.length; mi++) {
if (isAvatarImage(allImgs[mi])) continue;
var src = getImageSrc(allImgs[mi]);
if (src) mediaUrls.push(src);
}
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
});
}
if (messageText.length > 0 || mediaUrls.length > 0) {
results.push({
sender: sender,
text: messageText,
isSelf: sender === myUsername,
mediaUrls: mediaUrls
});
}
}
// 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;
return results;
})()
`) as { sender: string; text: string; isSelf: boolean }[];
`) as { sender: string; text: string; isSelf: boolean; mediaUrls: string[] }[];
// Convert to ChatMessage format
for (const msg of extractedMessages) {
const normalizedText = msg.text || (msg.mediaUrls && msg.mediaUrls.length > 0 ? '[Image]' : '');
messages.push({
sender: msg.isSelf ? 'Me' : msg.sender,
text: msg.text,
text: normalizedText,
timestamp: Date.now(),
isSelf: msg.isSelf,
mediaUrls: msg.mediaUrls || [],
});
}
@@ -425,11 +830,67 @@ export class ChatService {
return messages;
}
private stripMediaUrls(messages: ScrapedChatMessage[]): ChatMessage[] {
return messages.map((message) => ({
sender: message.sender,
text: message.text,
timestamp: message.timestamp,
isSelf: message.isSelf,
}));
}
private async scrapeChats(): Promise<ActiveChat[]> {
const chats: ActiveChat[] = [];
try {
const shadowRooms = await this.scrapeChatsViaShadowDom();
if (shadowRooms.length > 0) {
for (const room of shadowRooms) {
if (!room.href) continue;
if (room.href.includes('/create')) continue;
const url = room.href.startsWith('http')
? room.href
: `https://chat.reddit.com${room.href}`;
const roomIdMatch = room.href.match(/\/room\/([^/]+)/);
const roomId = roomIdMatch ? decodeURIComponent(roomIdMatch[1]) : undefined;
const roomName = (room.roomName || room.ariaLabel || 'Unknown').trim();
let lastMessage = (room.lastMessage || '').trim();
const isFromUs = /^you[:\s·-]/i.test(lastMessage);
if (isFromUs && lastMessage) {
lastMessage = lastMessage.replace(/^you[:\s·-]+/i, '').trim();
}
const timestamp = room.timeText ? this.parseRelativeTime(room.timeText) : Date.now();
chats.push({
username: roomName,
roomName,
lastMessageTime: timestamp,
lastMessageSnippet: lastMessage,
isFromUs,
unread: room.unread,
url,
roomId,
});
}
const chatMap = new Map<string, ActiveChat>();
for (const chat of chats) {
if (chat.url && !chatMap.has(chat.url)) {
chatMap.set(chat.url, chat);
}
}
const deduped = Array.from(chatMap.values())
.sort((a, b) => b.lastMessageTime - a.lastMessageTime);
this.logger.info(`Scraped ${deduped.length} rooms via shadow DOM`);
return deduped;
}
// Look for room links in the sidebar
this.logger.debug('Looking for chat room links...');
const roomLinks = await this.page.$$('a[href^="/room/"]');
@@ -537,6 +998,119 @@ export class ChatService {
}
}
/**
* Public method to persist current cached chats to disk.
* Used by AutoReplyService after updating pendingReply/replyStatus fields.
*/
async saveCacheExternal(): Promise<void> {
await this.saveCache(this.cachedChats);
}
private async saveCache(chats: ActiveChat[]): Promise<void> {
const payload: ChatCacheFile = {
version: 1,
savedAt: Date.now(),
chats,
};
try {
await fs.mkdir(path.dirname(this.cacheFilePath), { recursive: true });
await fs.writeFile(this.cacheFilePath, JSON.stringify(payload, null, 2), 'utf-8');
this.logger.info(`Saved ${chats.length} chats to cache`);
} catch (error) {
this.logger.warn('Failed to save chat cache:', error);
}
}
private async scrapeChatsViaShadowDom(): Promise<Array<{
href: string;
ariaLabel: string | null;
roomName: string | null;
lastMessage: string | null;
timeText: string | null;
unread: boolean;
}>> {
try {
return await this.page.evaluate(`
(function() {
function collectAllElements(root) {
var elements = [];
if (!root) return elements;
var treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null);
var current = treeWalker.currentNode;
while (current) {
elements.push(current);
if (current.shadowRoot) {
elements = elements.concat(collectAllElements(current.shadowRoot));
}
current = treeWalker.nextNode();
}
return elements;
}
function findByClassParts(root, parts) {
var elements = collectAllElements(root);
for (var i = 0; i < elements.length; i++) {
var el = elements[i];
if (!el.className || typeof el.className !== 'string') continue;
var className = el.className.toLowerCase();
for (var j = 0; j < parts.length; j++) {
if (className.includes(parts[j])) {
var text = (el.textContent || '').trim();
if (text.length > 0) return text;
}
}
}
return null;
}
function hasUnreadBadge(root) {
var elements = collectAllElements(root);
for (var i = 0; i < elements.length; i++) {
var el = elements[i];
var className = (el.className || '').toString().toLowerCase();
var testId = (el.getAttribute && el.getAttribute('data-testid')) || '';
var ariaLabel = (el.getAttribute && el.getAttribute('aria-label')) || '';
if (className.includes('unread') || className.includes('badge')) return true;
if (testId.toLowerCase().includes('unread')) return true;
if (ariaLabel.toLowerCase().includes('unread')) return true;
}
return false;
}
var elements = collectAllElements(document);
var anchors = elements.filter(function(el) {
return el.tagName === 'A' && el.getAttribute('href') && el.getAttribute('href').startsWith('/room/');
});
var rooms = [];
for (var i = 0; i < anchors.length; i++) {
var a = anchors[i];
var href = a.getAttribute('href');
if (!href) continue;
var roomName = findByClassParts(a, ['room-name', 'roomname', 'room_title', 'roomtitle']);
var lastMessage = findByClassParts(a, ['last-message', 'lastmessage', 'preview', 'message-preview', 'messagepreview']);
var timeText = findByClassParts(a, ['last-message-time', 'lastmessagetime', 'timestamp', 'time']);
rooms.push({
href: href,
ariaLabel: a.getAttribute('aria-label'),
roomName: roomName,
lastMessage: lastMessage,
timeText: timeText,
unread: hasUnreadBadge(a)
});
}
return rooms;
})()
`);
} catch (error) {
this.logger.warn('Failed to scrape chats via shadow DOM:', error);
return [];
}
}
private parseRelativeTime(text: string): number {
const now = Date.now();
const clean = text.toLowerCase().trim();
+119
View File
@@ -0,0 +1,119 @@
import type { Logger } from '../utils/logger.js';
import type { ChatService } from './chat.js';
export interface MediaSyncStatus {
isSyncing: boolean;
lastSyncAt: number | null;
lastSyncDurationMs: number | null;
lastSyncChats: number | null;
lastError: string | null;
nextSyncAt: number | null;
}
export class MediaSyncService {
private syncing = false;
private lastSyncAt: number | null = null;
private lastSyncDurationMs: number | null = null;
private lastSyncChats: number | null = null;
private lastError: string | null = null;
private intervalHandle: NodeJS.Timeout | null = null;
private readonly intervalMs: number;
constructor(
private chatService: ChatService,
private logger: Logger,
intervalMs: number = 30 * 60 * 1000 // default 30 minutes
) {
this.intervalMs = intervalMs;
}
/**
* Start periodic background syncing.
*/
start(): void {
if (this.intervalHandle) return;
this.logger.info(`Media sync: starting background sync every ${Math.round(this.intervalMs / 60000)} min`);
// Run first sync after a short delay to let the app settle
const initialDelay = setTimeout(() => this.runSync(), 15000);
this.intervalHandle = setInterval(() => this.runSync(), this.intervalMs);
// Ensure the initial timeout is cleaned up if stop() is called early
const origStop = this.stop.bind(this);
this.stop = () => {
clearTimeout(initialDelay);
origStop();
};
}
/**
* Stop periodic syncing.
*/
stop(): void {
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
this.intervalHandle = null;
this.logger.info('Media sync: background sync stopped');
}
}
/**
* Trigger a sync manually (e.g. from the dashboard).
* Returns immediately if a sync is already in progress.
*/
async triggerSync(full = false): Promise<boolean> {
if (this.syncing) {
this.logger.info('Media sync: sync already in progress, skipping trigger');
return false;
}
await this.runSync(full);
return true;
}
getStatus(): MediaSyncStatus {
return {
isSyncing: this.syncing,
lastSyncAt: this.lastSyncAt,
lastSyncDurationMs: this.lastSyncDurationMs,
lastSyncChats: this.lastSyncChats,
lastError: this.lastError,
nextSyncAt: this.intervalHandle && this.lastSyncAt
? this.lastSyncAt + this.intervalMs
: null,
};
}
private async runSync(full = false): Promise<void> {
if (this.syncing) return;
this.syncing = true;
this.lastError = null;
const start = Date.now();
try {
if (full) {
this.logger.info('Media sync: running full sync (all conversations)...');
const chats = await this.chatService.fetchAllMessages();
this.lastSyncChats = chats.length;
} else {
this.logger.info('Media sync: running incremental sync (top 10 chats)...');
await this.chatService.refreshTopChats(10);
this.lastSyncChats = 10;
}
this.lastSyncAt = Date.now();
this.lastSyncDurationMs = Date.now() - start;
this.logger.info(
`Media sync: completed in ${Math.round(this.lastSyncDurationMs / 1000)}s` +
` (${this.lastSyncChats} chats)`
);
} catch (error) {
this.lastError = error instanceof Error ? error.message : String(error);
this.lastSyncDurationMs = Date.now() - start;
this.logger.error('Media sync: failed:', error);
} finally {
this.syncing = false;
}
}
}
+254
View File
@@ -0,0 +1,254 @@
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { z } from 'zod';
import type { Logger } from '../utils/logger.js';
import type { ActiveChat, ChatMedia, ChatMessage } from './chat.js';
export interface MediaDownloadResult {
buffer: Buffer;
contentType: string | null;
}
export interface MediaDownloader {
fetch(url: string): Promise<MediaDownloadResult | null>;
}
export interface MediaRecord {
id: string;
chatUsername: string | null;
chatRoomId: string | null;
sender: string;
timestamp: number;
originalUrl: string;
fileName: string;
relativePath: string;
createdAt: number;
}
const MediaIndexSchema = z.object({
version: z.number(),
savedAt: z.number(),
items: z.array(
z.object({
id: z.string(),
chatUsername: z.string().nullable(),
chatRoomId: z.string().nullable(),
sender: z.string(),
timestamp: z.number(),
originalUrl: z.string(),
fileName: z.string(),
relativePath: z.string(),
createdAt: z.number(),
})
),
});
type MediaIndex = z.infer<typeof MediaIndexSchema>;
interface ScrapedChatMessage extends ChatMessage {
mediaUrls: string[];
}
export class MediaService {
private readonly mediaRoot: string;
private readonly indexPath: string;
private index: MediaIndex | null = null;
constructor(
private downloader: MediaDownloader,
private logger: Logger
) {
this.mediaRoot = path.join(process.cwd(), 'data', 'media');
this.indexPath = path.join(this.mediaRoot, 'index.json');
}
async attachMedia(chat: ActiveChat, messages: ScrapedChatMessage[]): Promise<ChatMessage[]> {
await this.loadIndex();
const updated: ChatMessage[] = [];
for (const message of messages) {
const media = await this.downloadMessageMedia(chat, message);
updated.push({
sender: message.sender,
text: message.text,
timestamp: message.timestamp,
isSelf: message.isSelf,
media: media.length > 0 ? media : undefined,
});
}
await this.saveIndex();
return updated;
}
async getMediaItems(): Promise<MediaRecord[]> {
await this.loadIndex();
return (this.index?.items || []).slice().sort((a, b) => b.createdAt - a.createdAt);
}
private async downloadMessageMedia(chat: ActiveChat, message: ScrapedChatMessage): Promise<ChatMedia[]> {
if (!message.mediaUrls || message.mediaUrls.length === 0) {
return [];
}
const mediaItems: ChatMedia[] = [];
for (const url of message.mediaUrls) {
const record = await this.downloadMedia(chat, message, url);
if (record) {
mediaItems.push({
originalUrl: record.originalUrl,
localPath: record.relativePath,
fileName: record.fileName,
});
}
}
return mediaItems;
}
/**
* Convert Reddit Matrix blurred thumbnail URLs to full-resolution download URLs.
* Blurred: https://matrix.redditspace.com/_matrix/media/v3/thumbnail/reddit.com/blur_XXXXX?height=300
* Full: https://matrix.redditspace.com/_matrix/media/v3/download/reddit.com/XXXXX
*/
private resolveFullMediaUrl(url: string): string {
try {
const parsed = new URL(url);
if (parsed.hostname === 'matrix.redditspace.com' && parsed.pathname.includes('/thumbnail/')) {
// Strip blur_ prefix from the media ID
const fullPath = parsed.pathname.replace('/thumbnail/', '/download/').replace('/blur_', '/');
return `${parsed.origin}${fullPath}`;
}
} catch { /* keep original */ }
return url;
}
private async downloadMedia(chat: ActiveChat, message: ScrapedChatMessage, url: string): Promise<MediaRecord | null> {
if (!url || url.startsWith('data:')) {
return null;
}
// Try the full-resolution URL first, fall back to original
const fullUrl = this.resolveFullMediaUrl(url);
const id = crypto.createHash('sha1').update(fullUrl).digest('hex');
const existing = this.index?.items.find((item) => item.id === id);
if (existing) {
// Verify the file still exists on disk
const existingPath = path.join(this.mediaRoot, existing.fileName);
try {
await fs.access(existingPath);
return existing;
} catch {
// File missing — remove stale record and re-download
this.logger.debug(`Media file missing on disk, re-downloading: ${existing.fileName}`);
this.index!.items = this.index!.items.filter((item) => item.id !== id);
}
}
let download = await this.downloader.fetch(fullUrl);
if (!download && fullUrl !== url) {
this.logger.debug(`Full-res download failed, trying original URL: ${url}`);
download = await this.downloader.fetch(url);
}
if (!download) {
this.logger.warn(`Failed to download chat media from ${fullUrl}`);
return null;
}
const extension = this.pickExtension(url, download.contentType);
if (!extension) {
this.logger.debug(`Skipping non-image media from ${url}`);
return null;
}
const fileName = `${id}${extension}`;
const relativePath = fileName;
const fullPath = path.join(this.mediaRoot, fileName);
await fs.mkdir(this.mediaRoot, { recursive: true });
await fs.writeFile(fullPath, download.buffer);
const record: MediaRecord = {
id,
chatUsername: chat.username || null,
chatRoomId: chat.roomId || null,
sender: message.sender,
timestamp: message.timestamp,
originalUrl: fullUrl,
fileName,
relativePath,
createdAt: Date.now(),
};
if (!this.index) {
this.index = { version: 1, savedAt: Date.now(), items: [record] };
} else {
this.index.items.push(record);
this.index.savedAt = Date.now();
}
return record;
}
private pickExtension(url: string, contentType: string | null): string | null {
let urlExt = '';
try {
urlExt = path.extname(new URL(url).pathname || '').toLowerCase();
} catch {
urlExt = path.extname(url.split('?')[0] || '').toLowerCase();
}
if (urlExt && urlExt.length <= 5) {
return urlExt;
}
if (!contentType) {
return null;
}
if (contentType.includes('image/jpeg')) return '.jpg';
if (contentType.includes('image/png')) return '.png';
if (contentType.includes('image/gif')) return '.gif';
if (contentType.includes('image/webp')) return '.webp';
if (contentType.includes('image/avif')) return '.avif';
return null;
}
private async loadIndex(): Promise<void> {
if (this.index) {
return;
}
try {
const raw = await fs.readFile(this.indexPath, 'utf-8');
const parsed = MediaIndexSchema.safeParse(JSON.parse(raw));
if (!parsed.success) {
this.logger.warn('Media index is invalid, starting fresh.');
this.index = { version: 1, savedAt: Date.now(), items: [] };
return;
}
this.index = parsed.data;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
this.logger.warn('Failed to load media index, starting fresh:', error);
}
this.index = { version: 1, savedAt: Date.now(), items: [] };
}
}
private async saveIndex(): Promise<void> {
if (!this.index) {
return;
}
try {
await fs.mkdir(this.mediaRoot, { recursive: true });
await fs.writeFile(this.indexPath, JSON.stringify(this.index, null, 2), 'utf-8');
} catch (error) {
this.logger.warn('Failed to save media index:', error);
}
}
}
+24 -16
View File
@@ -13,36 +13,40 @@ export class MonitorService {
) { }
async getUnprocessedPosts(): Promise<RedditPost[]> {
const unprocessedPosts: RedditPost[] = [];
// Create a shuffled copy of subreddits to check
const subreddits = [...this.config.reddit.subreddits];
this.shuffleArray(subreddits);
for (const subreddit of this.config.reddit.subreddits) {
for (const subreddit of subreddits) {
try {
this.logger.debug(`Checking r/${subreddit} for new posts...`);
const posts = await this.redditApi.getNewPosts(subreddit);
let newCount = 0;
// Filter for valid, unprocessed posts
const validPosts: RedditPost[] = [];
for (const post of posts) {
// Check if post is processed OR if we have already messaged this user
if (this.postsRepo.isProcessed(post.id)) {
continue;
}
if (this.messagesRepo.hasSentTo(post.author)) {
this.logger.debug(`Skipping post ${post.id} from u/${post.author} - already messaged this user`);
// Optionally mark this post as processed so we don't check it again?
// Or just rely on the check. Checking DB is fast enough.
// If we mark it processed, we should probably record WHY (e.g. "author_already_messaged").
// For now, let's just skip it.
continue;
}
unprocessedPosts.push(post);
newCount++;
validPosts.push(post);
}
if (newCount > 0) {
this.logger.info(`Found ${newCount} new posts in r/${subreddit}`);
if (validPosts.length > 0) {
this.logger.info(`Found ${validPosts.length} new posts in r/${subreddit}`);
// Sort by creation time (newest first)
validPosts.sort((a, b) => b.created_utc - a.created_utc);
// Return just the first (newest) valid post
// This ensures we switch to a different random subreddit on the next poll cycle
// instead of processing a batch from the same subreddit
return [validPosts[0]];
}
} catch (error) {
this.logger.error(`Failed to fetch posts from r/${subreddit}:`, error);
@@ -52,10 +56,14 @@ export class MonitorService {
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 [];
}
return unprocessedPosts;
private shuffleArray<T>(array: T[]): void {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
}
private sleep(ms: number): Promise<void> {
+38 -7
View File
@@ -2,6 +2,7 @@ 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 type { StrategySelector, SelectedStrategy } from './strategy-selector.js';
import { downloadImage, bufferToBase64, isValidImageMimeType } from '../utils/image.js';
export interface ProcessedPost {
@@ -9,10 +10,12 @@ export interface ProcessedPost {
imageAnalysis: string;
detectedGender: DetectedGender;
generatedMessage: string;
strategyId: string | null;
}
export class ProcessorService {
private successfulExamples: string[] = [];
private strategySelector: StrategySelector | null = null;
constructor(
private redditApi: RedditApiClient,
@@ -21,6 +24,13 @@ export class ProcessorService {
private logger: Logger
) {}
setStrategySelector(selector: StrategySelector): void {
this.strategySelector = selector;
if (selector.hasStrategies()) {
this.logger.info(`Strategy A/B testing enabled with ${this.config.strategies.variants.length} variants`);
}
}
setSuccessfulExamples(examples: string[]): void {
this.successfulExamples = examples;
if (examples.length > 0) {
@@ -77,14 +87,34 @@ export class ProcessorService {
this.logger.debug(`Gender check passed: ${detectedGender}`);
}
// Select a strategy for A/B testing
let selectedStrategy: SelectedStrategy | null = null;
if (this.strategySelector) {
selectedStrategy = this.strategySelector.selectStrategy();
if (selectedStrategy) {
this.logger.info(`Using strategy "${selectedStrategy.id}" (${selectedStrategy.reason})`);
}
}
// 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
);
let analysis;
let message;
try {
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
base64,
imageData.mimeType,
post.title,
post.author,
this.successfulExamples,
selectedStrategy?.promptOverride
));
} catch (error) {
if (error instanceof Error && error.message.startsWith('AI_REFUSAL')) {
this.logger.warn(`AI refused to analyze post ${post.id} by u/${post.author} - skipping`);
return null;
}
throw error;
}
if (!message || message.length < 10) {
this.logger.warn(`Generated message too short or empty for post ${post.id}`);
@@ -96,6 +126,7 @@ export class ProcessorService {
imageAnalysis: analysis.description,
detectedGender: analysis.detectedGender,
generatedMessage: message,
strategyId: selectedStrategy?.id || null,
};
}
}
+8 -2
View File
@@ -65,6 +65,7 @@ export class ResponseCheckerService {
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.`);
@@ -152,8 +153,10 @@ export class ResponseCheckerService {
};
}
getSuccessfulExamples(limit: number = 3): string[] {
const successful = this.successfulMessagesRepo.getRecent(limit);
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}"`;
@@ -163,6 +166,9 @@ export class ResponseCheckerService {
if (s.postTitle) {
example = `Post Title: "${s.postTitle}"\n${example}`;
}
if (s.responseSnippet) {
example += `\nTheir reply: "${s.responseSnippet.slice(0, 150)}"`;
}
return example;
});
}
+101
View File
@@ -0,0 +1,101 @@
import type { Config } from '../config/index.js';
import type { MessagesRepository, StrategyStats } from '../database/repositories.js';
import type { Logger } from '../utils/logger.js';
export interface SelectedStrategy {
id: string;
promptOverride: string;
reason: 'exploit' | 'explore';
}
export class StrategySelector {
private variants: Config['strategies']['variants'];
private explorationRate: number;
private minSamples: number;
constructor(
private config: Config,
private messagesRepo: MessagesRepository,
private logger: Logger
) {
this.variants = config.strategies.variants;
this.explorationRate = config.strategies.explorationRate;
this.minSamples = config.strategies.minSamples;
}
selectStrategy(): SelectedStrategy | null {
if (this.variants.length === 0) {
return null;
}
// Exploration: random strategy
if (Math.random() < this.explorationRate) {
const variant = this.variants[Math.floor(Math.random() * this.variants.length)];
this.logger.debug(`Strategy selected (explore): ${variant.id}`);
return { id: variant.id, promptOverride: variant.promptOverride, reason: 'explore' };
}
// Exploitation: pick the best-performing strategy
const stats = this.messagesRepo.getStrategyStats();
const statsMap = new Map(stats.map(s => [s.strategyId, s]));
// Find strategies with insufficient samples - prioritize exploring them
const unexplored = this.variants.filter(v => {
const s = statsMap.get(v.id);
return !s || s.totalSent < this.minSamples;
});
if (unexplored.length > 0) {
const variant = unexplored[Math.floor(Math.random() * unexplored.length)];
this.logger.debug(`Strategy selected (under-sampled): ${variant.id}`);
return { id: variant.id, promptOverride: variant.promptOverride, reason: 'explore' };
}
// All strategies have enough samples - pick the best response rate
let bestVariant = this.variants[0];
let bestRate = -1;
for (const variant of this.variants) {
const s = statsMap.get(variant.id);
if (s && s.responseRate > bestRate) {
bestRate = s.responseRate;
bestVariant = variant;
}
}
this.logger.debug(`Strategy selected (exploit, ${bestRate}% response rate): ${bestVariant.id}`);
return { id: bestVariant.id, promptOverride: bestVariant.promptOverride, reason: 'exploit' };
}
getStats(): (StrategyStats & { configured: boolean })[] {
const dbStats = this.messagesRepo.getStrategyStats();
const statsMap = new Map(dbStats.map(s => [s.strategyId, s]));
// Include all configured variants, even if they have no data yet
const results: (StrategyStats & { configured: boolean })[] = [];
for (const variant of this.variants) {
const s = statsMap.get(variant.id);
results.push({
strategyId: variant.id,
totalSent: s?.totalSent || 0,
totalResponses: s?.totalResponses || 0,
responseRate: s?.responseRate || 0,
configured: true,
});
}
// Include any historical strategies no longer in config
for (const s of dbStats) {
if (!this.variants.some(v => v.id === s.strategyId)) {
results.push({ ...s, configured: false });
}
}
return results;
}
hasStrategies(): boolean {
return this.variants.length > 0;
}
}