diff --git a/.gitignore b/.gitignore index 7b04266..1f8748a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,16 @@ Thumbs.db # TypeScript *.tsbuildinfo +tsc-errors.txt +tsc-out.txt + +# Runtime data +data/browser-session/ +data/reddit-bot.db +data/reddit-bot.db-shm +data/reddit-bot.db-wal +data/chat-cache.json +data/media/ + +# Claude Code +.claude/worktrees/ diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..393a4e5 --- /dev/null +++ b/agents.md @@ -0,0 +1,34 @@ +# Agent Guidelines & Code Standards + +## Refactoring Goal +Refactor code to improve maintainability, testability, and clarity. Break down large components into smaller, single-purpose classes/modules. + +## Engineering Standards +### 1. Single Responsibility Principle (SRP) +- Avoid "God Classes" (e.g., massive `Service` classes that do everything). +- Extract logic into dedicated handlers, managers, or utilities. +- **Example**: Instead of one `BotService` handling login, monitoring, and messaging, split into `AuthService`, `MonitorService`, and `MessagingService`. + +### 2. Design Patterns +- **Dependency Injection**: Pass dependencies via constructors. This makes swapping implementations (e.g., for testing) easier. +- **Factory Pattern**: Use factories for creating complex objects (e.g., configuring different AI models). +- **Strategy Pattern**: Good for interchangeable algorithms (e.g., differentsubreddit selection strategies). + +### 3. TypeScript Best Practices +- **Strict Typing**: No `any`. Define interfaces/types for all data structures (especially API/Reddit responses). +- **Zod Validation**: Use `zod` (already in deps) to validate external data (config files, API responses). +- **Async/Await**: properly handle promises. Use `p-queue` or `p-retry` for rate-limited operations. + +### 4. Project Specifics +- **Tech Stack**: Node.js, TypeScript, Playwright, Express, SQLite. +- **Directories**: + - `src/services/` for business logic. + - `src/web/` for Express server and UI. + - `src/browser/` for Playwright interactions. +- **Logging**: Use `winston` for structured logging. Do not use `console.log` in production code. + +## Workflow for Agents +1. **Analyze**: Before writing code, read the file and understand the context. +2. **Plan**: Identify what needs to change. Check if the change violates SRP. +3. **Refactor**: Create new files if necessary. Don't be afraid to create new small files. +4. **Verify**: Ensure types check out (`npm run build` or `tsc`). diff --git a/config.yaml b/config.yaml index 635e4fe..0f82694 100644 --- a/config.yaml +++ b/config.yaml @@ -1,25 +1,51 @@ reddit: subreddits: - - normalnudes - - ratemyboobs - - ratemypussy - - RateMyRack - - RateMyNudeBody - pollIntervalMs: 120000 + # Original list (kept intact) + - normalnudes # Everyday real bodies, highly authentic + - ratemyboobs # Boob-focused ratings, lots of self-posts + - ratemypussy # Pussy ratings, direct and active + - HomemadeNsfw # Pure homemade amateur content + - RateMyRack # Rack/boob ratings + - RateMyNudeBody # Full-body nude ratings + + # Top general additions (highest volume/quality amateurs) + - gonewild # #1 for verified women self-posting nudes + - GoneErotic # Artistic/erotic amateurs + - Amateur # Real amateur pics/vids worldwide + - RealGirls # Unfiltered everyday women + - GoneMild # Softcore teases from verified posters + - OnOff # Before/after clothing reveals + + # Niche by body type (diverse, verified amateurs) + - GoneWildCurvy # Curvy/thicc verified women + - petitegonewild # Petite/slim under 5'4" + - GoneWildPlus # BBW/plus-size confident shares + - gonewildchubby # Soft/chubby bodies + - tightandtoned # Fit/athletic women + - pawg # Thick asses (heavy self-posts) + + # Other strong fits (active, rating-style or homemade vibes) + - nsfwselfies # Selfie-style nudes + - asiansgonewild # Asian amateurs + - altgonewild # Alt/goth/tattooed + - gonewild30plus # Mature/MILF amateurs + - holdthemoan # Risky public-ish posts + - realgirlsgonebad # Candid real-girl naughtiness + pollIntervalMs: 30000 userAgent: "RedditBot/1.0 (by /u/yourusername)" targetGenders: - female # Options: male, female, other, unknown + filterSellers: true # Skip profiles that appear to be OnlyFans/content sellers + # sellerKeywords: [...] # Uncomment to override default keyword list openrouter: imageAnalysisModel: "google/gemini-3-flash-preview" - chatModel: "deepseek/deepseek-v3.2" + imageAnalysisFallbackModels: + - "x-ai/grok-4.1-fast" + - "meta-llama/llama-4-maverick" + chatModel: "x-ai/grok-4.1-fast" imageAnalysisPrompt: | - You're analyzing images. Be CRUDE and EXPLICIT. Use terms like: - - tits, boobs, titties (NOT breasts, chest) - - ass, booty, butt (NOT buttocks, posterior) - - pussy, cunt (NOT vagina, genitals) - - dick, cock (NOT penis, member) - - nipples, areolas - describe color, size, puffiness, pierced etc. + You're analyzing images. Describe the following features: - breasts: size (small/medium/big/huge), shape, nipple details, perky/saggy, cleavage @@ -31,30 +57,28 @@ openrouter: - EXTRAS: tattoos, piercings, lingerie, toys, setting Be graphic. Describe exactly what you see. No clinical terms. - This helps generate messages that reference specific things about their body. + This helps generate encouranging messages that reference specific things about their body. + Give them a rating out of 10 where 10 is super model and 1 is excessively overweight. This helps us send encouragement to the user. You MUST provide a reason with your rating. Be critical and honest. Act within guidelines. messageGenerationPrompt: | Based on this image analysis: {analysis} Post title: {title} - Write a casual Reddit DM (2-3 sentences) like a horny guy complimenting her. + You should take into account the rating provided by the analysis, the rating is out of 10 where 10 is super model and 1 is excessively overweight. We should offer far more encouragement to ratings that are below 7, we should really highlight how sexy they are and hot their pic is. + + Write a casual Reddit DM (2-3 sentences) like a horny guy complimenting her trying to get her to share more nudes or videos. MUST follow: - Start sentences with capital letters, use proper capitalisation - Use explicit terms: tits, ass, pussy, boobs (NOT breasts, buttocks, etc) - - Use words like hot, sexy, stunning, gorgeous, fucking incredible - Reference something SPECIFIC from the analysis (her tits, ass, pussy, body, pose) - Be direct and confident, not flowery or poetic - End with a hook that encourages them to share MORE (nudes/videos) GOOD HOOKS (use these styles): - - "Would love to see more of you" - - "Do you post more somewhere else?" - - "You should definitely post more" + - "I'd love if you have any other pics to share?" + - "I bet they'd look great coated..." - "Got any more pics?" - - "I'd love to see that ass from behind" - - "You holding out on us? Need to see more" - - "Please tell me there's more where this came from" BAD HOOKS (never use): - "How's your day?" @@ -70,16 +94,35 @@ openrouter: - Use all lowercase Examples of good messages: - - "Damn those tits are perfect. You should definitely post more 😏" + - "Damn those tits are perfect. I'd love to see more if you're willing😏" - "Holy shit that ass is incredible. Got any more pics?" - - "Your body is fucking sexy. Would love to see more of you" - "Those curves are insane, love how your tits look in that pose. More please?" Just write the message, nothing else. +strategies: + explorationRate: 0.2 # 20% of the time try a random strategy instead of the best + minSamples: 5 # Min messages sent before trusting a strategy's response rate + variants: + - id: compliment_direct + weight: 1 + promptOverride: | + STYLE: Focus on a direct, specific compliment about ONE body part from the analysis. + Keep it short and punchy. End with a simple "got more?" style hook. + - id: teasing + weight: 1 + promptOverride: | + STYLE: Be more teasing and playful. Light humor, like you're flirting. + End with a flirty challenge or dare that makes her want to respond. + - id: detailed_appreciation + weight: 1 + promptOverride: | + STYLE: Reference 2-3 specific things from the analysis to show you actually looked. + Be enthusiastic but not over the top. End with asking for more. + rateLimit: - minDelayBetweenMessagesMs: 300000 - maxMessagesPerHour: 8 + minDelayBetweenMessagesMs: 15000 + maxMessagesPerHour: 9999 jitterMs: 60000 browser: @@ -90,6 +133,12 @@ browser: database: path: "./data/reddit-bot.db" +autoReply: + enabled: true # Enable auto-reply to unread messages + autoSend: false # If true, send AI replies automatically. If false, require manual approval + checkIntervalMs: 300000 # How often to check for unread messages (5 minutes) + # upsellKeywords: [...] # Uncomment to override default upsell detection keywords + web: enabled: true port: 3000 diff --git a/public/index.html b/public/index.html index 975c2d9..7a5c589 100644 --- a/public/index.html +++ b/public/index.html @@ -230,6 +230,162 @@ margin-top: 4px; } + .activity-thumb { + width: 48px; + height: 48px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.05); + cursor: pointer; + transition: opacity 0.2s; + } + + .activity-thumb:hover { + opacity: 0.8; + } + + .user-search { + display: flex; + gap: 8px; + padding: 10px 20px; + background: rgba(0, 0, 0, 0.15); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + } + + .user-search input { + flex: 1; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + color: #fff; + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; + outline: none; + } + + .user-search input::placeholder { + color: #666; + } + + .user-search input:focus { + border-color: rgba(52, 152, 219, 0.6); + } + + .user-search button { + background: rgba(52, 152, 219, 0.3); + border: 1px solid rgba(52, 152, 219, 0.4); + color: #7ec8e3; + padding: 8px 14px; + border-radius: 6px; + font-size: 13px; + cursor: pointer; + white-space: nowrap; + } + + .user-search button:hover { + background: rgba(52, 152, 219, 0.5); + color: #fff; + } + + .user-lookup-results { + display: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + } + + .user-lookup-results.visible { + display: block; + } + + .lookup-post { + padding: 12px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + display: flex; + gap: 12px; + align-items: flex-start; + } + + .lookup-post:last-child { + border-bottom: none; + } + + .lookup-post-image { + width: 80px; + height: 80px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.05); + cursor: pointer; + } + + .lookup-post-image:hover { + opacity: 0.8; + } + + .lookup-post-info { + flex: 1; + min-width: 0; + } + + .lookup-post-title { + font-size: 14px; + color: #ddd; + font-weight: 500; + margin-bottom: 4px; + word-wrap: break-word; + } + + .lookup-post-meta { + font-size: 12px; + color: #888; + margin-bottom: 4px; + } + + .lookup-post-status { + font-size: 11px; + padding: 2px 8px; + border-radius: 4px; + display: inline-block; + } + + .lookup-post-status.sent { + background: rgba(46, 204, 113, 0.2); + color: #2ecc71; + } + + .lookup-post-status.error { + background: rgba(231, 76, 60, 0.2); + color: #e74c3c; + } + + .lookup-post-status.pending { + background: rgba(149, 165, 166, 0.2); + color: #95a5a6; + } + + .lookup-close { + background: none; + border: none; + color: #666; + font-size: 16px; + cursor: pointer; + padding: 4px 8px; + } + + .lookup-close:hover { + color: #fff; + } + + .lookup-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px 6px; + font-size: 12px; + color: #888; + } + .message-item { padding: 16px 20px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); @@ -445,6 +601,837 @@ ::-webkit-scrollbar-thumb:hover { background: #555; } + + /* Conversation Modal Styles */ + .modal-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + z-index: 1000; + justify-content: center; + align-items: center; + backdrop-filter: blur(4px); + } + + .modal-overlay.active { + display: flex; + } + + .modal-content { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 16px; + width: 90%; + max-width: 700px; + max-height: 85vh; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + } + + .modal-header { + padding: 20px 24px; + background: rgba(0, 0, 0, 0.3); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: space-between; + align-items: center; + } + + .modal-header h2 { + font-size: 18px; + color: #ff6b6b; + margin: 0; + } + + .modal-header .close-btn { + background: none; + border: none; + color: #888; + font-size: 24px; + cursor: pointer; + padding: 4px 8px; + transition: color 0.2s ease; + } + + .modal-header .close-btn:hover { + color: #fff; + } + + .modal-body { + padding: 20px 24px; + overflow-y: auto; + flex: 1; + } + + .conversation-meta { + display: flex; + gap: 16px; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + flex-wrap: wrap; + } + + .conversation-meta .meta-item { + font-size: 12px; + color: #888; + } + + .conversation-meta .meta-item span { + color: #ddd; + font-weight: 500; + } + + .message-bubble { + padding: 12px 16px; + border-radius: 16px; + margin-bottom: 12px; + max-width: 85%; + line-height: 1.5; + font-size: 14px; + } + + .message-bubble.sent { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; + margin-left: auto; + border-bottom-right-radius: 4px; + } + + .message-bubble.received { + background: rgba(255, 255, 255, 0.08); + color: #ddd; + border-bottom-left-radius: 4px; + } + + .message-bubble .sender { + font-size: 11px; + font-weight: 600; + margin-bottom: 4px; + opacity: 0.8; + } + + .message-bubble.sent .sender { + color: rgba(255, 255, 255, 0.8); + } + + .message-bubble.received .sender { + color: #ff6b6b; + } + + .no-messages { + text-align: center; + color: #666; + padding: 40px; + } + + .conversation-card { + padding: 14px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + gap: 12px; + align-items: flex-start; + cursor: pointer; + transition: background 0.2s ease; + } + + .conversation-card:hover { + background: rgba(255, 255, 255, 0.05); + } + + .conversation-card:last-child { + border-bottom: none; + } + + .conversation-card.has-reply { + border-left: 3px solid #2ecc71; + } + + .message-count-badge { + background: rgba(155, 89, 182, 0.3); + color: #9b59b6; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + margin-left: 8px; + } + + .reply-indicator { + background: rgba(46, 204, 113, 0.2); + color: #2ecc71; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + margin-left: 8px; + } + + .pending-indicator { + background: rgba(243, 156, 18, 0.2); + color: #f39c12; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + margin-left: 8px; + animation: pulse-pending 2s ease-in-out infinite; + } + + @keyframes pulse-pending { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } + } + + .upsell-indicator { + background: rgba(231, 76, 60, 0.2); + color: #e74c3c; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + margin-left: 8px; + } + + .conversation-card.has-pending { + border-left: 3px solid #f39c12; + } + + .conversation-card.has-upsell { + border-left: 3px solid #e74c3c; + opacity: 0.7; + } + + .pending-reply-box { + margin-top: 16px; + padding: 14px 16px; + background: rgba(243, 156, 18, 0.1); + border: 1px solid rgba(243, 156, 18, 0.3); + border-radius: 8px; + } + + .pending-reply-label { + font-size: 11px; + color: #f39c12; + font-weight: 600; + text-transform: uppercase; + margin-bottom: 8px; + } + + .pending-reply-text { + color: #e4e4e4; + font-size: 14px; + line-height: 1.5; + white-space: pre-wrap; + margin-bottom: 12px; + padding: 10px; + background: rgba(255, 255, 255, 0.05); + border-radius: 6px; + } + + .pending-reply-actions { + display: flex; + gap: 8px; + align-items: center; + } + + .approve-btn { + padding: 8px 20px; + background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: all 0.2s ease; + } + + .approve-btn:hover { + opacity: 0.9; + transform: translateY(-1px); + } + + .approve-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } + + .reject-btn { + padding: 8px 20px; + background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: all 0.2s ease; + } + + .reject-btn:hover { + opacity: 0.9; + transform: translateY(-1px); + } + + .reject-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } + + .dismiss-btn { + padding: 8px 16px; + background: rgba(255, 255, 255, 0.1); + color: #aaa; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.2s ease; + } + + .dismiss-btn:hover { + background: rgba(255, 255, 255, 0.15); + color: #e4e4e4; + } + + .upsell-box { + margin-top: 16px; + padding: 14px 16px; + background: rgba(231, 76, 60, 0.1); + border: 1px solid rgba(231, 76, 60, 0.3); + border-radius: 8px; + } + + .upsell-label { + font-size: 11px; + color: #e74c3c; + font-weight: 600; + text-transform: uppercase; + margin-bottom: 6px; + } + + .upsell-text { + color: #ccc; + font-size: 13px; + margin-bottom: 10px; + } + + .pending-action-status { + font-size: 12px; + margin-left: 8px; + } + + /* Media Gallery */ + .media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; + padding: 16px; + } + + .media-card { + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 200px; + text-decoration: none; + color: inherit; + } + + .media-card img { + width: 100%; + height: 140px; + object-fit: cover; + display: block; + background: #0f1624; + } + + .media-meta { + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 11px; + color: #888; + } + + .media-meta .media-user { + color: #ff6b6b; + font-weight: 600; + } + + /* Realtime Log Styles */ + .realtime-log { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 8px; + height: 300px; + overflow-y: auto; + padding: 12px; + } + + .log-entry { + padding: 4px 8px; + margin-bottom: 2px; + border-radius: 4px; + display: flex; + gap: 8px; + align-items: flex-start; + } + + .log-entry .log-time { + color: #6e7681; + flex-shrink: 0; + font-size: 11px; + } + + .log-entry .log-level { + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + flex-shrink: 0; + } + + .log-entry.info .log-level { + background: #1f6feb; + color: #fff; + } + + .log-entry.warn .log-level { + background: #d29922; + color: #000; + } + + .log-entry.error .log-level { + background: #f85149; + color: #fff; + } + + .log-entry.debug .log-level { + background: #6e7681; + color: #fff; + } + + .log-entry .log-message { + color: #c9d1d9; + word-break: break-word; + flex: 1; + } + + .log-entry.error .log-message { + color: #f85149; + } + + .log-entry.warn .log-message { + color: #d29922; + } + + .log-status { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: rgba(0, 0, 0, 0.3); + border-radius: 6px; + font-size: 12px; + margin-bottom: 12px; + } + + .log-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #e74c3c; + animation: pulse 2s infinite; + } + + .log-status-dot.connected { + background: #2ecc71; + } + + @keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } + } + + /* Settings Modal Styles */ + .settings-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #aaa; + padding: 8px 16px; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 6px; + } + + .settings-btn:hover { + background: rgba(255, 255, 255, 0.15); + color: #fff; + } + + .settings-form { + display: flex; + flex-direction: column; + gap: 20px; + } + + .form-group { + display: flex; + flex-direction: column; + gap: 8px; + } + + .form-group label { + font-size: 12px; + color: #888; + text-transform: uppercase; + font-weight: 600; + } + + .form-group input[type="number"], + .form-group input[type="text"] { + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 12px 16px; + color: #fff; + font-size: 14px; + transition: all 0.2s ease; + } + + .form-group input:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2); + } + + .form-group .hint { + font-size: 11px; + color: #666; + } + + .form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + } + + .toggle-group { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + } + + .toggle-group label { + font-size: 14px; + color: #ddd; + } + + .toggle { + position: relative; + width: 48px; + height: 26px; + } + + .toggle input { + opacity: 0; + width: 0; + height: 0; + } + + .toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #444; + border-radius: 26px; + transition: all 0.3s ease; + } + + .toggle-slider:before { + position: absolute; + content: ""; + height: 20px; + width: 20px; + left: 3px; + bottom: 3px; + background: #fff; + border-radius: 50%; + transition: all 0.3s ease; + } + + .toggle input:checked+.toggle-slider { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + } + + .toggle input:checked+.toggle-slider:before { + transform: translateX(22px); + } + + .modal-footer { + padding: 16px 24px; + background: rgba(0, 0, 0, 0.2); + display: flex; + justify-content: flex-end; + gap: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + } + + .suggested-reply { + margin-top: 16px; + padding: 12px 16px; + background: rgba(102, 126, 234, 0.15); + border: 1px solid rgba(102, 126, 234, 0.3); + border-radius: 8px; + } + + .suggested-reply-label { + font-size: 11px; + color: #667eea; + font-weight: 600; + text-transform: uppercase; + margin-bottom: 6px; + } + + .suggested-reply-text { + color: #e4e4e4; + font-size: 14px; + line-height: 1.5; + white-space: pre-wrap; + } + + .suggest-btn { + margin-top: 12px; + padding: 8px 16px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: all 0.2s ease; + } + + .suggest-btn:hover { + opacity: 0.9; + transform: translateY(-1px); + } + + .suggest-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } + + .reply-editor { + margin-top: 12px; + } + + .reply-editor textarea { + width: 100%; + min-height: 80px; + padding: 10px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: #e4e4e4; + font-size: 14px; + font-family: inherit; + resize: vertical; + } + + .reply-editor textarea:focus { + outline: none; + border-color: rgba(102, 126, 234, 0.5); + } + + .reply-actions { + display: flex; + gap: 8px; + margin-top: 8px; + align-items: center; + } + + .send-btn { + padding: 8px 20px; + background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: all 0.2s ease; + } + + .send-btn:hover { + opacity: 0.9; + transform: translateY(-1px); + } + + .send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } + + .send-status { + font-size: 12px; + margin-left: 8px; + } + + .btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; + border: none; + padding: 10px 24px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + } + + .btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + } + + .btn-secondary { + background: rgba(255, 255, 255, 0.1); + color: #aaa; + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 10px 24px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + } + + .btn-secondary:hover { + background: rgba(255, 255, 255, 0.15); + color: #fff; + } + + .clear-btn { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.2); + color: #888; + padding: 4px 12px; + border-radius: 4px; + font-size: 11px; + cursor: pointer; + transition: all 0.2s ease; + } + + .clear-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; + } + + /* Timer Styles */ + .timer-card { + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 16px; + border: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 12px; + position: relative; + overflow: hidden; + } + + .timer-card:last-child { + margin-bottom: 0; + } + + .timer-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + + .timer-title { + font-size: 13px; + font-weight: 600; + color: #aaa; + text-transform: uppercase; + display: flex; + align-items: center; + gap: 8px; + } + + .timer-value { + font-family: 'Monaco', 'Menlo', monospace; + font-size: 18px; + font-weight: 700; + color: #fff; + } + + .timer-progress-bg { + height: 4px; + background: rgba(255, 255, 255, 0.1); + border-radius: 2px; + overflow: hidden; + margin-top: 8px; + } + + .timer-progress-fill { + height: 100%; + background: linear-gradient(90deg, #667eea, #764ba2); + width: 0%; + transition: width 1s linear; + } + + .timer-progress-fill.warning { + background: linear-gradient(90deg, #f39c12, #e67e22); + } + + .timer-progress-fill.danger { + background: linear-gradient(90deg, #e74c3c, #c0392b); + } + + .timer-status { + font-size: 11px; + color: #666; + margin-top: 6px; + } + + .timer-icon { + font-size: 16px; + } @@ -456,6 +1443,7 @@ Stopped + @@ -483,9 +1471,56 @@ + +
+
+
⏳ Active Timers
+
+
+ + +
+
+
+ πŸš€ Next Message +
+
Ready
+
+
+
+
+
Waiting for new contents...
+
+ + +
+
+
+ ⏰ Hourly Limit Reset +
+
60:00
+
+
+
+
+
+ 0 messages remaining this hour +
+
+ +
+
+
+
+
Recent Activity
+ +
No activity yet
@@ -502,7 +1537,10 @@
Active Chats (Last 48 Hours) - + +
Loading chats...
@@ -510,6 +1548,31 @@
+
+
+
+ πŸ“ Cached Conversations (Browsable) + +
+
+
Loading cached conversations...
+
+
+
+ + + +
@@ -522,6 +1585,87 @@
+ + +
+
+
+ πŸ“‹ Realtime Log +
+ + Connecting... +
+ +
+
+
Waiting for log data...
+
+
+
+ + +
+
+
+ Media Gallery + + + + +
+
+
No media cached yet
+
+
+
+ + +
- \ No newline at end of file + diff --git a/scripts/inspect-chat-dom.ts b/scripts/inspect-chat-dom.ts new file mode 100644 index 0000000..ebdd795 --- /dev/null +++ b/scripts/inspect-chat-dom.ts @@ -0,0 +1,249 @@ +/** + * Diagnostic script: inspect the actual DOM structure of Reddit chat + * to understand how media images are rendered. + * + * Usage: npx tsx scripts/inspect-chat-dom.ts + */ +import { chromium } from 'playwright'; +import path from 'path'; + +async function main() { + const sessionDir = '/tmp/reddit-session-copy'; + + console.log('Launching browser with session copy...'); + const context = await chromium.launchPersistentContext(sessionDir, { + headless: false, + viewport: { width: 1280, height: 720 }, + args: ['--no-sandbox', '--disable-blink-features=AutomationControlled'], + }); + + const page = await context.newPage(); + await page.goto('https://chat.reddit.com/', { + waitUntil: 'domcontentloaded', + timeout: 30000, + }); + await page.waitForTimeout(5000); + + console.log('\n=== Finding chat rooms ==='); + + // Find the first chat room and click into it + const firstChatUrl = await page.evaluate(` + (function() { + var rsApp = document.querySelector('rs-app'); + if (!rsApp || !rsApp.shadowRoot) return null; + + // Look for room links + function findLinks(root) { + if (!root) return []; + var links = Array.from(root.querySelectorAll('a[href*="/room/"]')); + var allElements = root.querySelectorAll('*'); + for (var i = 0; i < allElements.length; i++) { + if (allElements[i].shadowRoot) { + links = links.concat(findLinks(allElements[i].shadowRoot)); + } + } + return links; + } + + var links = findLinks(rsApp.shadowRoot); + if (links.length === 0) return null; + return links[0].href; + })() + `); + + console.log('First chat URL:', firstChatUrl); + + if (firstChatUrl) { + await page.goto(firstChatUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }); + await page.waitForTimeout(3000); + } + + console.log('\n=== Inspecting message area DOM structure ==='); + + const domInfo = await page.evaluate(` + (function() { + var results = { + rsApp: false, + rsRoom: false, + rsRoomShadow: false, + customElements: [], + allImages: [], + messageStructure: [], + shadowRootChain: [], + }; + + var rsApp = document.querySelector('rs-app'); + if (!rsApp || !rsApp.shadowRoot) { + results.error = 'No rs-app shadow root found'; + return results; + } + results.rsApp = true; + + var rsRoom = rsApp.shadowRoot.querySelector('rs-room'); + if (!rsRoom) { + results.error = 'No rs-room found'; + return results; + } + results.rsRoom = true; + results.rsRoomShadow = !!rsRoom.shadowRoot; + + // Find ALL custom elements and their shadow roots recursively + function mapShadowDom(root, depth, path) { + if (!root || depth > 8) return; + var allEls = root.querySelectorAll('*'); + for (var i = 0; i < allEls.length; i++) { + var el = allEls[i]; + var tag = el.tagName.toLowerCase(); + if (tag.includes('-') || el.shadowRoot) { + var entry = { + tag: tag, + depth: depth, + path: path + ' > ' + tag, + hasShadow: !!el.shadowRoot, + childCount: el.childElementCount, + className: (el.className || '').toString().slice(0, 100), + }; + results.customElements.push(entry); + if (el.shadowRoot) { + mapShadowDom(el.shadowRoot, depth + 1, path + ' > ' + tag + ' [shadow]'); + } + } + } + } + + var searchRoot = rsRoom.shadowRoot || rsRoom; + mapShadowDom(searchRoot, 0, 'rs-room'); + + // Find ALL images recursively through shadow DOMs + function findAllImages(root, depth, path) { + if (!root || depth > 8) return; + var imgs = root.querySelectorAll('img'); + for (var i = 0; i < imgs.length; i++) { + var img = imgs[i]; + results.allImages.push({ + src: (img.getAttribute('src') || '').slice(0, 200), + alt: img.getAttribute('alt') || '', + width: img.width, + height: img.height, + naturalWidth: img.naturalWidth, + naturalHeight: img.naturalHeight, + className: (img.className || '').toString().slice(0, 100), + parentTag: img.parentElement ? img.parentElement.tagName.toLowerCase() : 'none', + parentClass: img.parentElement ? (img.parentElement.className || '').toString().slice(0, 100) : '', + grandparentTag: img.parentElement && img.parentElement.parentElement ? img.parentElement.parentElement.tagName.toLowerCase() : 'none', + path: path, + depth: depth, + }); + } + var allEls = root.querySelectorAll('*'); + for (var j = 0; j < allEls.length; j++) { + if (allEls[j].shadowRoot) { + findAllImages(allEls[j].shadowRoot, depth + 1, path + ' > ' + allEls[j].tagName.toLowerCase() + '[shadow]'); + } + } + } + + findAllImages(searchRoot, 0, 'rs-room'); + + // Try to understand message structure: look for repeated patterns + // Walk the shadow DOM and find elements that look like messages + function findMessageElements(root, depth, path) { + if (!root || depth > 8) return; + var allEls = root.querySelectorAll('*'); + for (var i = 0; i < allEls.length; i++) { + var el = allEls[i]; + var tag = el.tagName.toLowerCase(); + var cls = (el.className || '').toString().toLowerCase(); + var text = (el.textContent || '').trim(); + + // Look for elements that contain message-like content + if (tag.includes('message') || cls.includes('message') || + tag.includes('msg') || cls.includes('msg') || + tag.includes('chat') || cls.includes('bubble')) { + results.messageStructure.push({ + tag: tag, + className: cls.slice(0, 150), + path: path, + depth: depth, + textSnippet: text.slice(0, 100), + childCount: el.childElementCount, + hasImages: el.querySelectorAll('img').length, + innerHTML: el.innerHTML.slice(0, 300), + }); + } + + if (el.shadowRoot) { + findMessageElements(el.shadowRoot, depth + 1, path + ' > ' + tag + '[shadow]'); + } + } + } + + findMessageElements(searchRoot, 0, 'rs-room'); + + return results; + })() + `); + + console.log('\n--- Shadow DOM Structure ---'); + console.log('rs-app:', domInfo.rsApp); + console.log('rs-room:', domInfo.rsRoom); + console.log('rs-room has shadow:', domInfo.rsRoomShadow); + if (domInfo.error) console.log('ERROR:', domInfo.error); + + console.log('\n--- Custom Elements with Shadow Roots ---'); + for (const el of domInfo.customElements || []) { + console.log(`${' '.repeat(el.depth)}${el.tag} (shadow: ${el.hasShadow}, children: ${el.childCount}) ${el.className ? '[class: ' + el.className + ']' : ''}`); + } + + console.log('\n--- All Images Found ---'); + for (const img of domInfo.allImages || []) { + console.log(` [depth=${img.depth}] ${img.src}`); + console.log(` size: ${img.width}x${img.height} (natural: ${img.naturalWidth}x${img.naturalHeight})`); + console.log(` class: ${img.className}`); + console.log(` parent: <${img.parentTag} class="${img.parentClass}">`); + console.log(` path: ${img.path}`); + console.log(''); + } + + console.log('\n--- Message-like Elements ---'); + for (const msg of domInfo.messageStructure || []) { + console.log(` [depth=${msg.depth}] <${msg.tag}> images=${msg.hasImages}`); + console.log(` class: ${msg.className}`); + console.log(` text: ${msg.textSnippet}`); + console.log(` path: ${msg.path}`); + console.log(` html: ${msg.innerHTML}`); + console.log(''); + } + + // Also dump a broader view - find what's directly inside rs-room shadow + console.log('\n=== Direct children of message area ==='); + const directChildren = await page.evaluate(` + (function() { + var rsApp = document.querySelector('rs-app'); + if (!rsApp || !rsApp.shadowRoot) return 'no rs-app'; + var rsRoom = rsApp.shadowRoot.querySelector('rs-room'); + if (!rsRoom) return 'no rs-room'; + var root = rsRoom.shadowRoot || rsRoom; + + var children = []; + for (var i = 0; i < root.children.length; i++) { + var child = root.children[i]; + children.push({ + tag: child.tagName.toLowerCase(), + className: (child.className || '').toString().slice(0, 200), + id: child.id || '', + childCount: child.childElementCount, + hasShadow: !!child.shadowRoot, + textSnippet: (child.textContent || '').trim().slice(0, 150), + }); + } + return children; + })() + `); + console.log(JSON.stringify(directChildren, null, 2)); + + console.log('\nDone. Closing browser...'); + await context.close(); +} + +main().catch(console.error); diff --git a/src/ai/openrouter.ts b/src/ai/openrouter.ts index 3782f96..59b2cc1 100644 --- a/src/ai/openrouter.ts +++ b/src/ai/openrouter.ts @@ -65,64 +65,131 @@ export class OpenRouterClient { } async analyzeImage(imageBase64: string, mimeType: string): Promise { - try { - this.logger.debug('Analyzing image with AI...'); + const models = [ + this.config.openrouter.imageAnalysisModel, + ...this.config.openrouter.imageAnalysisFallbackModels, + ]; - const genderPrompt = `First, identify the gender of the person in this image. Respond with EXACTLY one of these on the first line: GENDER:male, GENDER:female, GENDER:other, or GENDER:unknown + for (let i = 0; i < models.length; i++) { + const model = models[i]; + const isFallback = i > 0; + + try { + if (isFallback) { + this.logger.info(`Trying fallback model: ${model}`); + } else { + this.logger.debug('Analyzing image with AI...'); + } + + const result = await this.tryAnalyzeWithModel(model, imageBase64, mimeType); + if (result) { + if (isFallback) { + this.logger.info(`Fallback model ${model} succeeded`); + } + return result; + } + + // Result was null = refusal, try next model + this.logger.warn(`Model ${model} refused to analyze image${i < models.length - 1 ? ', trying fallback...' : ''}`); + } catch (error) { + // API error (not a refusal) - still try fallback + this.logger.warn(`Model ${model} failed: ${error instanceof Error ? error.message : error}${i < models.length - 1 ? ', trying fallback...' : ''}`); + } + } + + // All models refused or failed + throw new Error('AI_REFUSAL: All models refused to analyze image'); + } + + private async tryAnalyzeWithModel(model: string, imageBase64: string, mimeType: string): Promise { + const genderPrompt = `First, identify the gender of the person in this image. Respond with EXACTLY one of these on the first line: GENDER:male, GENDER:female, GENDER:other, or GENDER:unknown Then provide your analysis: ${this.config.openrouter.imageAnalysisPrompt}`; - const response = await this.client.chat.completions.create({ - model: this.config.openrouter.imageAnalysisModel, - messages: [ - { - role: 'user', - content: [ - { - type: 'text', - text: genderPrompt, + const response = await this.client.chat.completions.create({ + model, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: genderPrompt, + }, + { + type: 'image_url', + image_url: { + url: `data:${mimeType};base64,${imageBase64}`, }, - { - type: 'image_url', - image_url: { - url: `data:${mimeType};base64,${imageBase64}`, - }, - }, - ], - }, - ], - max_tokens: 600, - }); + }, + ], + }, + ], + max_tokens: 600, + }); - const fullResponse = response.choices[0]?.message?.content || ''; + const fullResponse = response.choices[0]?.message?.content || ''; - // Parse gender from response - let detectedGender: DetectedGender = 'unknown'; - const genderMatch = fullResponse.match(/GENDER:(male|female|other|unknown)/i); - if (genderMatch) { - detectedGender = genderMatch[1].toLowerCase() as DetectedGender; - } - - // Remove gender line from description - const description = fullResponse.replace(/GENDER:(male|female|other|unknown)\n?/i, '').trim(); - - this.logger.debug(`Image analysis complete - Gender: ${detectedGender}`); - this.logger.debug(`Description: ${description}`); - - return { description, detectedGender }; - } catch (error) { - this.logger.error('Image analysis failed:', error); - throw error; + // Parse gender from response + let detectedGender: DetectedGender = 'unknown'; + const genderMatch = fullResponse.match(/GENDER:(male|female|other|unknown)/i); + if (genderMatch) { + detectedGender = genderMatch[1].toLowerCase() as DetectedGender; } + + // Remove gender line from description + const description = fullResponse.replace(/GENDER:(male|female|other|unknown)\n?/i, '').trim(); + + // Detect AI refusal - return null to signal fallback should be tried + if (this.isRefusal(description)) { + return null; + } + + this.logger.debug(`Image analysis complete (${model}) - Gender: ${detectedGender}`); + this.logger.debug(`Description: ${description}`); + + return { description, detectedGender }; + } + + private isRefusal(text: string): boolean { + const lower = text.toLowerCase(); + const refusalPatterns = [ + 'i cannot fulfill', + 'i can\'t fulfill', + 'i cannot provide', + 'i can\'t provide', + 'i\'m not able to', + 'i am not able to', + 'my safety guidelines', + 'i\'m programmed to', + 'i am programmed to', + 'i cannot generate', + 'i can\'t generate', + 'against my guidelines', + 'i must decline', + 'i cannot assist with', + 'i can\'t assist with', + 'i\'m unable to', + 'i am unable to', + 'violates my', + 'not appropriate for me', + 'i cannot engage', + 'i can\'t engage', + 'sexually explicit', + 'objectification', + 'prohibit me from', + ]; + return refusalPatterns.some(pattern => lower.includes(pattern)); } async generateMessage( imageAnalysis: string, postTitle: string, authorName: string, - successfulExamples: string[] = [] + successfulExamples: string[] = [], + strategyPrompt?: string ): Promise { try { this.logger.debug('Generating message...'); @@ -132,6 +199,11 @@ ${this.config.openrouter.imageAnalysisPrompt}`; .replace('{title}', postTitle) .replace('{author}', authorName); + // Append strategy-specific style guidance if provided + if (strategyPrompt) { + prompt += `\n\n${strategyPrompt}`; + } + // Add successful examples if available if (successfulExamples.length > 0) { const examplesText = successfulExamples @@ -159,15 +231,77 @@ ${this.config.openrouter.imageAnalysisPrompt}`; } } + async generateReply( + conversationHistory: { sender: string; text: string; isSelf: boolean }[], + username: string + ): Promise { + try { + this.logger.debug(`Generating suggested reply for conversation with ${username}...`); + + const historyText = conversationHistory + .map(msg => `${msg.isSelf ? 'You' : msg.sender}: ${msg.text}`) + .join('\n'); + + const prompt = `You are helping continue a Reddit chat conversation. Here is the conversation so far: + +${historyText} + +Write the next message to send to ${username}. Keep the same tone and style as the previous messages from "You". Be casual, direct, and engaging. Keep it to 1-3 sentences. Reference something they said in their reply to keep the conversation flowing naturally. + +DO NOT: +- Sound like an AI or use formal language +- Use words like "captivating", "alluring", "exquisite", "radiant" +- Write long elaborate sentences +- Be generic or boring + +Just write the message, nothing else.`; + + const response = await this.client.chat.completions.create({ + model: this.config.openrouter.chatModel, + messages: [{ role: 'user', content: prompt }], + max_tokens: 300, + temperature: 0.8, + }); + + const message = response.choices[0]?.message?.content || ''; + this.logger.debug(`Suggested reply generated: ${message}`); + return message.trim(); + } catch (error) { + this.logger.error('Reply generation failed:', error); + throw error; + } + } + + /** + * Check if message content contains upsell/spam keywords (OnlyFans, selling, etc.) + */ + detectUpsell(messages: { text: string; isSelf: boolean }[], upsellKeywords: string[]): { isUpsell: boolean; matchedKeyword?: string } { + // Only check messages from the other user + const theirMessages = messages.filter(m => !m.isSelf); + if (theirMessages.length === 0) return { isUpsell: false }; + + // Combine all their message text for keyword scanning + const combinedText = theirMessages.map(m => m.text).join(' ').toLowerCase(); + + for (const keyword of upsellKeywords) { + if (combinedText.includes(keyword.toLowerCase())) { + return { isUpsell: true, matchedKeyword: keyword }; + } + } + + return { isUpsell: false }; + } + async analyzeAndGenerateMessage( imageBase64: string, mimeType: string, postTitle: string, authorName: string, - successfulExamples: string[] = [] + successfulExamples: string[] = [], + strategyPrompt?: string ): Promise<{ analysis: ImageAnalysisResult; message: string }> { const analysis = await this.analyzeImage(imageBase64, mimeType); - const message = await this.generateMessage(analysis.description, postTitle, authorName, successfulExamples); + const message = await this.generateMessage(analysis.description, postTitle, authorName, successfulExamples, strategyPrompt); return { analysis, message }; } } diff --git a/src/browser/media-downloader.ts b/src/browser/media-downloader.ts new file mode 100644 index 0000000..0584715 --- /dev/null +++ b/src/browser/media-downloader.ts @@ -0,0 +1,29 @@ +import type { Page } from 'playwright'; +import type { Logger } from '../utils/logger.js'; +import type { MediaDownloadResult, MediaDownloader } from '../services/media.js'; + +export class PlaywrightMediaDownloader implements MediaDownloader { + constructor( + private page: Page, + private logger: Logger + ) {} + + async fetch(url: string): Promise { + try { + const response = await this.page.request.get(url, { timeout: 15000 }); + if (!response.ok()) { + this.logger.warn(`Media download failed (${response.status()}): ${url}`); + return null; + } + + const headers = response.headers(); + const contentType = headers['content-type'] ?? null; + const buffer = Buffer.from(await response.body()); + + return { buffer, contentType }; + } catch (error) { + this.logger.warn(`Media download error for ${url}:`, error); + return null; + } + } +} diff --git a/src/browser/message-page.ts b/src/browser/message-page.ts index 0b0af1a..d81fac6 100644 --- a/src/browser/message-page.ts +++ b/src/browser/message-page.ts @@ -64,6 +64,17 @@ export class RedditMessagePage { } } + // Wait a moment for Reddit's validation to kick in + await this.page.waitForTimeout(1500); + + // Check for "unable to send message" error immediately after recipient is filled + // This happens when the user has blocked messages, doesn't exist, or doesn't accept message requests + const recipientError = await this.checkRecipientError(); + if (recipientError) { + this.logger.warn(`Cannot message u/${recipient}: ${recipientError}`); + return false; + } + // --- Subject --- const subjectFilled = await this.fillField( ['faceplate-text-input[name="message-title"]', 'input[name="subject"]', '#subject'], @@ -107,38 +118,51 @@ export class RedditMessagePage { (await this.page.$('button:has-text("Send")')) || (await this.page.$('input[type="submit"]')); + let clicked = false; if (!submitButton) { - throw new Error('Could not find submit button'); - } - - // Wait for button to be enabled (up to 5 seconds) - // Sometimes validation takes a moment - try { - await submitButton.waitForElementState('enabled', { timeout: 5000 }); - } catch (e) { - this.logger.warn('Submit button still disabled after filling fields. Attempting to trigger validation...'); - - // Try to trigger validation by blurring/focusing the last field - await this.page.keyboard.press('Tab'); - await this.page.waitForTimeout(500); - await this.page.keyboard.press('Shift+Tab'); - await this.page.waitForTimeout(500); - - // One last check - const isEnabled = await submitButton.isEnabled(); - if (!isEnabled) { - this.logger.warn('Button still disabled. Attempting force click...'); - await submitButton.click({ force: true }); - // If force click fails/doesn't work, we proceed to success check anyway - // (sometimes it works even if reported disabled in shadow dom scenarios) - } else { - await submitButton.click(); + clicked = await this.clickSendButtonViaShadowDom(); + if (!clicked) { + throw new Error('Could not find submit button'); } } - // If we didn't force click above (normal path) - // Actually, the try-catch block above handles all clicking scenarios. - // We don't need a second click here. + if (submitButton) { + // Wait for button to be enabled (up to 5 seconds) + // Sometimes validation takes a moment + try { + await submitButton.waitForElementState('enabled', { timeout: 5000 }); + } catch (e) { + this.logger.warn('Submit button still disabled after filling fields. Attempting to trigger validation...'); + + // Try to trigger validation by blurring/focusing the last field + await this.page.keyboard.press('Tab'); + await this.page.waitForTimeout(500); + await this.page.keyboard.press('Shift+Tab'); + await this.page.waitForTimeout(500); + + // One last check + const isEnabled = await submitButton.isEnabled(); + if (!isEnabled) { + this.logger.warn('Button still disabled. Attempting force click...'); + await submitButton.click({ force: true }); + clicked = true; + // If force click fails/doesn't work, we proceed to success check anyway + // (sometimes it works even if reported disabled in shadow dom scenarios) + } else { + await submitButton.click(); + clicked = true; + } + } + } + + if (submitButton && !clicked) { + await submitButton.click(); + clicked = true; + } + + if (!clicked) { + clicked = await this.clickSendButtonViaShadowDom(); + } // Wait for success indication try { @@ -192,30 +216,170 @@ export class RedditMessagePage { } } + private async clickSendButtonViaShadowDom(): Promise { + try { + const clicked = await this.page.evaluate(() => { + function collectAllElements(root: ParentNode): Element[] { + const elements: Element[] = []; + if (!root) return elements; + const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + let current = treeWalker.currentNode as Element | null; + while (current) { + elements.push(current); + const maybeShadow = (current as Element & { shadowRoot?: ShadowRoot }).shadowRoot; + if (maybeShadow) { + elements.push(...collectAllElements(maybeShadow)); + } + current = treeWalker.nextNode() as Element | null; + } + return elements; + } + + function isSendButton(el: Element): boolean { + const text = (el.textContent || '').trim().toLowerCase(); + const aria = (el.getAttribute('aria-label') || '').trim().toLowerCase(); + const value = (el instanceof HTMLInputElement ? el.value : '').trim().toLowerCase(); + const label = [text, aria, value].join(' '); + return /\bsend\b/.test(label); + } + + const elements = collectAllElements(document); + for (const el of elements) { + if (el.tagName === 'BUTTON' && isSendButton(el)) { + (el as HTMLElement).click(); + return true; + } + if (el.tagName === 'INPUT') { + const input = el as HTMLInputElement; + if (input.type === 'submit' && isSendButton(el)) { + input.click(); + return true; + } + } + } + return false; + }); + + if (clicked) { + this.logger.info('Clicked send button via shadow DOM fallback'); + } + return clicked; + } catch (error) { + this.logger.warn('Failed to click send button via shadow DOM fallback:', error); + return false; + } + } + private async fillField(selectors: string[], value: string, useDelay = false): Promise { for (const selector of selectors) { try { const element = await this.page.$(selector); if (element) { - await element.click(); - // Clear potentially existing content if it's an input/textarea - // Note: .fill() usually clears, but for custom elements or div-inputs we might need to select all + delete - // For now, we assume click focuses it. + // First, check if this is a faceplate-* Shadow DOM component + const isShadowComponent = await element.evaluate((el) => { + return el.tagName.toLowerCase().startsWith('faceplate-') && !!el.shadowRoot; + }); - if (useDelay) { - await this.typeWithDelay(value); - } else { - // Try standard fill first, fallback to typing if it fails (e.g. custom element) - try { - await element.fill(value); - } catch { - await this.page.keyboard.type(value); + if (isShadowComponent) { + // Handle Shadow DOM components specially + this.logger.debug?.(`Found shadow DOM component: ${selector}`); + + // Click the outer element to focus it + await element.click(); + await this.page.waitForTimeout(100); + + if (useDelay) { + // For typing with delay, we need to focus the inner input first + await element.evaluate((el) => { + const inner = el.shadowRoot?.querySelector('input, textarea') as HTMLElement; + inner?.focus(); + }); + await this.page.waitForTimeout(100); + await this.typeWithDelay(value); + } else { + // Directly set value via evaluate and dispatch events + const success = await element.evaluate((el, val: string) => { + const inner = el.shadowRoot?.querySelector('input, textarea') as HTMLInputElement | HTMLTextAreaElement | null; + if (!inner) return false; + + // Focus the element + inner.focus(); + + // Clear existing value + inner.value = ''; + + // Set new value + inner.value = val; + + // Dispatch events that Reddit's validation listens for + inner.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + inner.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + inner.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, composed: true })); + + // Blur to trigger any onBlur validation + inner.blur(); + + return true; + }, value); + + if (!success) { + this.logger.warn?.(`Failed to set value via shadow DOM for ${selector}`); + continue; + } } + + // Verify the value was set + const verifiedValue = await element.evaluate((el) => { + const inner = el.shadowRoot?.querySelector('input, textarea') as HTMLInputElement | HTMLTextAreaElement | null; + return inner?.value || ''; + }); + + if (verifiedValue === value) { + this.logger.debug?.(`Successfully filled ${selector} with value`); + return true; + } else { + this.logger.warn?.(`Value mismatch after setting ${selector}: expected "${value.substring(0, 20)}...", got "${verifiedValue.substring(0, 20)}..."`); + // Still return true if we at least set something + if (verifiedValue.length > 0) return true; + } + } else { + // Standard element handling (non-shadow DOM) + await element.click(); + + const targetHandle = await element.evaluateHandle((el) => { + if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement).isContentEditable) { + return el; + } + return el.querySelector('input, textarea, [contenteditable="true"]'); + }); + const target = targetHandle.asElement() || element; + + if (useDelay) { + await target.click(); + await this.typeWithDelay(value); + } else { + try { + await target.fill(value); + } catch { + // Last resort fallback - use element directly since target may not be evaluatable + await element.evaluate((el, val: string) => { + const targetEl = (el.querySelector('input, textarea, [contenteditable="true"]') || el) as HTMLElement; + if (targetEl instanceof HTMLInputElement || targetEl instanceof HTMLTextAreaElement) { + targetEl.value = val; + } else if (targetEl.isContentEditable) { + targetEl.textContent = val; + } + targetEl.dispatchEvent(new Event('input', { bubbles: true })); + targetEl.dispatchEvent(new Event('change', { bubbles: true })); + }, value); + } + } + return true; } - return true; } } catch (e) { - // Ignore errors for individual selectors, try the next one + this.logger.warn?.(`Error filling field with selector ${selector}:`, e); + // Continue to next selector } } return false; @@ -277,4 +441,120 @@ export class RedditMessagePage { return false; } } + + /** + * Check if Reddit is showing an error after entering the recipient + * (e.g., "You are unable to send a message request to this account") + */ + private async checkRecipientError(): Promise { + const errorPatterns = [ + 'unable to send a message', + 'unable to send message', + 'cannot send message', + 'can\'t send message', + 'user not found', + 'user does not exist', + 'doesn\'t accept direct messages', + 'blocked', + 'something went wrong', + ]; + + // Retry for up to 4 seconds to allow async validation to complete + const maxRetries = 8; + for (let i = 0; i < maxRetries; i++) { + try { + // 1. Check specific Shadow DOM path (most reliable for new UI) + const specificError = await this.page.evaluate(() => { + const input = document.querySelector('faceplate-text-input[name="message-recipient-input"]'); + if (!input || !input.shadowRoot) return null; + + const helper = input.shadowRoot.querySelector('faceplate-form-helper-text'); + if (!helper) return null; + + // Check deep shadow text + if (helper.shadowRoot) { + const inner = helper.shadowRoot.querySelector('#helper-text'); + if (inner && inner.textContent) return inner.textContent; + } + + // Check light text + return helper.textContent; + }); + + if (specificError) { + const lowerText = specificError.toLowerCase(); + if (errorPatterns.some(p => lowerText.includes(p))) { + return specificError.trim(); + } + } + + // 2. Check standard DOM selectors + const errorSelectors = [ + '.error', + '[class*="error"]', + '[class*="Error"]', + '[role="alert"]', + 'span[style*="color: red"]', + 'faceplate-text-input[name="message-recipient-input"] ~ *', + ]; + + for (const selector of errorSelectors) { + const elements = await this.page.$$(selector); + for (const el of elements) { + const text = await el.textContent(); + if (text) { + const lowerText = text.toLowerCase(); + if (errorPatterns.some(p => lowerText.includes(p))) { + return text.trim(); + } + } + } + } + + // 3. Fallback: Full Shadow DOM Crawl (expensive but thorough) + // Only do this on later retries to save time initially? Or just do it. + const errorFromShadow = await this.page.evaluate((patterns: string[]) => { + function collectAllElements(root: ParentNode): Element[] { + const elements: Element[] = []; + if (!root) return elements; + const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + let current = treeWalker.currentNode as Element | null; + while (current) { + elements.push(current); + const maybeShadow = (current as Element & { shadowRoot?: ShadowRoot }).shadowRoot; + if (maybeShadow) { + elements.push(...collectAllElements(maybeShadow)); + } + current = treeWalker.nextNode() as Element | null; + } + return elements; + } + + const allElements = collectAllElements(document); + for (const el of allElements) { + const text = (el.textContent || '').toLowerCase(); + if (text.length > 200 || text.length < 5) continue; // Filter reasonable length + + for (const pattern of patterns) { + if (text.includes(pattern)) { + return (el.textContent || '').trim(); + } + } + } + return null; + }, errorPatterns); + + if (errorFromShadow) return errorFromShadow; + + } catch (error) { + this.logger.warn('Error checking for recipient error:', error); + } + + // Wait before next retry + await this.page.waitForTimeout(500); + } + + return null; + } } + diff --git a/src/config/index.ts b/src/config/index.ts index 0615aa2..f556b43 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -12,12 +12,53 @@ export const ConfigSchema = z.object({ pollIntervalMs: z.number().default(120000), userAgent: z.string().default('RedditBot/1.0'), targetGenders: z.array(z.enum(['male', 'female', 'other', 'unknown'])).default(['female']), + filterSellers: z.boolean().default(true), + sellerKeywords: z + .array(z.string()) + .default([ + 'onlyfans', + 'only fans', + 'fansly', + 'manyvids', + 'linktree', + 'linktr.ee', + 'allmylinks', + 'beacons.ai', + 'cash.app', + 'cashapp', + 'venmo', + 'paypal', + 'subscribe', + 'premium snap', + 'premiumsnap', + 'fan.ly', + 'fanvue', + 'loyalfans', + 'pornhub.com', + 'xvideos.com', + 'chaturbate', + 'myfreecams', + 'cam girl', + 'camgirl', + 'buy my', + 'custom content', + 'custom vid', + 'selling content', + 'paid content', + 'tip me', + 'spoil me', + 'findom', + 'send tribute', + 'wishlist', + 'throne.com', + ]), username: z.string().optional(), password: z.string().optional(), }), openrouter: z.object({ apiKey: z.string(), imageAnalysisModel: z.string().default('google/gemini-3-flash-preview'), + imageAnalysisFallbackModels: z.array(z.string()).default([]), chatModel: z.string().default('google/gemini-3-flash-preview'), imageAnalysisPrompt: z.string(), messageGenerationPrompt: z.string(), @@ -35,6 +76,32 @@ export const ConfigSchema = z.object({ database: z.object({ path: z.string().default('./data/reddit-bot.db'), }), + media: z.object({ + syncIntervalMs: z.number().default(1800000), // 30 minutes + syncEnabled: z.boolean().default(true), + }).default({ syncIntervalMs: 1800000, syncEnabled: true }), + autoReply: z.object({ + enabled: z.boolean().default(false), + autoSend: z.boolean().default(false), // If true, send replies automatically. If false, require manual approval + checkIntervalMs: z.number().default(300000), // 5 minutes + upsellKeywords: z.array(z.string()).default([ + 'onlyfans', 'only fans', 'fansly', 'manyvids', 'linktree', 'linktr.ee', + 'subscribe', 'premium', 'paid content', 'custom content', 'custom vid', + 'cashapp', 'cash app', 'venmo', 'paypal', 'tip me', 'spoil me', + 'wishlist', 'throne.com', 'link in bio', 'check my profile', + 'fanvue', 'loyalfans', 'cam show', 'buy my', 'selling', + 'dm me for price', 'menu', 'rates', 'ppv', + ]), + }).default({ enabled: false, autoSend: false, checkIntervalMs: 300000, upsellKeywords: [] }), + strategies: z.object({ + explorationRate: z.number().min(0).max(1).default(0.2), + minSamples: z.number().default(5), + variants: z.array(z.object({ + id: z.string(), + weight: z.number().default(1), + promptOverride: z.string(), + })).default([]), + }).default({ explorationRate: 0.2, minSamples: 5, variants: [] }), web: z.object({ enabled: z.boolean().default(true), port: z.number().default(3000), diff --git a/src/database/client.ts b/src/database/client.ts index ca5687b..55898e9 100644 --- a/src/database/client.ts +++ b/src/database/client.ts @@ -81,6 +81,12 @@ export function runMigrations(db: Database.Database): void { db.exec(`CREATE INDEX IF NOT EXISTS idx_sent_messages_response ON sent_messages(got_response)`); db.exec(`CREATE INDEX IF NOT EXISTS idx_sent_messages_recipient ON sent_messages(recipient)`); + // Add strategy_id column for A/B testing + addColumnIfNotExists('sent_messages', 'strategy_id', 'TEXT'); + + // Add subreddit column to successful_messages for smarter example selection + addColumnIfNotExists('successful_messages', 'subreddit', 'TEXT'); + // Migration: Make sent_message_id nullable in successful_messages // SQLite doesn't support ALTER COLUMN, so we need to recreate the table const successfulMsgColumns = db.prepare(`PRAGMA table_info(successful_messages)`).all() as { name: string; notnull: number }[]; diff --git a/src/database/repositories.ts b/src/database/repositories.ts index e1ed244..a8b454a 100644 --- a/src/database/repositories.ts +++ b/src/database/repositories.ts @@ -22,7 +22,10 @@ export interface SentMessageRecord { sentAt: number; gotResponse: boolean; responseCheckedAt: number | null; + strategyId: string | null; subreddit?: string; + postTitle?: string; + postImageUrl?: string; } export interface SuccessfulMessageRecord { @@ -32,9 +35,31 @@ export interface SuccessfulMessageRecord { imageAnalysis: string | null; postTitle: string | null; responseSnippet: string | null; + subreddit: string | null; createdAt: number; } +export interface StrategyStats { + strategyId: string; + totalSent: number; + totalResponses: number; + responseRate: number; +} + +export interface SubredditStats { + subreddit: string; + totalSent: number; + totalResponses: number; + responseRate: number; +} + +export interface DailyTrend { + date: string; + totalSent: number; + totalResponses: number; + responseRate: number; +} + export class PostsRepository { private insertStmt: Database.Statement; private existsStmt: Database.Statement; @@ -42,6 +67,7 @@ export class PostsRepository { private updateAnalysisStmt: Database.Statement; private setErrorStmt: Database.Statement; private getByIdStmt: Database.Statement; + private getByAuthorStmt: Database.Statement; constructor(private db: Database.Database) { this.insertStmt = db.prepare(` @@ -62,6 +88,10 @@ export class PostsRepository { this.setErrorStmt = db.prepare(`UPDATE processed_posts SET error = ? WHERE id = ?`); this.getByIdStmt = db.prepare(`SELECT * FROM processed_posts WHERE id = ?`); + + this.getByAuthorStmt = db.prepare(` + SELECT * FROM processed_posts WHERE author = ? COLLATE NOCASE ORDER BY processed_at DESC LIMIT 10 + `); } isProcessed(postId: string): boolean { @@ -115,6 +145,22 @@ export class PostsRepository { error: row.error as string | null, }; } + + getByAuthor(author: string): ProcessedPostRecord[] { + const rows = this.getByAuthorStmt.all(author) as Record[]; + return rows.map((row) => ({ + id: row.id as string, + subreddit: row.subreddit as string, + author: row.author as string, + title: row.title as string, + imageUrl: row.image_url as string | null, + imageAnalysis: row.image_analysis as string | null, + generatedMessage: row.generated_message as string | null, + processedAt: row.processed_at as number, + messageSent: Boolean(row.message_sent), + error: row.error as string | null, + })); + } } export class MessagesRepository { @@ -128,14 +174,14 @@ export class MessagesRepository { constructor(private db: Database.Database) { this.insertStmt = db.prepare(` - INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at) - VALUES (?, ?, ?, ?, ?) + INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at, strategy_id) + VALUES (?, ?, ?, ?, ?, ?) `); this.getByPostIdStmt = db.prepare(`SELECT * FROM sent_messages WHERE post_id = ?`); this.getRecentStmt = db.prepare(` - SELECT sm.*, pp.subreddit + SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url FROM sent_messages sm LEFT JOIN processed_posts pp ON sm.post_id = pp.id ORDER BY sm.sent_at DESC @@ -170,13 +216,15 @@ export class MessagesRepository { subject: string; message: string; sentAt: number; + strategyId?: string | null; }): number { const result = this.insertStmt.run( message.postId, message.recipient, message.subject, message.message, - message.sentAt + message.sentAt, + message.strategyId || null ); return result.lastInsertRowid as number; } @@ -212,6 +260,72 @@ export class MessagesRepository { return this.hasSentToStmt.get(recipient) !== undefined; } + getStrategyStats(): StrategyStats[] { + const rows = this.db.prepare(` + SELECT + strategy_id, + COUNT(*) as total_sent, + SUM(CASE WHEN got_response = 1 THEN 1 ELSE 0 END) as total_responses + FROM sent_messages + WHERE strategy_id IS NOT NULL + GROUP BY strategy_id + `).all() as Record[]; + + return rows.map(row => ({ + strategyId: row.strategy_id as string, + totalSent: row.total_sent as number, + totalResponses: row.total_responses as number, + responseRate: (row.total_sent as number) > 0 + ? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100) + : 0, + })); + } + + getSubredditStats(): SubredditStats[] { + const rows = this.db.prepare(` + SELECT + pp.subreddit, + COUNT(*) as total_sent, + SUM(CASE WHEN sm.got_response = 1 THEN 1 ELSE 0 END) as total_responses + FROM sent_messages sm + JOIN processed_posts pp ON sm.post_id = pp.id + GROUP BY pp.subreddit + ORDER BY total_sent DESC + `).all() as Record[]; + + return rows.map(row => ({ + subreddit: row.subreddit as string, + totalSent: row.total_sent as number, + totalResponses: row.total_responses as number, + responseRate: (row.total_sent as number) > 0 + ? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100) + : 0, + })); + } + + getResponseRateTrend(days: number = 30): DailyTrend[] { + const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000); + const rows = this.db.prepare(` + SELECT + date(sent_at / 1000, 'unixepoch') as day, + COUNT(*) as total_sent, + SUM(CASE WHEN got_response = 1 THEN 1 ELSE 0 END) as total_responses + FROM sent_messages + WHERE sent_at > ? + GROUP BY day + ORDER BY day ASC + `).all(cutoff) as Record[]; + + return rows.map(row => ({ + date: row.day as string, + totalSent: row.total_sent as number, + totalResponses: row.total_responses as number, + responseRate: (row.total_sent as number) > 0 + ? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100) + : 0, + })); + } + private mapRow(row: Record): SentMessageRecord { return { id: row.id as number, @@ -222,7 +336,10 @@ export class MessagesRepository { sentAt: row.sent_at as number, gotResponse: Boolean(row.got_response), responseCheckedAt: row.response_checked_at as number | null, + strategyId: (row.strategy_id as string) || null, subreddit: (row.subreddit as string) || undefined, + postTitle: (row.post_title as string) || undefined, + postImageUrl: (row.post_image_url as string) || undefined, }; } } @@ -230,19 +347,29 @@ export class MessagesRepository { export class SuccessfulMessagesRepository { private insertStmt: Database.Statement; private getRecentStmt: Database.Statement; + private getBySubredditStmt: Database.Statement; private countStmt: Database.Statement; private existsByMessageStmt: Database.Statement; constructor(private db: Database.Database) { this.insertStmt = db.prepare(` - INSERT INTO successful_messages (sent_message_id, original_message, image_analysis, post_title, response_snippet, created_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO successful_messages (sent_message_id, original_message, image_analysis, post_title, response_snippet, subreddit, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) `); this.getRecentStmt = db.prepare(` SELECT * FROM successful_messages ORDER BY created_at DESC LIMIT ? `); + // Get examples prioritizing same subreddit, then falling back to others + this.getBySubredditStmt = db.prepare(` + SELECT *, + CASE WHEN subreddit = ? THEN 1 ELSE 0 END as subreddit_match + FROM successful_messages + ORDER BY subreddit_match DESC, created_at DESC + LIMIT ? + `); + this.countStmt = db.prepare(`SELECT COUNT(*) as count FROM successful_messages`); this.existsByMessageStmt = db.prepare(` @@ -256,6 +383,7 @@ export class SuccessfulMessagesRepository { imageAnalysis: string | null; postTitle: string | null; responseSnippet: string | null; + subreddit: string | null; }): void { this.insertStmt.run( data.sentMessageId, @@ -263,6 +391,7 @@ export class SuccessfulMessagesRepository { data.imageAnalysis, data.postTitle, data.responseSnippet, + data.subreddit, Date.now() ); } @@ -273,19 +402,32 @@ export class SuccessfulMessagesRepository { getRecent(limit: number = 5): SuccessfulMessageRecord[] { const rows = this.getRecentStmt.all(limit) as Record[]; - return rows.map((row) => ({ - id: row.id as number, - sentMessageId: row.sent_message_id as number, - originalMessage: row.original_message as string, - imageAnalysis: row.image_analysis as string | null, - postTitle: row.post_title as string | null, - responseSnippet: row.response_snippet as string | null, - createdAt: row.created_at as number, - })); + return rows.map((row) => this.mapRow(row)); + } + + getWeightedExamples(subreddit: string | null, limit: number = 3): SuccessfulMessageRecord[] { + if (subreddit) { + const rows = this.getBySubredditStmt.all(subreddit, limit) as Record[]; + return rows.map((row) => this.mapRow(row)); + } + return this.getRecent(limit); } count(): number { const result = this.countStmt.get() as { count: number }; return result.count; } + + private mapRow(row: Record): SuccessfulMessageRecord { + return { + id: row.id as number, + sentMessageId: row.sent_message_id as number, + originalMessage: row.original_message as string, + imageAnalysis: row.image_analysis as string | null, + postTitle: row.post_title as string | null, + responseSnippet: row.response_snippet as string | null, + subreddit: row.subreddit as string | null, + createdAt: row.created_at as number, + }; + } } diff --git a/src/index.ts b/src/index.ts index de352aa..f6d6a07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,12 @@ import { OpenRouterClient } from './ai/openrouter.js'; import { MonitorService } from './services/monitor.js'; import { ProcessorService } from './services/processor.js'; import { ResponseCheckerService } from './services/response-checker.js'; +import { StrategySelector } from './services/strategy-selector.js'; import { ChatService } from './services/chat.js'; +import { MediaService } from './services/media.js'; +import { MediaSyncService } from './services/media-sync.js'; +import { AutoReplyService } from './services/auto-reply.js'; +import { PlaywrightMediaDownloader } from './browser/media-downloader.js'; import { RateLimiter } from './utils/rate-limiter.js'; import { createLogger } from './utils/logger.js'; import { withRetry } from './utils/retry.js'; @@ -82,19 +87,66 @@ async function main(): Promise { // Initialize services const redditApi = new RedditApiClient(config, logger); redditApi.setBrowserContext(browserClient.getContext()!); + + // Create separate pages for concurrent operations + // 1. chatPage: For background response checking + const chatPage = await browserClient.getContext()!.newPage(); + // 2. webUIPage: For user-driven chat browsing in the web dashboard + const webUIPage = await browserClient.getContext()!.newPage(); + const aiClient = new OpenRouterClient(config, logger); const monitorService = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger); const processorService = new ProcessorService(redditApi, aiClient, config, logger); + const strategySelector = new StrategySelector(config, messagesRepo, logger); + processorService.setStrategySelector(strategySelector); const responseChecker = new ResponseCheckerService( - page, + chatPage, messagesRepo, successfulMessagesRepo, postsRepo, logger ); - const chatService = new ChatService(page, logger, successfulMessagesRepo); + const mediaDownloader = new PlaywrightMediaDownloader(webUIPage, logger); + const mediaService = new MediaService(mediaDownloader, logger); + const chatService = new ChatService(webUIPage, logger, successfulMessagesRepo, mediaService); + const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs); const rateLimiter = new RateLimiter(config, logger); + await chatService.loadCache(); + + // Initialize auto-reply service + const autoReplyService = new AutoReplyService( + chatService, + aiClient, + config, + logger, + // onReplyGenerated + (chat) => { + webServer?.logActivity('info', `Auto-reply pending approval for u/${chat.username}`, chat.username); + webServer?.pushLog('info', `Reply pending approval for u/${chat.username}: "${chat.pendingReply?.slice(0, 80)}..."`); + }, + // onUpsellDetected + (chat, keyword) => { + webServer?.logActivity('skipped', `Upsell detected from u/${chat.username} (keyword: "${keyword}")`, chat.username); + webServer?.pushLog('warn', `Upsell detected from u/${chat.username} β€” keyword: "${keyword}"`); + }, + // onAutoSent + (chat) => { + webServer?.logActivity('sent', `Auto-reply sent to u/${chat.username}`, chat.username); + webServer?.pushLog('info', `Auto-reply sent to u/${chat.username}`); + }, + ); + + // Start background media sync + if (config.media.syncEnabled) { + mediaSyncService.start(); + } + + // Start auto-reply service + if (config.autoReply.enabled) { + autoReplyService.start(); + } + // Initialize web dashboard let webServer: WebServer | null = null; if (config.web.enabled) { @@ -107,7 +159,14 @@ async function main(): Promise { logger ); webServer.setChatService(chatService); + webServer.setMediaService(mediaService); + webServer.setMediaSyncService(mediaSyncService); webServer.setResponseChecker(responseChecker); + webServer.setRateLimiter(rateLimiter); + webServer.setAiClient(aiClient); + webServer.setStrategySelector(strategySelector); + webServer.setAutoReplyService(autoReplyService); + webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard')); webServer.start(); } @@ -121,11 +180,19 @@ async function main(): Promise { // Graceful shutdown handler let isShuttingDown = false; + let responseCheckInterval: NodeJS.Timeout | null = null; const shutdown = async (): Promise => { if (isShuttingDown) return; isShuttingDown = true; + if (responseCheckInterval) { + clearInterval(responseCheckInterval); + } + + mediaSyncService.stop(); + autoReplyService.stop(); + logger.info('Shutting down gracefully...'); webServer?.stop(); await browserClient.close(); @@ -145,28 +212,37 @@ async function main(): Promise { logger.info(`Max messages per hour: ${config.rateLimit.maxMessagesPerHour}`); logger.info(''); + // Start background response checking + const RESPONSE_CHECK_INTERVAL_MS = Math.max(config.reddit.pollIntervalMs * 5, 60000); + + const checkResponses = async () => { + if (isShuttingDown) return; + + try { + logger.debug('Checking for responses to sent messages (Background Thread)...'); + const responses = await responseChecker.checkForResponses(); + + if (responses.some((r) => r.gotResponse)) { + // Reload successful examples + const updatedExamples = responseChecker.getSuccessfulExamples(3); + processorService.setSuccessfulExamples(updatedExamples); + } + } catch (error) { + logger.error('Error in background response checker:', error); + } + }; + + // Run immediately then schedule + checkResponses(); + responseCheckInterval = setInterval(checkResponses, RESPONSE_CHECK_INTERVAL_MS); + // Track poll states let pollCycle = 0; - let lastResponseCheck = 0; - const RESPONSE_CHECK_INTERVAL_MS = Math.max(config.reddit.pollIntervalMs * 5, 60000); let lastPausedLog = 0; // Main loop while (!isShuttingDown) { try { - // Periodically check for responses to learn from (run this even if paused) - if (Date.now() - lastResponseCheck > RESPONSE_CHECK_INTERVAL_MS) { - logger.debug('Checking for responses to sent messages...'); - const responses = await responseChecker.checkForResponses(); - lastResponseCheck = Date.now(); - - if (responses.some((r) => r.gotResponse)) { - // Reload successful examples - const updatedExamples = responseChecker.getSuccessfulExamples(3); - processorService.setSuccessfulExamples(updatedExamples); - } - } - // Check if bot is running (controlled via web UI) if (webServer && !webServer.isRunning()) { if (Date.now() - lastPausedLog > 60000) { // Log every minute @@ -184,6 +260,7 @@ async function main(): Promise { if (posts.length > 0) { logger.info(`Found ${posts.length} unprocessed posts`); + webServer?.pushLog('info', `Found ${posts.length} unprocessed posts`); } else { logger.info('No new posts found. Monitoring...'); } @@ -193,15 +270,32 @@ async function main(): Promise { try { // Mark as processed immediately to avoid duplicates on restart + const postImageUrl = redditApi.getImageUrl(post); postsRepo.insert({ id: post.id, subreddit: post.subreddit, author: post.author, title: post.title, - imageUrl: redditApi.getImageUrl(post), + imageUrl: postImageUrl, processedAt: Date.now(), }); + // Check if user is a seller/OnlyFans profile (before spending on AI) + if (config.reddit.filterSellers && config.reddit.sellerKeywords.length > 0) { + const isSeller = await redditApi.isSellerProfile(post.author, config.reddit.sellerKeywords); + if (isSeller) { + logger.info(`Skipping u/${post.author} - seller/OnlyFans profile detected`); + webServer?.logActivity('skipped', `Skipped u/${post.author} (seller profile)`, post.author); + webServer?.pushLog('info', `🚫 Skipped u/${post.author} - seller/OnlyFans profile`); + postsRepo.setError(post.id, 'Seller profile detected'); + continue; + } + } + + // Refresh examples weighted toward this post's subreddit + const subredditExamples = responseChecker.getSuccessfulExamples(3, post.subreddit); + processorService.setSuccessfulExamples(subredditExamples); + // Process post (download image, analyze, generate message) const processed = await withRetry( () => processorService.processPost(post), @@ -217,12 +311,19 @@ async function main(): Promise { // Update database with analysis postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage); + // Log image analysis and strategy to realtime log + webServer?.pushLog('info', `πŸ–ΌοΈ Image analysis for u/${post.author}: ${processed.imageAnalysis}`); + if (processed.strategyId) { + webServer?.pushLog('info', `🎯 Strategy: ${processed.strategyId}`); + } + // Create subject line const subject = "Stunning"; // Send message with rate limiting await rateLimiter.throttle(async () => { logger.info(`Sending message to u/${post.author}...`); + webServer?.pushLog('info', `Sending message to u/${post.author}...`); const success = await messagePage.sendMessage( post.author, @@ -231,37 +332,35 @@ async function main(): Promise { ); if (success) { - // Verify the message actually appears in chat - const verified = await chatService.verifyMessageSent( - post.author, - processed.generatedMessage + // Message was sent successfully via /message/compose + // Note: We skip chat.reddit.com verification since /message/compose sends to inbox, not chat + // The sendMessage function already confirms success via form submission indicators + postsRepo.markMessageSent(post.id); + + // Refresh top chats in background so dashboard is up-to-date + chatService.refreshTopChats(5).catch(err => logger.error('Failed to trigger chat refresh:', err)); + + messagesRepo.insert({ + postId: post.id, + recipient: post.author, + subject, + message: processed.generatedMessage, + sentAt: Date.now(), + strategyId: processed.strategyId, + }); + + const stats = rateLimiter.getStats(); + const currentCount = stats.messagesThisHour + 1; + logger.info( + `Message sent successfully! (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)` ); - - if (verified) { - postsRepo.markMessageSent(post.id); - messagesRepo.insert({ - postId: post.id, - recipient: post.author, - subject, - message: processed.generatedMessage, - sentAt: Date.now(), - }); - - const stats = rateLimiter.getStats(); - const currentCount = stats.messagesThisHour + 1; - logger.info( - `Message sent and verified! (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)` - ); - webServer?.logActivity('sent', `Sent message to u/${post.author}`, post.author); - webServer?.updateStats(currentCount); - } else { - logger.warn(`Message to u/${post.author} likely sent but not found in chat verification.`); - postsRepo.setError(post.id, 'Message sent but verification failed'); - webServer?.logActivity('error', `Verification failed for u/${post.author}`, post.author); - } + webServer?.logActivity('sent', `Sent message to u/${post.author}`, post.author, postImageUrl ?? undefined); + webServer?.pushLog('info', `βœ… Message sent to u/${post.author} (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)`); + webServer?.updateStats(currentCount); } else { postsRepo.setError(post.id, 'Failed to send message'); - webServer?.logActivity('error', `Failed to send to u/${post.author}`, post.author); + webServer?.logActivity('error', `Failed to send to u/${post.author}`, post.author, postImageUrl ?? undefined); + webServer?.pushLog('error', `❌ Failed to send message to u/${post.author}`); } return success; diff --git a/src/reddit/api.ts b/src/reddit/api.ts index 3744893..35bff69 100644 --- a/src/reddit/api.ts +++ b/src/reddit/api.ts @@ -180,6 +180,53 @@ export class RedditApiClient { return url.replace(/&/g, '&'); } + async isSellerProfile(username: string, sellerKeywords: string[]): Promise { + if (sellerKeywords.length === 0) return false; + + try { + const cookieHeader = await this.getCookieHeader(); + const headers: Record = {}; + if (cookieHeader) { + headers['Cookie'] = cookieHeader; + } + + const response = await this.client.get(`/user/${username}/about.json`, { + params: { raw_json: 1 }, + headers, + }); + + const userData = response.data?.data; + if (!userData) return false; + + // Combine all text fields to scan + const textToCheck = [ + userData.subreddit?.public_description || '', + userData.subreddit?.title || '', + userData.subreddit?.display_name_prefixed || '', + ] + .join(' ') + .toLowerCase(); + + for (const keyword of sellerKeywords) { + if (textToCheck.includes(keyword.toLowerCase())) { + this.logger.info( + `u/${username} flagged as seller profile (matched: "${keyword}")` + ); + return true; + } + } + + return false; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + this.logger.warn(`User u/${username} profile not found`); + return false; + } + this.logger.warn(`Failed to check profile for u/${username}:`, error); + return false; // Don't block on errors - let it through + } + } + getPostUrl(post: RedditPost): string { return `https://www.reddit.com${post.permalink}`; } diff --git a/src/services/auto-reply.ts b/src/services/auto-reply.ts new file mode 100644 index 0000000..11849d2 --- /dev/null +++ b/src/services/auto-reply.ts @@ -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(); + + 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 { + 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 { + if (!this.config.autoReply.enabled) return; + await this.processUnreadChats(); + } + + private async processUnreadChats(): Promise { + 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 { + 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)); + } +} diff --git a/src/services/chat.ts b/src/services/chat.ts index 007e099..57d4699 100644 --- a/src/services/chat.ts +++ b/src/services/chat.ts @@ -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 & { 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 = Promise.resolve(); + + private enqueue(task: () => Promise): Promise { + 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 { + // 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 { - // 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 { + 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 { + 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 { + 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 { + 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 { 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 { + 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 { this.logger.info(`Verifying message to ${recipient} in chat...`); @@ -283,8 +661,8 @@ export class ChatService { } } - private async scrapeConversationMessages(recipientUsername: string): Promise { - const messages: ChatMessage[] = []; + private async scrapeConversationMessages(recipientUsername: string): Promise { + 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 { 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(); + 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 { + await this.saveCache(this.cachedChats); + } + + private async saveCache(chats: ActiveChat[]): Promise { + 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> { + 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(); diff --git a/src/services/media-sync.ts b/src/services/media-sync.ts new file mode 100644 index 0000000..566e663 --- /dev/null +++ b/src/services/media-sync.ts @@ -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 { + 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 { + 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; + } + } +} diff --git a/src/services/media.ts b/src/services/media.ts new file mode 100644 index 0000000..e266528 --- /dev/null +++ b/src/services/media.ts @@ -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; +} + +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; + +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 { + 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 { + await this.loadIndex(); + return (this.index?.items || []).slice().sort((a, b) => b.createdAt - a.createdAt); + } + + private async downloadMessageMedia(chat: ActiveChat, message: ScrapedChatMessage): Promise { + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/src/services/monitor.ts b/src/services/monitor.ts index d92d381..200caf1 100644 --- a/src/services/monitor.ts +++ b/src/services/monitor.ts @@ -13,36 +13,40 @@ export class MonitorService { ) { } async getUnprocessedPosts(): Promise { - 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(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 { diff --git a/src/services/processor.ts b/src/services/processor.ts index a2481bd..9d565b3 100644 --- a/src/services/processor.ts +++ b/src/services/processor.ts @@ -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, }; } } diff --git a/src/services/response-checker.ts b/src/services/response-checker.ts index 03fb9a5..a265abf 100644 --- a/src/services/response-checker.ts +++ b/src/services/response-checker.ts @@ -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; }); } diff --git a/src/services/strategy-selector.ts b/src/services/strategy-selector.ts new file mode 100644 index 0000000..82941a9 --- /dev/null +++ b/src/services/strategy-selector.ts @@ -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; + } +} diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts index 143a02c..bc1ee50 100644 --- a/src/utils/rate-limiter.ts +++ b/src/utils/rate-limiter.ts @@ -70,4 +70,45 @@ export class RateLimiter { minutesSinceLastMessage: Math.round((Date.now() - this.lastMessageTime) / 60000), }; } + + /** + * Get active timer information for the dashboard + */ + getTimers(): { + nextMessageAvailableAt: number | null; + hourlyResetAt: number; + isHourlyLimitReached: boolean; + messagesRemaining: number; + } { + const now = Date.now(); + const minDelay = this.config.rateLimit.minDelayBetweenMessagesMs; + const maxMessages = this.config.rateLimit.maxMessagesPerHour; + + // Calculate when the next message can be sent based on min delay + let nextMessageAvailableAt: number | null = null; + if (this.lastMessageTime > 0) { + const nextAvailable = this.lastMessageTime + minDelay; + if (nextAvailable > now) { + nextMessageAvailableAt = nextAvailable; + } + } + + // Calculate when the hourly counter resets + const hourlyResetAt = this.hourStartTime + 3600000; + + // Check if hourly limit is reached + const isHourlyLimitReached = this.messagesThisHour >= maxMessages; + + // If hourly limit is reached, next message is available when the hour resets + if (isHourlyLimitReached) { + nextMessageAvailableAt = hourlyResetAt; + } + + return { + nextMessageAvailableAt, + hourlyResetAt, + isHourlyLimitReached, + messagesRemaining: Math.max(0, maxMessages - this.messagesThisHour), + }; + } } diff --git a/src/web/server.ts b/src/web/server.ts index 8b17533..a97e285 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1,11 +1,18 @@ import express from 'express'; import path from 'path'; import type { Server } from 'http'; +import type { Response } from 'express'; import type { Logger } from '../utils/logger.js'; import type { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from '../database/repositories.js'; import type { Config } from '../config/index.js'; import type { ChatService, ActiveChat } from '../services/chat.js'; +import type { MediaService } from '../services/media.js'; +import type { MediaSyncService } from '../services/media-sync.js'; import type { ResponseCheckerService } from '../services/response-checker.js'; +import type { RateLimiter } from '../utils/rate-limiter.js'; +import type { OpenRouterClient } from '../ai/openrouter.js'; +import type { AutoReplyService } from '../services/auto-reply.js'; +import type { StrategySelector } from '../services/strategy-selector.js'; export interface BotStats { isRunning: boolean; @@ -23,19 +30,48 @@ export interface BotStats { export interface ActivityItem { timestamp: number; - type: 'sent' | 'skipped' | 'response' | 'error'; + type: 'sent' | 'skipped' | 'response' | 'error' | 'info' | 'debug'; message: string; recipient?: string; + imageUrl?: string; +} + +export interface LogEntry { + timestamp: number; + level: 'info' | 'warn' | 'error' | 'debug'; + message: string; + data?: unknown; +} + +export interface RuntimeConfig { + rateLimit: { + minDelayBetweenMessagesMs: number; + maxMessagesPerHour: number; + jitterMs: number; + }; + browser: { + dryRun: boolean; + }; } export class WebServer { private app: express.Application; private server: Server | null = null; private activityLog: ActivityItem[] = []; + private realtimeLog: LogEntry[] = []; + private sseClients: Set = new Set(); private botRunning = false; // Start stopped private messagesThisHour = 0; private chatService: ChatService | null = null; + private mediaService: MediaService | null = null; + private mediaSyncService: MediaSyncService | null = null; private responseChecker: ResponseCheckerService | null = null; + private rateLimiter: RateLimiter | null = null; + private aiClient: OpenRouterClient | null = null; + private autoReplyService: AutoReplyService | null = null; + private strategySelector: StrategySelector | null = null; + private runtimeConfig: RuntimeConfig; + private onConfigChange: ((config: RuntimeConfig) => void) | null = null; constructor( private port: number, @@ -46,9 +82,21 @@ export class WebServer { private logger: Logger ) { this.app = express(); + // Initialize runtime config from loaded config + this.runtimeConfig = { + rateLimit: { + minDelayBetweenMessagesMs: config.rateLimit.minDelayBetweenMessagesMs, + maxMessagesPerHour: config.rateLimit.maxMessagesPerHour, + jitterMs: config.rateLimit.jitterMs, + }, + browser: { + dryRun: config.browser.dryRun, + }, + }; this.setupRoutes(); } private setupRoutes(): void { + this.app.use('/media', express.static(path.join(process.cwd(), 'data', 'media'))); // Serve static files this.app.use(express.static(path.join(process.cwd(), 'public'))); this.app.use(express.json()); @@ -64,6 +112,12 @@ export class WebServer { res.json(this.activityLog.slice(-50).reverse()); }); + // API: Look up posts by author username + this.app.get('/api/posts/by-author/:username', (req, res) => { + const posts = this.postsRepo.getByAuthor(req.params.username); + res.json(posts); + }); + // API: Get recent messages this.app.get('/api/messages', (req, res) => { const messages = this.messagesRepo.getRecent(20); @@ -76,21 +130,347 @@ export class WebServer { res.json(successful); }); - // API: Get active chats (last 48 hours) + // API: Get active chats (from cache - last 48 hours) + // Does NOT fetch from Reddit - use /api/chats/fetch-all to refresh from Reddit this.app.get('/api/chats', async (req, res) => { if (!this.chatService) { res.json([]); return; } try { - const chats = await this.chatService.getActiveChats(); - res.json(chats); + const allChats = await this.chatService.getCachedConversations(); + // Filter to last 48 hours for "active" view + const cutoffTime = Date.now() - (48 * 60 * 60 * 1000); + const activeChats = allChats.filter(chat => chat.lastMessageTime >= cutoffTime); + res.json(activeChats); } catch (error) { this.logger.error('Failed to fetch chats:', error); res.json([]); } }); + // API: Fetch all chats/messages and refresh cache + this.app.post('/api/chats/fetch-all', async (req, res) => { + if (!this.chatService) { + res.status(500).json({ error: 'Chat service not initialized' }); + return; + } + try { + const chats = await this.chatService.fetchAllMessages(); + res.json({ success: true, count: chats.length }); + } catch (error) { + this.logger.error('Failed to fetch all chats:', error); + res.status(500).json({ error: String(error) }); + } + }); + + // API: Get cached conversations with full message history + this.app.get('/api/conversations', async (req, res) => { + if (!this.chatService) { + res.json([]); + return; + } + try { + const conversations = await this.chatService.getCachedConversations(); + res.json(conversations); + } catch (error) { + this.logger.error('Failed to fetch conversations:', error); + res.json([]); + } + }); + + // API: Get a single cached conversation by room ID or username + this.app.get('/api/conversations/:id', async (req, res) => { + if (!this.chatService) { + res.status(404).json({ error: 'Chat service not initialized' }); + return; + } + try { + const conversations = await this.chatService.getCachedConversations(); + const id = decodeURIComponent(req.params.id); + const conversation = conversations.find( + (c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id + ); + if (!conversation) { + res.status(404).json({ error: 'Conversation not found' }); + return; + } + res.json(conversation); + } catch (error) { + this.logger.error('Failed to fetch conversation:', error); + res.status(500).json({ error: String(error) }); + } + }); + + // API: Suggest a reply for a conversation using AI + this.app.post('/api/conversations/:id/suggest-reply', async (req, res) => { + if (!this.chatService) { + res.status(500).json({ error: 'Chat service not initialized' }); + return; + } + if (!this.aiClient) { + res.status(500).json({ error: 'AI client not initialized' }); + return; + } + try { + const conversations = await this.chatService.getCachedConversations(); + const id = decodeURIComponent(req.params.id); + const conversation = conversations.find( + (c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id + ); + if (!conversation) { + res.status(404).json({ error: 'Conversation not found' }); + return; + } + if (!conversation.messages || conversation.messages.length === 0) { + res.status(400).json({ error: 'No messages in conversation to generate a reply from' }); + return; + } + const hasReply = conversation.messages.some(m => !m.isSelf); + if (!hasReply) { + res.status(400).json({ error: 'No reply from user yet - nothing to respond to' }); + return; + } + + const suggestedReply = await this.aiClient.generateReply( + conversation.messages.map(m => ({ sender: m.sender, text: m.text, isSelf: m.isSelf })), + conversation.username + ); + res.json({ suggestedReply }); + } catch (error) { + this.logger.error('Failed to generate suggested reply:', error); + res.status(500).json({ error: String(error) }); + } + }); + + // API: Send a reply in a chat conversation + this.app.post('/api/conversations/:id/send-reply', async (req, res) => { + if (!this.chatService) { + res.status(500).json({ error: 'Chat service not initialized' }); + return; + } + try { + const { message } = req.body; + if (!message || typeof message !== 'string' || message.trim().length === 0) { + res.status(400).json({ error: 'Message is required' }); + return; + } + + const conversations = await this.chatService.getCachedConversations(); + const id = decodeURIComponent(req.params.id); + const conversation = conversations.find( + (c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id + ); + if (!conversation) { + res.status(404).json({ error: 'Conversation not found' }); + return; + } + if (!conversation.url) { + res.status(400).json({ error: 'No chat URL for this conversation' }); + return; + } + + const success = await this.chatService.sendChatMessage(conversation.url, message.trim()); + if (success) { + this.logActivity('sent', `Sent chat reply to u/${conversation.username}`, conversation.username); + this.pushLog('info', `Chat reply sent to u/${conversation.username}`); + res.json({ success: true }); + + // Refresh chats in background so cache reflects the sent message + this.chatService.refreshTopChats(5).catch(err => + this.logger.error('Failed to refresh chats after send-reply:', err) + ); + } else { + res.status(500).json({ error: 'Failed to send chat message' }); + } + } catch (error) { + this.logger.error('Failed to send chat reply:', error); + res.status(500).json({ error: String(error) }); + } + }); + + // API: Approve a pending auto-reply + this.app.post('/api/conversations/:id/approve-reply', async (req, res) => { + if (!this.chatService) { + res.status(500).json({ error: 'Chat service not initialized' }); + return; + } + try { + const conversations = await this.chatService.getCachedConversations(); + const id = decodeURIComponent(req.params.id); + const conversation = conversations.find( + (c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id + ); + if (!conversation) { + res.status(404).json({ error: 'Conversation not found' }); + return; + } + if (conversation.replyStatus !== 'pending' || !conversation.pendingReply) { + res.status(400).json({ error: 'No pending reply to approve' }); + return; + } + if (!conversation.url) { + res.status(400).json({ error: 'No chat URL for this conversation' }); + return; + } + + const message = conversation.pendingReply; + const success = await this.chatService.sendChatMessage(conversation.url, message); + if (success) { + conversation.replyStatus = 'sent'; + conversation.pendingReply = undefined; + await this.chatService.saveCacheExternal(); + this.logActivity('sent', `Approved & sent auto-reply to u/${conversation.username}`, conversation.username); + this.pushLog('info', `Auto-reply approved and sent to u/${conversation.username}`); + res.json({ success: true }); + + // Refresh chats in background so cache reflects the sent message + this.chatService.refreshTopChats(5).catch(err => + this.logger.error('Failed to refresh chats after approve-reply:', err) + ); + } else { + res.status(500).json({ error: 'Failed to send message' }); + } + } catch (error) { + this.logger.error('Failed to approve reply:', error); + res.status(500).json({ error: String(error) }); + } + }); + + // API: Reject a pending auto-reply + this.app.post('/api/conversations/:id/reject-reply', async (req, res) => { + if (!this.chatService) { + res.status(500).json({ error: 'Chat service not initialized' }); + return; + } + try { + const conversations = await this.chatService.getCachedConversations(); + const id = decodeURIComponent(req.params.id); + const conversation = conversations.find( + (c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id + ); + if (!conversation) { + res.status(404).json({ error: 'Conversation not found' }); + return; + } + if (conversation.replyStatus !== 'pending') { + res.status(400).json({ error: 'No pending reply to reject' }); + return; + } + + conversation.pendingReply = undefined; + conversation.replyStatus = undefined; + + // Clear processed state so it can be re-evaluated later if needed + if (this.autoReplyService) { + this.autoReplyService.clearProcessedState(conversation.username); + } + + await this.chatService.saveCacheExternal(); + this.logActivity('info', `Rejected auto-reply for u/${conversation.username}`, conversation.username); + this.pushLog('info', `Auto-reply rejected for u/${conversation.username}`); + res.json({ success: true }); + } catch (error) { + this.logger.error('Failed to reject reply:', error); + res.status(500).json({ error: String(error) }); + } + }); + + // API: Dismiss upsell detection flag + this.app.post('/api/conversations/:id/dismiss-upsell', async (req, res) => { + if (!this.chatService) { + res.status(500).json({ error: 'Chat service not initialized' }); + return; + } + try { + const conversations = await this.chatService.getCachedConversations(); + const id = decodeURIComponent(req.params.id); + const conversation = conversations.find( + (c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id + ); + if (!conversation) { + res.status(404).json({ error: 'Conversation not found' }); + return; + } + + conversation.replyStatus = undefined; + conversation.pendingReply = undefined; + await this.chatService.saveCacheExternal(); + res.json({ success: true }); + } catch (error) { + this.logger.error('Failed to dismiss upsell flag:', error); + res.status(500).json({ error: String(error) }); + } + }); + + // API: Get downloaded chat media + this.app.get('/api/media', async (req, res) => { + if (!this.mediaService) { + res.json([]); + return; + } + try { + const items = await this.mediaService.getMediaItems(); + res.json(items.map((item) => ({ + ...item, + urlPath: `/media/${encodeURIComponent(item.relativePath)}`, + }))); + } catch (error) { + this.logger.error('Failed to fetch media index:', error); + res.status(500).json({ error: String(error) }); + } + }); + + // API: Get media sync status + this.app.get('/api/media/sync', (req, res) => { + if (!this.mediaSyncService) { + res.status(503).json({ error: 'Media sync service not initialized' }); + return; + } + res.json(this.mediaSyncService.getStatus()); + }); + + // API: Trigger media sync from dashboard + this.app.post('/api/media/sync', async (req, res) => { + if (!this.mediaSyncService) { + res.status(503).json({ error: 'Media sync service not initialized' }); + return; + } + try { + const full = req.body?.full === true; + const started = await this.mediaSyncService.triggerSync(full); + if (!started) { + res.json({ success: false, reason: 'Sync already in progress' }); + return; + } + this.pushLog('info', `Media sync triggered from dashboard (${full ? 'full' : 'incremental'})`); + res.json({ success: true }); + } catch (error) { + this.logger.error('Media sync trigger failed:', error); + res.status(500).json({ error: String(error) }); + } + }); + + // API: Full media sync - walks ALL conversations + this.app.post('/api/media/sync/full', async (req, res) => { + if (!this.mediaSyncService) { + res.status(503).json({ error: 'Media sync service not initialized' }); + return; + } + try { + const started = await this.mediaSyncService.triggerSync(true); + if (!started) { + res.json({ success: false, reason: 'Sync already in progress' }); + return; + } + this.pushLog('info', 'Full media sync triggered from dashboard (all conversations)'); + res.json({ success: true }); + } catch (error) { + this.logger.error('Full media sync trigger failed:', error); + res.status(500).json({ error: String(error) }); + } + }); + // API: Start bot this.app.post('/api/start', (req, res) => { this.botRunning = true; @@ -107,6 +487,26 @@ export class WebServer { res.json({ success: true, running: false }); }); + // API: Debug inspect chat DOM + this.app.post('/api/debug/inspect-dom', async (req, res) => { + if (!this.chatService) { + res.status(500).json({ error: 'Chat service not initialized' }); + return; + } + try { + const chatUrl = req.body?.url; + if (!chatUrl) { + res.status(400).json({ error: 'url is required' }); + return; + } + const result = await this.chatService.debugInspectDom(chatUrl); + res.json(result); + } catch (error) { + this.logger.error('DOM inspection failed:', error); + res.status(500).json({ error: String(error) }); + } + }); + // API: Debug check responses this.app.post('/api/debug/check-responses', async (req, res) => { if (!this.responseChecker) { @@ -123,6 +523,121 @@ export class WebServer { } }); + // API: SSE endpoint for realtime logs + this.app.get('/api/logs/stream', (req, res) => { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering + + // Send initial connection message + res.write(`data: ${JSON.stringify({ type: 'connected', timestamp: Date.now() })}\n\n`); + + // Add client to SSE set + this.sseClients.add(res); + + // Send recent logs as initial data + this.realtimeLog.slice(-50).forEach(log => { + res.write(`data: ${JSON.stringify({ type: 'log', ...log })}\n\n`); + }); + + // Remove client on disconnect + req.on('close', () => { + this.sseClients.delete(res); + }); + }); + + // API: Get recent logs (for initial load) + this.app.get('/api/logs', (req, res) => { + const limit = parseInt(req.query.limit as string) || 100; + res.json(this.realtimeLog.slice(-limit)); + }); + + // API: Get active timers + this.app.get('/api/timers', (req, res) => { + if (!this.rateLimiter) { + res.status(503).json({ error: 'Rate limiter not initialized' }); + return; + } + res.json(this.rateLimiter.getTimers()); + }); + + // API: Analytics - response rate by subreddit + this.app.get('/api/analytics/by-subreddit', (req, res) => { + res.json(this.messagesRepo.getSubredditStats()); + }); + + // API: Analytics - response rate by strategy + this.app.get('/api/analytics/by-strategy', (req, res) => { + if (this.strategySelector) { + res.json(this.strategySelector.getStats()); + } else { + res.json(this.messagesRepo.getStrategyStats()); + } + }); + + // API: Analytics - daily response rate trend + this.app.get('/api/analytics/trend', (req, res) => { + const days = parseInt(req.query.days as string) || 30; + res.json(this.messagesRepo.getResponseRateTrend(days)); + }); + + // API: Get runtime config + this.app.get('/api/config', (req, res) => { + res.json({ + rateLimit: this.runtimeConfig.rateLimit, + browser: { + dryRun: this.runtimeConfig.browser.dryRun, + }, + }); + }); + + // API: Update runtime config + this.app.put('/api/config', (req, res) => { + try { + const updates = req.body; + + // Validate and apply rate limit updates + if (updates.rateLimit) { + if (typeof updates.rateLimit.minDelayBetweenMessagesMs === 'number') { + this.runtimeConfig.rateLimit.minDelayBetweenMessagesMs = Math.max(0, updates.rateLimit.minDelayBetweenMessagesMs); + } + if (typeof updates.rateLimit.maxMessagesPerHour === 'number') { + this.runtimeConfig.rateLimit.maxMessagesPerHour = Math.max(1, updates.rateLimit.maxMessagesPerHour); + } + if (typeof updates.rateLimit.jitterMs === 'number') { + this.runtimeConfig.rateLimit.jitterMs = Math.max(0, updates.rateLimit.jitterMs); + } + } + + // Validate and apply browser updates + if (updates.browser) { + if (typeof updates.browser.dryRun === 'boolean') { + this.runtimeConfig.browser.dryRun = updates.browser.dryRun; + } + } + + // Also update the original config object so getStats reflects new values + this.config.rateLimit.minDelayBetweenMessagesMs = this.runtimeConfig.rateLimit.minDelayBetweenMessagesMs; + this.config.rateLimit.maxMessagesPerHour = this.runtimeConfig.rateLimit.maxMessagesPerHour; + this.config.rateLimit.jitterMs = this.runtimeConfig.rateLimit.jitterMs; + this.config.browser.dryRun = this.runtimeConfig.browser.dryRun; + + // Notify listeners of config change + if (this.onConfigChange) { + this.onConfigChange(this.runtimeConfig); + } + + this.logActivity('info', `Config updated via dashboard`); + this.pushLog('info', 'Configuration updated via dashboard', updates); + + res.json({ success: true, config: this.runtimeConfig }); + } catch (error) { + this.logger.error('Failed to update config:', error); + res.status(400).json({ error: String(error) }); + } + }); + // Serve dashboard for all other routes this.app.get(/.*/, (req, res) => { res.sendFile(path.join(process.cwd(), 'public', 'index.html')); @@ -136,10 +651,34 @@ export class WebServer { this.chatService = chatService; } + setMediaService(mediaService: MediaService): void { + this.mediaService = mediaService; + } + + setMediaSyncService(mediaSyncService: MediaSyncService): void { + this.mediaSyncService = mediaSyncService; + } + setResponseChecker(responseChecker: ResponseCheckerService): void { this.responseChecker = responseChecker; } + setRateLimiter(rateLimiter: RateLimiter): void { + this.rateLimiter = rateLimiter; + } + + setAiClient(aiClient: OpenRouterClient): void { + this.aiClient = aiClient; + } + + setStrategySelector(strategySelector: StrategySelector): void { + this.strategySelector = strategySelector; + } + + setAutoReplyService(autoReplyService: AutoReplyService): void { + this.autoReplyService = autoReplyService; + } + private getStats(): BotStats { const messages = this.messagesRepo.getRecent(1000); const totalSent = messages.length; @@ -165,12 +704,13 @@ export class WebServer { return this.activityLog.filter((a) => a.type === 'sent' || a.type === 'skipped').length; } - logActivity(type: ActivityItem['type'], message: string, recipient?: string): void { + logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void { const item: ActivityItem = { timestamp: Date.now(), type, message, recipient, + imageUrl, }; this.activityLog.push(item); @@ -180,6 +720,50 @@ export class WebServer { } } + /** + * Push a log entry to all connected SSE clients + */ + pushLog(level: LogEntry['level'], message: string, data?: unknown): void { + const entry: LogEntry = { + timestamp: Date.now(), + level, + message, + data, + }; + + this.realtimeLog.push(entry); + + // Keep only last 500 log entries + if (this.realtimeLog.length > 500) { + this.realtimeLog = this.realtimeLog.slice(-500); + } + + // Broadcast to all SSE clients + const eventData = JSON.stringify({ type: 'log', ...entry }); + this.sseClients.forEach(client => { + try { + client.write(`data: ${eventData}\n\n`); + } catch { + // Client disconnected, will be cleaned up on close event + this.sseClients.delete(client); + } + }); + } + + /** + * Get the current runtime configuration + */ + getRuntimeConfig(): RuntimeConfig { + return this.runtimeConfig; + } + + /** + * Set a callback to be notified when config changes + */ + setOnConfigChange(callback: (config: RuntimeConfig) => void): void { + this.onConfigChange = callback; + } + updateStats(messagesThisHour: number): void { this.messagesThisHour = messagesThisHour; } @@ -187,10 +771,21 @@ export class WebServer { start(): void { this.server = this.app.listen(this.port, () => { this.logger.info(`Web dashboard running at http://localhost:${this.port}`); + this.pushLog('info', `Web dashboard started on port ${this.port}`); }); } stop(): void { + // Close all SSE connections + this.sseClients.forEach(client => { + try { + client.end(); + } catch { + // Ignore errors when closing + } + }); + this.sseClients.clear(); + if (this.server) { this.server.close(); this.server = null;