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:
1 parent
e90213d1ff
commit
dfa0580859
23 files changed
+5324
-347
No files matched your search
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user