Initial
This commit is contained in:
commit
c552956516
23 files changed
+5193
No files matched your search
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Data directory (contains session and database)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
reddit:
|
||||||
|
subreddits:
|
||||||
|
- normalnudes
|
||||||
|
- ratemyboobs
|
||||||
|
pollIntervalMs: 120000
|
||||||
|
userAgent: "RedditBot/1.0 (by /u/yourusername)"
|
||||||
|
targetGenders:
|
||||||
|
- female # Options: male, female, other, unknown
|
||||||
|
|
||||||
|
openrouter:
|
||||||
|
model: "google/gemini-3-flash-preview"
|
||||||
|
imageAnalysisPrompt: |
|
||||||
|
Analyze this NSFW image in explicit detail for adult content messaging. Describe:
|
||||||
|
- Body: physique, curves, skin tone, any tattoos/piercings, notable physical features, pull out explicit details about their body, sizes, shapes, colours, hair etc.
|
||||||
|
- Pose: how they're positioned, what's being shown/emphasized, confidence level
|
||||||
|
- Attire: clothing/lingerie if any, colors, style, how it fits
|
||||||
|
- Setting: location, lighting, props, aesthetic choices
|
||||||
|
- Vibe: mood, energy, what makes this image unique or eye-catching
|
||||||
|
- Standout details: anything specific that would make a good conversation hook
|
||||||
|
|
||||||
|
Be explicit and descriptive - this is for generating personalized adult messages.
|
||||||
|
Focus on details that could be genuinely complimented.
|
||||||
|
messageGenerationPrompt: |
|
||||||
|
Based on this image analysis: {analysis}
|
||||||
|
Post title: {title}
|
||||||
|
|
||||||
|
Write a casual Reddit DM (2-3 sentences) like a real person would type.
|
||||||
|
|
||||||
|
MUST follow:
|
||||||
|
- Start sentences with capital letters, use proper capitalisation
|
||||||
|
- Use words like hot, sexy, stunning, gorgeous to boost their confidence
|
||||||
|
- Reference something SPECIFIC from the image about their body so they know you actually looked
|
||||||
|
- Be direct and complimentary, not flowery or poetic
|
||||||
|
- End with a hook that encourages them to share MORE content (photos/videos)
|
||||||
|
|
||||||
|
GOOD HOOKS (use these styles):
|
||||||
|
- "Would love to see more of you"
|
||||||
|
- "Do you post more somewhere else?"
|
||||||
|
- "You should definitely post more"
|
||||||
|
- "Got any more pics?"
|
||||||
|
- "I'd love to see [specific thing] from another angle"
|
||||||
|
- "You holding out on us? Need to see more"
|
||||||
|
|
||||||
|
BAD HOOKS (never use):
|
||||||
|
- "How's your day?"
|
||||||
|
- "What are you up to?"
|
||||||
|
- Generic small talk questions
|
||||||
|
- "Tell me about yourself"
|
||||||
|
|
||||||
|
DO NOT:
|
||||||
|
- Sound like an AI or formal writing
|
||||||
|
- Use words like "captivating", "alluring", "exquisite", "radiant"
|
||||||
|
- Write long elaborate sentences
|
||||||
|
- Use all lowercase
|
||||||
|
|
||||||
|
Examples of good messages:
|
||||||
|
- "Damn you look stunning in that lace. You should definitely post more 😏"
|
||||||
|
- "Holy shit those curves are incredible. Got any more pics?"
|
||||||
|
- "You're sexy as hell. Would love to see more of you"
|
||||||
|
|
||||||
|
Just write the message, nothing else.
|
||||||
|
|
||||||
|
rateLimit:
|
||||||
|
minDelayBetweenMessagesMs: 300000
|
||||||
|
maxMessagesPerHour: 8
|
||||||
|
jitterMs: 60000
|
||||||
|
|
||||||
|
browser:
|
||||||
|
sessionDir: "./data/browser-session"
|
||||||
|
headless: false
|
||||||
|
dryRun: true # Set to false to actually send messages
|
||||||
|
|
||||||
|
database:
|
||||||
|
path: "./data/reddit-bot.db"
|
||||||
|
|
||||||
|
web:
|
||||||
|
enabled: true
|
||||||
|
port: 3000
|
||||||
Generated
+2533
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "reddit-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Reddit bot for monitoring subreddits and sending AI-generated messages",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "tsx src/index.ts",
|
||||||
|
"login": "tsx scripts/login.ts",
|
||||||
|
"dev": "tsx watch src/index.ts"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"reddit",
|
||||||
|
"bot",
|
||||||
|
"ai"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"better-sqlite3": "^12.5.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"openai": "^6.15.0",
|
||||||
|
"p-queue": "^9.0.1",
|
||||||
|
"p-retry": "^7.1.1",
|
||||||
|
"playwright": "^1.57.0",
|
||||||
|
"winston": "^3.19.0",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
|
"zod": "^4.3.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/node": "^25.0.3",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Reddit Bot Dashboard</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #e4e4e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-running {
|
||||||
|
background: #2ecc71;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dry-run {
|
||||||
|
background: #f39c12;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .subtext {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.highlight {
|
||||||
|
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.highlight h3,
|
||||||
|
.stat-card.highlight .subtext {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.panels {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
padding: 0;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon.sent {
|
||||||
|
background: #2ecc71;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon.skipped {
|
||||||
|
background: #95a5a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon.response {
|
||||||
|
background: #9b59b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon.error {
|
||||||
|
background: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-message {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #ddd;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-recipient {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #bbb;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-badge {
|
||||||
|
background: #9b59b6;
|
||||||
|
color: #fff;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item h4 {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item .value {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(255, 107, 107, 0.2);
|
||||||
|
color: #ff6b6b;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Reddit Bot Dashboard</h1>
|
||||||
|
<div id="status-badges">
|
||||||
|
<span class="status-badge status-running" id="running-badge">Running</span>
|
||||||
|
<span class="status-badge status-dry-run" id="dryrun-badge" style="display: none;">Dry Run</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card highlight">
|
||||||
|
<h3>Messages Sent</h3>
|
||||||
|
<div class="value" id="stat-sent">0</div>
|
||||||
|
<div class="subtext"><span id="stat-hour">0</span> / <span id="stat-max-hour">8</span> this hour</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Responses</h3>
|
||||||
|
<div class="value" id="stat-responses">0</div>
|
||||||
|
<div class="subtext"><span id="stat-rate">0</span>% response rate</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Posts Processed</h3>
|
||||||
|
<div class="value" id="stat-processed">0</div>
|
||||||
|
<div class="subtext">Total analyzed</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Success Examples</h3>
|
||||||
|
<div class="value" id="stat-examples">0</div>
|
||||||
|
<div class="subtext">Learning from responses</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panels">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">Recent Activity</div>
|
||||||
|
<div class="panel-content" id="activity-list">
|
||||||
|
<div class="empty-state">No activity yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">Recent Messages</div>
|
||||||
|
<div class="panel-content" id="messages-list">
|
||||||
|
<div class="empty-state">No messages sent yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-grid">
|
||||||
|
<div class="config-item">
|
||||||
|
<h4>Monitoring Subreddits</h4>
|
||||||
|
<div class="value" id="config-subreddits"></div>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<h4>Target Genders</h4>
|
||||||
|
<div class="value" id="config-genders"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function formatTime(timestamp) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now - date;
|
||||||
|
|
||||||
|
if (diff < 60000) return 'Just now';
|
||||||
|
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
|
||||||
|
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityIcon(type) {
|
||||||
|
const icons = {
|
||||||
|
sent: '✉️',
|
||||||
|
skipped: '⏭️',
|
||||||
|
response: '💬',
|
||||||
|
error: '❌'
|
||||||
|
};
|
||||||
|
return icons[type] || '📌';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/stats');
|
||||||
|
const stats = await res.json();
|
||||||
|
|
||||||
|
document.getElementById('stat-sent').textContent = stats.totalMessagesSent;
|
||||||
|
document.getElementById('stat-hour').textContent = stats.messagesThisHour;
|
||||||
|
document.getElementById('stat-max-hour').textContent = stats.maxMessagesPerHour;
|
||||||
|
document.getElementById('stat-responses').textContent = stats.totalResponses;
|
||||||
|
document.getElementById('stat-rate').textContent = stats.responseRate;
|
||||||
|
document.getElementById('stat-processed').textContent = stats.totalProcessed;
|
||||||
|
|
||||||
|
document.getElementById('dryrun-badge').style.display = stats.dryRunMode ? 'inline' : 'none';
|
||||||
|
|
||||||
|
document.getElementById('config-subreddits').innerHTML = stats.subreddits
|
||||||
|
.map(s => `<span class="tag">r/${s}</span>`).join('');
|
||||||
|
document.getElementById('config-genders').innerHTML = stats.targetGenders
|
||||||
|
.map(g => `<span class="tag">${g}</span>`).join('');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch stats:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchActivity() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/activity');
|
||||||
|
const activity = await res.json();
|
||||||
|
|
||||||
|
const container = document.getElementById('activity-list');
|
||||||
|
|
||||||
|
if (activity.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No activity yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = activity.map(item => `
|
||||||
|
<div class="activity-item">
|
||||||
|
<div class="activity-icon ${item.type}">${getActivityIcon(item.type)}</div>
|
||||||
|
<div class="activity-content">
|
||||||
|
<div class="activity-message">${escapeHtml(item.message)}</div>
|
||||||
|
<div class="activity-time">${formatTime(item.timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch activity:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMessages() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/messages');
|
||||||
|
const messages = await res.json();
|
||||||
|
|
||||||
|
const container = document.getElementById('messages-list');
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No messages sent yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = messages.map(msg => `
|
||||||
|
<div class="message-item">
|
||||||
|
<div class="message-recipient">
|
||||||
|
u/${escapeHtml(msg.recipient)}
|
||||||
|
${msg.gotResponse ? '<span class="response-badge">Got Response!</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="message-text">${escapeHtml(msg.message)}</div>
|
||||||
|
<div class="message-meta">
|
||||||
|
<span>${formatTime(msg.sentAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch messages:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSuccessful() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/successful');
|
||||||
|
const successful = await res.json();
|
||||||
|
document.getElementById('stat-examples').textContent = successful.length;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch successful:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
fetchStats();
|
||||||
|
fetchActivity();
|
||||||
|
fetchMessages();
|
||||||
|
fetchSuccessful();
|
||||||
|
|
||||||
|
// Poll for updates
|
||||||
|
setInterval(() => {
|
||||||
|
fetchStats();
|
||||||
|
fetchActivity();
|
||||||
|
fetchMessages();
|
||||||
|
fetchSuccessful();
|
||||||
|
}, 5000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import readline from 'readline';
|
||||||
|
|
||||||
|
const SESSION_DIR = path.resolve(process.cwd(), './data/browser-session');
|
||||||
|
|
||||||
|
async function waitForEnter(prompt: string): Promise<void> {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(prompt, () => {
|
||||||
|
rl.close();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function manualLogin(): Promise<void> {
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log('Reddit Bot - Manual Login');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// Ensure session directory exists
|
||||||
|
if (!fs.existsSync(SESSION_DIR)) {
|
||||||
|
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Session will be saved to: ${SESSION_DIR}`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
const context = await chromium.launchPersistentContext(SESSION_DIR, {
|
||||||
|
headless: false,
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
args: ['--disable-blink-features=AutomationControlled'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
console.log('Opening Reddit login page...');
|
||||||
|
await page.goto('https://www.reddit.com/login/');
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log('-'.repeat(60));
|
||||||
|
console.log('Please log in to Reddit in the browser window.');
|
||||||
|
console.log();
|
||||||
|
console.log('After logging in successfully:');
|
||||||
|
console.log('1. Make sure you see your username in the top right');
|
||||||
|
console.log('2. Come back here and press Enter');
|
||||||
|
console.log('-'.repeat(60));
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
await waitForEnter('Press Enter when you have logged in... ');
|
||||||
|
|
||||||
|
// Verify login
|
||||||
|
console.log();
|
||||||
|
console.log('Verifying login status...');
|
||||||
|
|
||||||
|
await page.goto('https://www.reddit.com');
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// Check for login indicators
|
||||||
|
const userButton = await page.$('[data-testid="user-drawer-button"]');
|
||||||
|
const avatar = await page.$('[class*="avatar"], [class*="Avatar"]');
|
||||||
|
|
||||||
|
const isLoggedIn = (userButton && (await userButton.isVisible())) || avatar !== null;
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
if (isLoggedIn) {
|
||||||
|
console.log('SUCCESS! You are logged in.');
|
||||||
|
console.log(`Session saved to: ${SESSION_DIR}`);
|
||||||
|
console.log();
|
||||||
|
console.log('You can now run the bot with: npm start');
|
||||||
|
} else {
|
||||||
|
console.log('WARNING: Login could not be verified.');
|
||||||
|
console.log('The session has been saved, but you may need to try again.');
|
||||||
|
console.log();
|
||||||
|
console.log('Tips:');
|
||||||
|
console.log('- Make sure you completed the login process');
|
||||||
|
console.log('- Try clicking on your profile to ensure the page loaded');
|
||||||
|
console.log('- Run this script again if needed');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log('Closing browser...');
|
||||||
|
await context.close();
|
||||||
|
|
||||||
|
console.log('Done!');
|
||||||
|
}
|
||||||
|
|
||||||
|
manualLogin().catch((error) => {
|
||||||
|
console.error('Error during login:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import OpenAI from 'openai';
|
||||||
|
import type { Config } from '../config/index.js';
|
||||||
|
import type { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
export type DetectedGender = 'male' | 'female' | 'other' | 'unknown';
|
||||||
|
|
||||||
|
export interface ImageAnalysisResult {
|
||||||
|
description: string;
|
||||||
|
detectedGender: DetectedGender;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpenRouterClient {
|
||||||
|
private client: OpenAI;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: Config,
|
||||||
|
private logger: Logger
|
||||||
|
) {
|
||||||
|
this.client = new OpenAI({
|
||||||
|
baseURL: 'https://openrouter.ai/api/v1',
|
||||||
|
apiKey: config.openrouter.apiKey,
|
||||||
|
defaultHeaders: {
|
||||||
|
'HTTP-Referer': 'https://github.com/reddit-bot',
|
||||||
|
'X-Title': 'Reddit Bot',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectGenderOnly(imageBase64: string, mimeType: string): Promise<DetectedGender> {
|
||||||
|
try {
|
||||||
|
this.logger.debug('Quick gender detection...');
|
||||||
|
|
||||||
|
const response = await this.client.chat.completions.create({
|
||||||
|
model: this.config.openrouter.model,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'What is the gender of the person in this image? Reply with ONLY one word: male, female, other, or unknown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: {
|
||||||
|
url: `data:${mimeType};base64,${imageBase64}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
max_tokens: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = response.choices[0]?.message?.content?.toLowerCase().trim() || 'unknown';
|
||||||
|
|
||||||
|
if (result.includes('female')) return 'female';
|
||||||
|
if (result.includes('male') && !result.includes('female')) return 'male';
|
||||||
|
if (result.includes('other')) return 'other';
|
||||||
|
return 'unknown';
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Gender detection failed:', error);
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async analyzeImage(imageBase64: string, mimeType: string): Promise<ImageAnalysisResult> {
|
||||||
|
try {
|
||||||
|
this.logger.debug('Analyzing image with AI...');
|
||||||
|
|
||||||
|
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.model,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: genderPrompt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: {
|
||||||
|
url: `data:${mimeType};base64,${imageBase64}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
max_tokens: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateMessage(
|
||||||
|
imageAnalysis: string,
|
||||||
|
postTitle: string,
|
||||||
|
authorName: string,
|
||||||
|
successfulExamples: string[] = []
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
this.logger.debug('Generating message...');
|
||||||
|
|
||||||
|
let prompt = this.config.openrouter.messageGenerationPrompt
|
||||||
|
.replace('{analysis}', imageAnalysis)
|
||||||
|
.replace('{title}', postTitle)
|
||||||
|
.replace('{author}', authorName);
|
||||||
|
|
||||||
|
// Add successful examples if available
|
||||||
|
if (successfulExamples.length > 0) {
|
||||||
|
const examplesText = successfulExamples
|
||||||
|
.map((ex, i) => `Example ${i + 1}:\n${ex}`)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
prompt = `Here are some examples of messages that got positive responses:\n\n${examplesText}\n\n---\n\nNow generate a new message in a similar style:\n\n${prompt}`;
|
||||||
|
|
||||||
|
this.logger.debug(`Including ${successfulExamples.length} successful examples in prompt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.client.chat.completions.create({
|
||||||
|
model: this.config.openrouter.model,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
max_tokens: 300,
|
||||||
|
temperature: 0.8, // Add some creativity
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = response.choices[0]?.message?.content || '';
|
||||||
|
this.logger.debug(`Message generated: ${message}`);
|
||||||
|
return message.trim();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Message generation failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async analyzeAndGenerateMessage(
|
||||||
|
imageBase64: string,
|
||||||
|
mimeType: string,
|
||||||
|
postTitle: string,
|
||||||
|
authorName: string,
|
||||||
|
successfulExamples: string[] = []
|
||||||
|
): Promise<{ analysis: ImageAnalysisResult; message: string }> {
|
||||||
|
const analysis = await this.analyzeImage(imageBase64, mimeType);
|
||||||
|
const message = await this.generateMessage(analysis.description, postTitle, authorName, successfulExamples);
|
||||||
|
return { analysis, message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { chromium, BrowserContext, Page } from 'playwright';
|
||||||
|
import type { Config } from '../config/index.js';
|
||||||
|
import type { Logger } from '../utils/logger.js';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
export class BrowserClient {
|
||||||
|
private context: BrowserContext | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: Config,
|
||||||
|
private logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async initialize(): Promise<BrowserContext> {
|
||||||
|
const sessionDir = path.resolve(process.cwd(), this.config.browser.sessionDir);
|
||||||
|
|
||||||
|
// Ensure session directory exists
|
||||||
|
if (!fs.existsSync(sessionDir)) {
|
||||||
|
fs.mkdirSync(sessionDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(`Launching browser with session dir: ${sessionDir}`);
|
||||||
|
|
||||||
|
// Use launchPersistentContext to maintain cookies/localStorage
|
||||||
|
this.context = await chromium.launchPersistentContext(sessionDir, {
|
||||||
|
headless: this.config.browser.headless,
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
userAgent: this.config.reddit.userAgent,
|
||||||
|
args: [
|
||||||
|
'--disable-blink-features=AutomationControlled',
|
||||||
|
'--no-sandbox',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.context;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPage(): Promise<Page> {
|
||||||
|
if (!this.context) {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
const pages = this.context!.pages();
|
||||||
|
return pages.length > 0 ? pages[0] : await this.context!.newPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
getContext(): BrowserContext | null {
|
||||||
|
return this.context;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.context) {
|
||||||
|
this.logger.info('Closing browser');
|
||||||
|
await this.context.close();
|
||||||
|
this.context = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isSessionValid(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const page = await this.getPage();
|
||||||
|
await page.goto('https://www.reddit.com', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
|
||||||
|
// Check for various login indicators
|
||||||
|
const isLoggedIn = await page.evaluate(() => {
|
||||||
|
// Check for user menu button
|
||||||
|
const userButton = document.querySelector('[data-testid="user-drawer-button"]');
|
||||||
|
if (userButton) return true;
|
||||||
|
|
||||||
|
// Check for login/signup buttons which indicate NOT logged in
|
||||||
|
const loginButton = document.querySelector('a[href*="login"]');
|
||||||
|
const signupButton = document.querySelector('a[href*="register"]');
|
||||||
|
|
||||||
|
// If we find login/signup prominently, we're not logged in
|
||||||
|
// But we need to be careful - some elements may exist in DOM
|
||||||
|
return !loginButton && !signupButton;
|
||||||
|
});
|
||||||
|
|
||||||
|
return isLoggedIn;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to check session validity:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import type { Page } from 'playwright';
|
||||||
|
import type { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
export class RedditMessagePage {
|
||||||
|
private dryRun: boolean = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private page: Page,
|
||||||
|
private logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
setDryRun(enabled: boolean): void {
|
||||||
|
this.dryRun = enabled;
|
||||||
|
if (enabled) {
|
||||||
|
this.logger.info('Dry run mode enabled - messages will be filled but not sent');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(recipient: string, subject: string, body: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
this.logger.info(`Sending message to u/${recipient}`);
|
||||||
|
|
||||||
|
// Navigate to compose page
|
||||||
|
await this.page.goto('https://www.reddit.com/message/compose/', {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the form to load - try multiple possible selectors
|
||||||
|
await this.page.waitForSelector('input[name="to"], #to, [data-testid="message-to"]', {
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for React to hydrate
|
||||||
|
await this.page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Fill recipient field
|
||||||
|
const toInput = await this.page.$('input[name="to"]') || await this.page.$('#to');
|
||||||
|
if (toInput) {
|
||||||
|
await toInput.click();
|
||||||
|
await toInput.fill(recipient);
|
||||||
|
} else {
|
||||||
|
throw new Error('Could not find recipient input field');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill subject field
|
||||||
|
const subjectInput = await this.page.$('input[name="subject"]') || await this.page.$('#subject');
|
||||||
|
if (subjectInput) {
|
||||||
|
await subjectInput.click();
|
||||||
|
await subjectInput.fill(subject);
|
||||||
|
} else {
|
||||||
|
throw new Error('Could not find subject input field');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill message body - Reddit uses various editor implementations
|
||||||
|
// Try contenteditable div first (new Reddit)
|
||||||
|
let bodyFilled = false;
|
||||||
|
|
||||||
|
// Try markdown textarea
|
||||||
|
const textarea = await this.page.$('textarea[name="text"], textarea#text, textarea.md');
|
||||||
|
if (textarea) {
|
||||||
|
await textarea.click();
|
||||||
|
await this.typeWithDelay(body);
|
||||||
|
bodyFilled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try contenteditable div (fancy editor)
|
||||||
|
if (!bodyFilled) {
|
||||||
|
const contentEditable = await this.page.$('[contenteditable="true"]');
|
||||||
|
if (contentEditable) {
|
||||||
|
await contentEditable.click();
|
||||||
|
await this.typeWithDelay(body);
|
||||||
|
bodyFilled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try data-testid selector
|
||||||
|
if (!bodyFilled) {
|
||||||
|
const markdownInput = await this.page.$('[data-testid="markdown-input"]');
|
||||||
|
if (markdownInput) {
|
||||||
|
await markdownInput.click();
|
||||||
|
await this.typeWithDelay(body);
|
||||||
|
bodyFilled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bodyFilled) {
|
||||||
|
throw new Error('Could not find message body input');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a bit before submitting
|
||||||
|
await this.page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Dry run mode - don't actually send
|
||||||
|
if (this.dryRun) {
|
||||||
|
this.logger.info(`[DRY RUN] Message filled out for u/${recipient} - NOT sending`);
|
||||||
|
this.logger.info(`[DRY RUN] Subject: ${subject}`);
|
||||||
|
this.logger.info(`[DRY RUN] Body: ${body.slice(0, 100)}...`);
|
||||||
|
this.logger.info(`[DRY RUN] Pausing 10 seconds so you can review the form...`);
|
||||||
|
await this.page.waitForTimeout(10000);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and click submit button
|
||||||
|
const submitButton =
|
||||||
|
(await this.page.$('button[type="submit"]')) ||
|
||||||
|
(await this.page.$('button:has-text("Send")')) ||
|
||||||
|
(await this.page.$('input[type="submit"]'));
|
||||||
|
|
||||||
|
if (!submitButton) {
|
||||||
|
throw new Error('Could not find submit button');
|
||||||
|
}
|
||||||
|
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
// Wait for success indication
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
this.page.waitForURL(/\/message\/sent/, { timeout: 15000 }),
|
||||||
|
this.page.waitForSelector('.success, [class*="success"], [class*="Success"]', { timeout: 15000 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.logger.info(`Message sent successfully to u/${recipient}`);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// Check if we're on a success page or got any error
|
||||||
|
const currentUrl = this.page.url();
|
||||||
|
if (currentUrl.includes('/message/sent') || currentUrl.includes('success')) {
|
||||||
|
this.logger.info(`Message sent successfully to u/${recipient}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for error messages
|
||||||
|
const errorElement = await this.page.$('.error, [class*="error"], [class*="Error"]');
|
||||||
|
if (errorElement) {
|
||||||
|
const errorText = await errorElement.textContent();
|
||||||
|
throw new Error(`Reddit error: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Message send status unclear');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to send message to u/${recipient}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async typeWithDelay(text: string): Promise<void> {
|
||||||
|
// Type with human-like delays
|
||||||
|
for (const char of text) {
|
||||||
|
await this.page.keyboard.type(char, { delay: 20 + Math.random() * 30 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isLoggedIn(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.page.goto('https://www.reddit.com', {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for page to stabilize
|
||||||
|
await this.page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Check for user menu button (indicates logged in)
|
||||||
|
const userButton = await this.page.$('[data-testid="user-drawer-button"]');
|
||||||
|
if (userButton) {
|
||||||
|
const isVisible = await userButton.isVisible();
|
||||||
|
return isVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative check - look for avatar or user icon
|
||||||
|
const avatar = await this.page.$('[class*="avatar"], [class*="Avatar"]');
|
||||||
|
if (avatar) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to check login status:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import YAML from 'yaml';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export const ConfigSchema = z.object({
|
||||||
|
reddit: z.object({
|
||||||
|
subreddits: z.array(z.string()).min(1),
|
||||||
|
pollIntervalMs: z.number().default(120000),
|
||||||
|
userAgent: z.string().default('RedditBot/1.0'),
|
||||||
|
targetGenders: z.array(z.enum(['male', 'female', 'other', 'unknown'])).default(['female']),
|
||||||
|
}),
|
||||||
|
openrouter: z.object({
|
||||||
|
apiKey: z.string(),
|
||||||
|
model: z.string().default('google/gemini-3-flash-preview'),
|
||||||
|
imageAnalysisPrompt: z.string(),
|
||||||
|
messageGenerationPrompt: z.string(),
|
||||||
|
}),
|
||||||
|
rateLimit: z.object({
|
||||||
|
minDelayBetweenMessagesMs: z.number().default(300000),
|
||||||
|
maxMessagesPerHour: z.number().default(8),
|
||||||
|
jitterMs: z.number().default(60000),
|
||||||
|
}),
|
||||||
|
browser: z.object({
|
||||||
|
sessionDir: z.string().default('./data/browser-session'),
|
||||||
|
headless: z.boolean().default(false),
|
||||||
|
dryRun: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
database: z.object({
|
||||||
|
path: z.string().default('./data/reddit-bot.db'),
|
||||||
|
}),
|
||||||
|
web: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
port: z.number().default(3000),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
export function loadConfig(): Config {
|
||||||
|
const configPath = path.resolve(process.cwd(), 'config.yaml');
|
||||||
|
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
throw new Error(`Config file not found at ${configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configFile = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
const rawConfig = YAML.parse(configFile);
|
||||||
|
|
||||||
|
// Inject API key from environment
|
||||||
|
if (!rawConfig.openrouter) {
|
||||||
|
rawConfig.openrouter = {};
|
||||||
|
}
|
||||||
|
rawConfig.openrouter.apiKey = process.env.OPENROUTER_API_KEY || rawConfig.openrouter.apiKey;
|
||||||
|
|
||||||
|
if (!rawConfig.openrouter.apiKey) {
|
||||||
|
throw new Error('OPENROUTER_API_KEY environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = ConfigSchema.safeParse(rawConfig);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const errors = result.error.issues
|
||||||
|
.map((e) => ` - ${String(e.path.join('.'))}: ${e.message}`)
|
||||||
|
.join('\n');
|
||||||
|
throw new Error(`Invalid configuration:\n${errors}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import type { Config } from '../config/index.js';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
export function createDatabase(config: Config): Database.Database {
|
||||||
|
const dbPath = path.resolve(process.cwd(), config.database.path);
|
||||||
|
const dbDir = path.dirname(dbPath);
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
// Enable WAL mode for better concurrency
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runMigrations(db: Database.Database): void {
|
||||||
|
// Create tables if they don't exist
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS processed_posts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
subreddit TEXT NOT NULL,
|
||||||
|
author TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
image_url TEXT,
|
||||||
|
image_analysis TEXT,
|
||||||
|
generated_message TEXT,
|
||||||
|
processed_at INTEGER NOT NULL,
|
||||||
|
message_sent INTEGER DEFAULT 0,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sent_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
post_id TEXT NOT NULL,
|
||||||
|
recipient TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
sent_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (post_id) REFERENCES processed_posts(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS successful_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sent_message_id INTEGER NOT NULL,
|
||||||
|
original_message TEXT NOT NULL,
|
||||||
|
image_analysis TEXT,
|
||||||
|
post_title TEXT,
|
||||||
|
response_snippet TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (sent_message_id) REFERENCES sent_messages(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_subreddit
|
||||||
|
ON processed_posts(subreddit);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_processed_at
|
||||||
|
ON processed_posts(processed_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_message_sent
|
||||||
|
ON processed_posts(message_sent);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add new columns to existing tables (safe to run multiple times)
|
||||||
|
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
|
||||||
|
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
|
||||||
|
const exists = columns.some((c) => c.name === column);
|
||||||
|
if (!exists) {
|
||||||
|
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addColumnIfNotExists('sent_messages', 'got_response', 'INTEGER DEFAULT 0');
|
||||||
|
addColumnIfNotExists('sent_messages', 'response_checked_at', 'INTEGER');
|
||||||
|
|
||||||
|
// Create index for response checking
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sent_messages_response ON sent_messages(got_response)`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
export interface ProcessedPostRecord {
|
||||||
|
id: string;
|
||||||
|
subreddit: string;
|
||||||
|
author: string;
|
||||||
|
title: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
imageAnalysis: string | null;
|
||||||
|
generatedMessage: string | null;
|
||||||
|
processedAt: number;
|
||||||
|
messageSent: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SentMessageRecord {
|
||||||
|
id: number;
|
||||||
|
postId: string;
|
||||||
|
recipient: string;
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
sentAt: number;
|
||||||
|
gotResponse: boolean;
|
||||||
|
responseCheckedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuccessfulMessageRecord {
|
||||||
|
id: number;
|
||||||
|
sentMessageId: number;
|
||||||
|
originalMessage: string;
|
||||||
|
imageAnalysis: string | null;
|
||||||
|
postTitle: string | null;
|
||||||
|
responseSnippet: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PostsRepository {
|
||||||
|
private insertStmt: Database.Statement;
|
||||||
|
private existsStmt: Database.Statement;
|
||||||
|
private markSentStmt: Database.Statement;
|
||||||
|
private updateAnalysisStmt: Database.Statement;
|
||||||
|
private setErrorStmt: Database.Statement;
|
||||||
|
private getByIdStmt: Database.Statement;
|
||||||
|
|
||||||
|
constructor(private db: Database.Database) {
|
||||||
|
this.insertStmt = db.prepare(`
|
||||||
|
INSERT INTO processed_posts (id, subreddit, author, title, image_url, processed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.existsStmt = db.prepare(`SELECT 1 FROM processed_posts WHERE id = ?`);
|
||||||
|
|
||||||
|
this.markSentStmt = db.prepare(`UPDATE processed_posts SET message_sent = 1 WHERE id = ?`);
|
||||||
|
|
||||||
|
this.updateAnalysisStmt = db.prepare(`
|
||||||
|
UPDATE processed_posts
|
||||||
|
SET image_analysis = ?, generated_message = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.setErrorStmt = db.prepare(`UPDATE processed_posts SET error = ? WHERE id = ?`);
|
||||||
|
|
||||||
|
this.getByIdStmt = db.prepare(`SELECT * FROM processed_posts WHERE id = ?`);
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessed(postId: string): boolean {
|
||||||
|
return this.existsStmt.get(postId) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
insert(post: {
|
||||||
|
id: string;
|
||||||
|
subreddit: string;
|
||||||
|
author: string;
|
||||||
|
title: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
processedAt: number;
|
||||||
|
}): void {
|
||||||
|
this.insertStmt.run(
|
||||||
|
post.id,
|
||||||
|
post.subreddit,
|
||||||
|
post.author,
|
||||||
|
post.title,
|
||||||
|
post.imageUrl,
|
||||||
|
post.processedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnalysis(postId: string, analysis: string, message: string): void {
|
||||||
|
this.updateAnalysisStmt.run(analysis, message, postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
markMessageSent(postId: string): void {
|
||||||
|
this.markSentStmt.run(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(postId: string, error: string): void {
|
||||||
|
this.setErrorStmt.run(error, postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(postId: string): ProcessedPostRecord | undefined {
|
||||||
|
const row = this.getByIdStmt.get(postId) as Record<string, unknown> | undefined;
|
||||||
|
if (!row) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
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 {
|
||||||
|
private insertStmt: Database.Statement;
|
||||||
|
private getByPostIdStmt: Database.Statement;
|
||||||
|
private getRecentStmt: Database.Statement;
|
||||||
|
private getUncheckedStmt: Database.Statement;
|
||||||
|
private markResponseStmt: Database.Statement;
|
||||||
|
private markCheckedStmt: Database.Statement;
|
||||||
|
|
||||||
|
constructor(private db: Database.Database) {
|
||||||
|
this.insertStmt = db.prepare(`
|
||||||
|
INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.getByPostIdStmt = db.prepare(`SELECT * FROM sent_messages WHERE post_id = ?`);
|
||||||
|
|
||||||
|
this.getRecentStmt = db.prepare(`
|
||||||
|
SELECT * FROM sent_messages ORDER BY sent_at DESC LIMIT ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.getUncheckedStmt = db.prepare(`
|
||||||
|
SELECT * FROM sent_messages
|
||||||
|
WHERE got_response = 0
|
||||||
|
AND (response_checked_at IS NULL OR response_checked_at < ?)
|
||||||
|
AND sent_at < ?
|
||||||
|
ORDER BY sent_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.markResponseStmt = db.prepare(`
|
||||||
|
UPDATE sent_messages SET got_response = 1, response_checked_at = ? WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.markCheckedStmt = db.prepare(`
|
||||||
|
UPDATE sent_messages SET response_checked_at = ? WHERE id = ?
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
insert(message: {
|
||||||
|
postId: string;
|
||||||
|
recipient: string;
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
sentAt: number;
|
||||||
|
}): number {
|
||||||
|
const result = this.insertStmt.run(
|
||||||
|
message.postId,
|
||||||
|
message.recipient,
|
||||||
|
message.subject,
|
||||||
|
message.message,
|
||||||
|
message.sentAt
|
||||||
|
);
|
||||||
|
return result.lastInsertRowid as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
getByPostId(postId: string): SentMessageRecord | undefined {
|
||||||
|
const row = this.getByPostIdStmt.get(postId) as Record<string, unknown> | undefined;
|
||||||
|
if (!row) return undefined;
|
||||||
|
return this.mapRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecent(limit: number = 10): SentMessageRecord[] {
|
||||||
|
const rows = this.getRecentStmt.all(limit) as Record<string, unknown>[];
|
||||||
|
return rows.map((row) => this.mapRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
getUncheckedMessages(minAgeMs: number, recheckAfterMs: number, limit: number = 10): SentMessageRecord[] {
|
||||||
|
const now = Date.now();
|
||||||
|
const minSentAt = now - minAgeMs;
|
||||||
|
const recheckBefore = now - recheckAfterMs;
|
||||||
|
const rows = this.getUncheckedStmt.all(recheckBefore, minSentAt, limit) as Record<string, unknown>[];
|
||||||
|
return rows.map((row) => this.mapRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
markGotResponse(messageId: number): void {
|
||||||
|
this.markResponseStmt.run(Date.now(), messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
markChecked(messageId: number): void {
|
||||||
|
this.markCheckedStmt.run(Date.now(), messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRow(row: Record<string, unknown>): SentMessageRecord {
|
||||||
|
return {
|
||||||
|
id: row.id as number,
|
||||||
|
postId: row.post_id as string,
|
||||||
|
recipient: row.recipient as string,
|
||||||
|
subject: row.subject as string,
|
||||||
|
message: row.message as string,
|
||||||
|
sentAt: row.sent_at as number,
|
||||||
|
gotResponse: Boolean(row.got_response),
|
||||||
|
responseCheckedAt: row.response_checked_at as number | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SuccessfulMessagesRepository {
|
||||||
|
private insertStmt: Database.Statement;
|
||||||
|
private getRecentStmt: Database.Statement;
|
||||||
|
private countStmt: 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 (?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.getRecentStmt = db.prepare(`
|
||||||
|
SELECT * FROM successful_messages ORDER BY created_at DESC LIMIT ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.countStmt = db.prepare(`SELECT COUNT(*) as count FROM successful_messages`);
|
||||||
|
}
|
||||||
|
|
||||||
|
insert(data: {
|
||||||
|
sentMessageId: number;
|
||||||
|
originalMessage: string;
|
||||||
|
imageAnalysis: string | null;
|
||||||
|
postTitle: string | null;
|
||||||
|
responseSnippet: string | null;
|
||||||
|
}): void {
|
||||||
|
this.insertStmt.run(
|
||||||
|
data.sentMessageId,
|
||||||
|
data.originalMessage,
|
||||||
|
data.imageAnalysis,
|
||||||
|
data.postTitle,
|
||||||
|
data.responseSnippet,
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecent(limit: number = 5): SuccessfulMessageRecord[] {
|
||||||
|
const rows = this.getRecentStmt.all(limit) as Record<string, unknown>[];
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
count(): number {
|
||||||
|
const result = this.countStmt.get() as { count: number };
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
import { loadConfig } from './config/index.js';
|
||||||
|
import { createDatabase, runMigrations } from './database/client.js';
|
||||||
|
import { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from './database/repositories.js';
|
||||||
|
import { BrowserClient } from './browser/client.js';
|
||||||
|
import { RedditMessagePage } from './browser/message-page.js';
|
||||||
|
import { RedditApiClient } from './reddit/api.js';
|
||||||
|
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 { RateLimiter } from './utils/rate-limiter.js';
|
||||||
|
import { createLogger } from './utils/logger.js';
|
||||||
|
import { withRetry } from './utils/retry.js';
|
||||||
|
import { WebServer } from './web/server.js';
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
logger.info('='.repeat(60));
|
||||||
|
logger.info('Reddit Bot Starting');
|
||||||
|
logger.info('='.repeat(60));
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
let config;
|
||||||
|
try {
|
||||||
|
config = loadConfig();
|
||||||
|
logger.info(`Loaded configuration for ${config.reddit.subreddits.length} subreddits`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load configuration:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
const db = createDatabase(config);
|
||||||
|
runMigrations(db);
|
||||||
|
const postsRepo = new PostsRepository(db);
|
||||||
|
const messagesRepo = new MessagesRepository(db);
|
||||||
|
const successfulMessagesRepo = new SuccessfulMessagesRepository(db);
|
||||||
|
logger.info('Database initialized');
|
||||||
|
|
||||||
|
// Initialize browser
|
||||||
|
const browserClient = new BrowserClient(config, logger);
|
||||||
|
await browserClient.initialize();
|
||||||
|
const page = await browserClient.getPage();
|
||||||
|
const messagePage = new RedditMessagePage(page, logger);
|
||||||
|
|
||||||
|
// Set dry run mode if configured
|
||||||
|
messagePage.setDryRun(config.browser.dryRun);
|
||||||
|
|
||||||
|
// Verify login
|
||||||
|
logger.info('Verifying Reddit login status...');
|
||||||
|
const isLoggedIn = await messagePage.isLoggedIn();
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
logger.error('Not logged in to Reddit!');
|
||||||
|
logger.error('Please run "npm run login" first to authenticate.');
|
||||||
|
await browserClient.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Reddit login verified');
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
const redditApi = new RedditApiClient(config, logger);
|
||||||
|
redditApi.setBrowserContext(browserClient.getContext()!);
|
||||||
|
const aiClient = new OpenRouterClient(config, logger);
|
||||||
|
const monitorService = new MonitorService(redditApi, postsRepo, config, logger);
|
||||||
|
const processorService = new ProcessorService(redditApi, aiClient, config, logger);
|
||||||
|
const responseChecker = new ResponseCheckerService(
|
||||||
|
page,
|
||||||
|
messagesRepo,
|
||||||
|
successfulMessagesRepo,
|
||||||
|
postsRepo,
|
||||||
|
logger
|
||||||
|
);
|
||||||
|
const rateLimiter = new RateLimiter(config, logger);
|
||||||
|
|
||||||
|
// Initialize web dashboard
|
||||||
|
let webServer: WebServer | null = null;
|
||||||
|
if (config.web.enabled) {
|
||||||
|
webServer = new WebServer(
|
||||||
|
config.web.port,
|
||||||
|
config,
|
||||||
|
postsRepo,
|
||||||
|
messagesRepo,
|
||||||
|
successfulMessagesRepo,
|
||||||
|
logger
|
||||||
|
);
|
||||||
|
webServer.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load any existing successful examples
|
||||||
|
const existingExamples = responseChecker.getSuccessfulExamples(3);
|
||||||
|
processorService.setSuccessfulExamples(existingExamples);
|
||||||
|
const successCount = responseChecker.getSuccessCount();
|
||||||
|
if (successCount > 0) {
|
||||||
|
logger.info(`Loaded ${successCount} successful message(s) from history`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown handler
|
||||||
|
let isShuttingDown = false;
|
||||||
|
|
||||||
|
const shutdown = async (): Promise<void> => {
|
||||||
|
if (isShuttingDown) return;
|
||||||
|
isShuttingDown = true;
|
||||||
|
|
||||||
|
logger.info('Shutting down gracefully...');
|
||||||
|
webServer?.stop();
|
||||||
|
await browserClient.close();
|
||||||
|
db.close();
|
||||||
|
logger.info('Goodbye!');
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
|
||||||
|
logger.info('');
|
||||||
|
logger.info('Bot is now running!');
|
||||||
|
logger.info(`Monitoring subreddits: ${config.reddit.subreddits.join(', ')}`);
|
||||||
|
logger.info(`Poll interval: ${config.reddit.pollIntervalMs / 1000} seconds`);
|
||||||
|
logger.info(`Min delay between messages: ${config.rateLimit.minDelayBetweenMessagesMs / 1000} seconds`);
|
||||||
|
logger.info(`Max messages per hour: ${config.rateLimit.maxMessagesPerHour}`);
|
||||||
|
logger.info('');
|
||||||
|
|
||||||
|
// Track poll cycles for periodic response checking
|
||||||
|
let pollCycle = 0;
|
||||||
|
const RESPONSE_CHECK_INTERVAL = 5; // Check for responses every 5 poll cycles
|
||||||
|
|
||||||
|
// Main loop
|
||||||
|
while (!isShuttingDown) {
|
||||||
|
try {
|
||||||
|
pollCycle++;
|
||||||
|
|
||||||
|
// Periodically check for responses to learn from
|
||||||
|
if (pollCycle % RESPONSE_CHECK_INTERVAL === 0) {
|
||||||
|
logger.debug('Checking for responses to sent messages...');
|
||||||
|
const responses = await responseChecker.checkForResponses();
|
||||||
|
|
||||||
|
if (responses.some((r) => r.gotResponse)) {
|
||||||
|
// Reload successful examples
|
||||||
|
const updatedExamples = responseChecker.getSuccessfulExamples(3);
|
||||||
|
processorService.setSuccessfulExamples(updatedExamples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unprocessed posts
|
||||||
|
const posts = await monitorService.getUnprocessedPosts();
|
||||||
|
|
||||||
|
if (posts.length > 0) {
|
||||||
|
logger.info(`Found ${posts.length} unprocessed posts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
if (isShuttingDown) break;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Mark as processed immediately to avoid duplicates on restart
|
||||||
|
postsRepo.insert({
|
||||||
|
id: post.id,
|
||||||
|
subreddit: post.subreddit,
|
||||||
|
author: post.author,
|
||||||
|
title: post.title,
|
||||||
|
imageUrl: redditApi.getImageUrl(post),
|
||||||
|
processedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process post (download image, analyze, generate message)
|
||||||
|
const processed = await withRetry(
|
||||||
|
() => processorService.processPost(post),
|
||||||
|
{ logger, retries: 2, operationName: 'Post processing' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!processed) {
|
||||||
|
logger.debug(`Skipping post ${post.id} - processing returned null`);
|
||||||
|
webServer?.logActivity('skipped', `Skipped post by u/${post.author}`, post.author);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update database with analysis
|
||||||
|
postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage);
|
||||||
|
|
||||||
|
// Create subject line
|
||||||
|
const subject = `Re: ${post.title.slice(0, 50)}${post.title.length > 50 ? '...' : ''}`;
|
||||||
|
|
||||||
|
// Send message with rate limiting
|
||||||
|
await rateLimiter.throttle(async () => {
|
||||||
|
logger.info(`Sending message to u/${post.author}...`);
|
||||||
|
|
||||||
|
const success = await messagePage.sendMessage(
|
||||||
|
post.author,
|
||||||
|
subject,
|
||||||
|
processed.generatedMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
postsRepo.markMessageSent(post.id);
|
||||||
|
messagesRepo.insert({
|
||||||
|
postId: post.id,
|
||||||
|
recipient: post.author,
|
||||||
|
subject,
|
||||||
|
message: processed.generatedMessage,
|
||||||
|
sentAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = rateLimiter.getStats();
|
||||||
|
logger.info(
|
||||||
|
`Message sent! (${stats.messagesThisHour}/${config.rateLimit.maxMessagesPerHour} this hour)`
|
||||||
|
);
|
||||||
|
webServer?.logActivity('sent', `Sent message to u/${post.author}`, post.author);
|
||||||
|
webServer?.updateStats(stats.messagesThisHour);
|
||||||
|
} else {
|
||||||
|
postsRepo.setError(post.id, 'Failed to send message');
|
||||||
|
webServer?.logActivity('error', `Failed to send to u/${post.author}`, post.author);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(`Error processing post ${post.id}:`, error);
|
||||||
|
postsRepo.setError(post.id, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isShuttingDown) {
|
||||||
|
// Wait before next poll
|
||||||
|
logger.debug(`Waiting ${config.reddit.pollIntervalMs / 1000} seconds before next poll...`);
|
||||||
|
await sleep(config.reddit.pollIntervalMs);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in main loop:', error);
|
||||||
|
// Wait a minute on error before retrying
|
||||||
|
await sleep(60000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('Fatal error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import type { BrowserContext } from 'playwright';
|
||||||
|
import type { Config } from '../config/index.js';
|
||||||
|
import type { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
export interface RedditPost {
|
||||||
|
id: string;
|
||||||
|
name: string; // fullname like "t3_xxxxx"
|
||||||
|
subreddit: string;
|
||||||
|
author: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
permalink: string;
|
||||||
|
thumbnail: string;
|
||||||
|
is_self: boolean;
|
||||||
|
post_hint?: string;
|
||||||
|
preview?: {
|
||||||
|
images: Array<{
|
||||||
|
source: { url: string; width: number; height: number };
|
||||||
|
resolutions: Array<{ url: string; width: number; height: number }>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
created_utc: number;
|
||||||
|
over_18: boolean;
|
||||||
|
spoiler: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RedditApiClient {
|
||||||
|
private client: AxiosInstance;
|
||||||
|
private browserContext: BrowserContext | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
config: Config,
|
||||||
|
private logger: Logger
|
||||||
|
) {
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL: 'https://www.reddit.com',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': config.reddit.userAgent,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setBrowserContext(context: BrowserContext): void {
|
||||||
|
this.browserContext = context;
|
||||||
|
this.logger.debug('Browser context set for authenticated API requests');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCookieHeader(): Promise<string> {
|
||||||
|
if (!this.browserContext) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cookies = await this.browserContext.cookies('https://www.reddit.com');
|
||||||
|
return cookies.map((c) => `${c.name}=${c.value}`).join('; ');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn('Failed to get cookies from browser context:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNewPosts(subreddit: string, limit = 25): Promise<RedditPost[]> {
|
||||||
|
try {
|
||||||
|
const cookieHeader = await this.getCookieHeader();
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (cookieHeader) {
|
||||||
|
headers['Cookie'] = cookieHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.client.get(`/r/${subreddit}/new.json`, {
|
||||||
|
params: { limit, raw_json: 1 },
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const posts: RedditPost[] = response.data.data.children
|
||||||
|
.map((child: { data: RedditPost }) => child.data)
|
||||||
|
.filter((post: RedditPost) => this.isValidPost(post));
|
||||||
|
|
||||||
|
this.logger.debug(`Fetched ${posts.length} valid image posts from r/${subreddit}`);
|
||||||
|
return posts;
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
this.logger.warn(`Subreddit r/${subreddit} not found`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
this.logger.warn(`Subreddit r/${subreddit} is private or quarantined`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
this.logger.warn(`Rate limited by Reddit, waiting...`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.error(`Error fetching posts from r/${subreddit}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isValidPost(post: RedditPost): boolean {
|
||||||
|
// Skip self posts (text only)
|
||||||
|
if (post.is_self) return false;
|
||||||
|
|
||||||
|
// Skip deleted/removed authors
|
||||||
|
if (post.author === '[deleted]' || post.author === 'AutoModerator') return false;
|
||||||
|
|
||||||
|
// Skip NSFW if we want to be safe (configurable later)
|
||||||
|
// if (post.over_18) return false;
|
||||||
|
|
||||||
|
// Check if it's an image post
|
||||||
|
return this.hasImage(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasImage(post: RedditPost): boolean {
|
||||||
|
// Check post_hint
|
||||||
|
if (post.post_hint === 'image') return true;
|
||||||
|
|
||||||
|
// Check for preview images
|
||||||
|
if (post.preview?.images?.length) return true;
|
||||||
|
|
||||||
|
// Check URL patterns for direct image links
|
||||||
|
const url = post.url.toLowerCase();
|
||||||
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||||
|
|
||||||
|
if (imageExtensions.some((ext) => url.includes(ext))) return true;
|
||||||
|
|
||||||
|
// Check for known image hosts
|
||||||
|
const imageHosts = ['i.redd.it', 'i.imgur.com', 'imgur.com'];
|
||||||
|
if (imageHosts.some((host) => url.includes(host))) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getImageUrl(post: RedditPost): string | null {
|
||||||
|
// Prefer high-quality preview images (already processed by Reddit)
|
||||||
|
if (post.preview?.images?.[0]?.source?.url) {
|
||||||
|
// Reddit encodes URLs in preview, need to decode HTML entities
|
||||||
|
return this.decodeRedditUrl(post.preview.images[0].source.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct i.redd.it links
|
||||||
|
if (post.url.includes('i.redd.it')) {
|
||||||
|
return post.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct imgur links
|
||||||
|
if (post.url.includes('i.imgur.com')) {
|
||||||
|
return post.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imgur album/page links - try to get direct image
|
||||||
|
if (post.url.includes('imgur.com') && !post.url.includes('i.imgur.com')) {
|
||||||
|
// Try adding .jpg extension
|
||||||
|
const imgurId = post.url.split('/').pop()?.split('.')[0];
|
||||||
|
if (imgurId) {
|
||||||
|
return `https://i.imgur.com/${imgurId}.jpg`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if URL has image extension
|
||||||
|
const url = post.url.toLowerCase();
|
||||||
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||||
|
if (imageExtensions.some((ext) => url.includes(ext))) {
|
||||||
|
return post.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use thumbnail as last resort if it's a valid URL
|
||||||
|
if (post.thumbnail && post.thumbnail.startsWith('http')) {
|
||||||
|
return post.thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeRedditUrl(url: string): string {
|
||||||
|
return url.replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
getPostUrl(post: RedditPost): string {
|
||||||
|
return `https://www.reddit.com${post.permalink}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { RedditApiClient, RedditPost } from '../reddit/api.js';
|
||||||
|
import type { PostsRepository } from '../database/repositories.js';
|
||||||
|
import type { Config } from '../config/index.js';
|
||||||
|
import type { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
export class MonitorService {
|
||||||
|
constructor(
|
||||||
|
private redditApi: RedditApiClient,
|
||||||
|
private postsRepo: PostsRepository,
|
||||||
|
private config: Config,
|
||||||
|
private logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getUnprocessedPosts(): Promise<RedditPost[]> {
|
||||||
|
const unprocessedPosts: RedditPost[] = [];
|
||||||
|
|
||||||
|
for (const subreddit of this.config.reddit.subreddits) {
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Checking r/${subreddit} for new posts...`);
|
||||||
|
const posts = await this.redditApi.getNewPosts(subreddit);
|
||||||
|
|
||||||
|
let newCount = 0;
|
||||||
|
for (const post of posts) {
|
||||||
|
if (!this.postsRepo.isProcessed(post.id)) {
|
||||||
|
unprocessedPosts.push(post);
|
||||||
|
newCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newCount > 0) {
|
||||||
|
this.logger.info(`Found ${newCount} new posts in r/${subreddit}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to fetch posts from r/${subreddit}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay between subreddit fetches to be nice to Reddit
|
||||||
|
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 unprocessedPosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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 { downloadImage, bufferToBase64, isValidImageMimeType } from '../utils/image.js';
|
||||||
|
|
||||||
|
export interface ProcessedPost {
|
||||||
|
post: RedditPost;
|
||||||
|
imageAnalysis: string;
|
||||||
|
detectedGender: DetectedGender;
|
||||||
|
generatedMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProcessorService {
|
||||||
|
private successfulExamples: string[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private redditApi: RedditApiClient,
|
||||||
|
private aiClient: OpenRouterClient,
|
||||||
|
private config: Config,
|
||||||
|
private logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
setSuccessfulExamples(examples: string[]): void {
|
||||||
|
this.successfulExamples = examples;
|
||||||
|
if (examples.length > 0) {
|
||||||
|
this.logger.info(`Loaded ${examples.length} successful message examples for learning`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async processPost(post: RedditPost): Promise<ProcessedPost | null> {
|
||||||
|
const imageUrl = this.redditApi.getImageUrl(post);
|
||||||
|
|
||||||
|
if (!imageUrl) {
|
||||||
|
this.logger.warn(`No image URL found for post ${post.id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(`Processing post: "${post.title.slice(0, 50)}..." by u/${post.author}`);
|
||||||
|
|
||||||
|
// Download image
|
||||||
|
let imageData;
|
||||||
|
try {
|
||||||
|
imageData = await downloadImage(imageUrl, this.config.reddit.userAgent);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to download image from ${imageUrl}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate image type
|
||||||
|
if (!isValidImageMimeType(imageData.mimeType)) {
|
||||||
|
this.logger.warn(`Invalid image type ${imageData.mimeType} for post ${post.id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check image size (skip very small images like thumbnails)
|
||||||
|
if (imageData.buffer.length < 5000) {
|
||||||
|
this.logger.warn(`Image too small (${imageData.buffer.length} bytes) for post ${post.id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to base64
|
||||||
|
const base64 = bufferToBase64(imageData.buffer);
|
||||||
|
|
||||||
|
// Quick gender check first (saves API costs on non-matching posts)
|
||||||
|
const targetGenders = this.config.reddit.targetGenders;
|
||||||
|
if (targetGenders.length > 0) {
|
||||||
|
const detectedGender = await this.aiClient.detectGenderOnly(base64, imageData.mimeType);
|
||||||
|
|
||||||
|
if (!targetGenders.includes(detectedGender)) {
|
||||||
|
this.logger.info(
|
||||||
|
`Skipping post ${post.id} - detected gender "${detectedGender}" not in target list [${targetGenders.join(', ')}]`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Gender check passed: ${detectedGender}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!message || message.length < 10) {
|
||||||
|
this.logger.warn(`Generated message too short or empty for post ${post.id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
post,
|
||||||
|
imageAnalysis: analysis.description,
|
||||||
|
detectedGender: analysis.detectedGender,
|
||||||
|
generatedMessage: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import type { Page } from 'playwright';
|
||||||
|
import type { Logger } from '../utils/logger.js';
|
||||||
|
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord } from '../database/repositories.js';
|
||||||
|
import type { PostsRepository } from '../database/repositories.js';
|
||||||
|
|
||||||
|
export interface ResponseCheckResult {
|
||||||
|
messageId: number;
|
||||||
|
recipient: string;
|
||||||
|
gotResponse: boolean;
|
||||||
|
responseSnippet?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResponseCheckerService {
|
||||||
|
constructor(
|
||||||
|
private page: Page,
|
||||||
|
private messagesRepo: MessagesRepository,
|
||||||
|
private successfulMessagesRepo: SuccessfulMessagesRepository,
|
||||||
|
private postsRepo: PostsRepository,
|
||||||
|
private logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async checkForResponses(): Promise<ResponseCheckResult[]> {
|
||||||
|
const results: ResponseCheckResult[] = [];
|
||||||
|
|
||||||
|
// Get messages that haven't been checked recently (at least 1 hour old, recheck every 6 hours)
|
||||||
|
const minAgeMs = 60 * 60 * 1000; // 1 hour
|
||||||
|
const recheckAfterMs = 6 * 60 * 60 * 1000; // 6 hours
|
||||||
|
const uncheckedMessages = this.messagesRepo.getUncheckedMessages(minAgeMs, recheckAfterMs, 5);
|
||||||
|
|
||||||
|
if (uncheckedMessages.length === 0) {
|
||||||
|
this.logger.debug('No messages to check for responses');
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(`Checking ${uncheckedMessages.length} messages for responses...`);
|
||||||
|
|
||||||
|
// Navigate to inbox
|
||||||
|
try {
|
||||||
|
await this.page.goto('https://www.reddit.com/message/inbox/', {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Get all message threads/conversations
|
||||||
|
const conversations = await this.getInboxConversations();
|
||||||
|
|
||||||
|
for (const message of uncheckedMessages) {
|
||||||
|
const result = await this.checkMessageResponse(message, conversations);
|
||||||
|
results.push(result);
|
||||||
|
|
||||||
|
if (result.gotResponse) {
|
||||||
|
// Mark as got response
|
||||||
|
this.messagesRepo.markGotResponse(message.id);
|
||||||
|
|
||||||
|
// Get the original post info
|
||||||
|
const post = this.postsRepo.getById(message.postId);
|
||||||
|
|
||||||
|
// Store as successful message for learning
|
||||||
|
this.successfulMessagesRepo.insert({
|
||||||
|
sentMessageId: message.id,
|
||||||
|
originalMessage: message.message,
|
||||||
|
imageAnalysis: post?.imageAnalysis || null,
|
||||||
|
postTitle: post?.title || null,
|
||||||
|
responseSnippet: result.responseSnippet || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.info(`Got response from u/${message.recipient}! Stored for learning.`);
|
||||||
|
} else {
|
||||||
|
// Just mark as checked
|
||||||
|
this.messagesRepo.markChecked(message.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to check inbox for responses:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getInboxConversations(): Promise<Map<string, { hasReply: boolean; snippet: string }>> {
|
||||||
|
const conversations = new Map<string, { hasReply: boolean; snippet: string }>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to get conversation items from inbox
|
||||||
|
const messageItems = await this.page.$$('.message, [data-testid="message"], .thing.message');
|
||||||
|
|
||||||
|
for (const item of messageItems) {
|
||||||
|
try {
|
||||||
|
// Get the author/recipient of the conversation
|
||||||
|
const authorEl = await item.$('.author, [data-testid="message-author"], .sender a');
|
||||||
|
const author = authorEl ? await authorEl.textContent() : null;
|
||||||
|
|
||||||
|
if (!author) continue;
|
||||||
|
|
||||||
|
const cleanAuthor = author.replace(/^u\//, '').replace(/^\/u\//, '').trim();
|
||||||
|
|
||||||
|
// Check if this is a reply (not from us)
|
||||||
|
const isReply = await item.evaluate((el) => {
|
||||||
|
// Check various indicators that this is a received message, not sent
|
||||||
|
return !el.classList.contains('sent') && !el.querySelector('.sent');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get message snippet
|
||||||
|
const bodyEl = await item.$('.md, .message-body, [data-testid="message-body"]');
|
||||||
|
const snippet = bodyEl ? (await bodyEl.textContent())?.slice(0, 200) || '' : '';
|
||||||
|
|
||||||
|
if (isReply && cleanAuthor) {
|
||||||
|
conversations.set(cleanAuthor.toLowerCase(), {
|
||||||
|
hasReply: true,
|
||||||
|
snippet: snippet.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip problematic items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn('Failed to parse inbox conversations:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return conversations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkMessageResponse(
|
||||||
|
message: SentMessageRecord,
|
||||||
|
conversations: Map<string, { hasReply: boolean; snippet: string }>
|
||||||
|
): Promise<ResponseCheckResult> {
|
||||||
|
const recipientLower = message.recipient.toLowerCase();
|
||||||
|
const conversation = conversations.get(recipientLower);
|
||||||
|
|
||||||
|
if (conversation?.hasReply) {
|
||||||
|
return {
|
||||||
|
messageId: message.id,
|
||||||
|
recipient: message.recipient,
|
||||||
|
gotResponse: true,
|
||||||
|
responseSnippet: conversation.snippet,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId: message.id,
|
||||||
|
recipient: message.recipient,
|
||||||
|
gotResponse: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getSuccessfulExamples(limit: number = 3): string[] {
|
||||||
|
const successful = this.successfulMessagesRepo.getRecent(limit);
|
||||||
|
|
||||||
|
return successful.map((s) => {
|
||||||
|
let example = `Message: "${s.originalMessage}"`;
|
||||||
|
if (s.postTitle) {
|
||||||
|
example = `Post: "${s.postTitle}"\n${example}`;
|
||||||
|
}
|
||||||
|
return example;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getSuccessCount(): number {
|
||||||
|
return this.successfulMessagesRepo.count();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export interface DownloadedImage {
|
||||||
|
buffer: Buffer;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadImage(url: string, userAgent: string): Promise<DownloadedImage> {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': userAgent,
|
||||||
|
Accept: 'image/*',
|
||||||
|
},
|
||||||
|
maxRedirects: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers['content-type'] || 'image/jpeg';
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from(response.data),
|
||||||
|
mimeType: contentType.split(';')[0].trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bufferToBase64(buffer: Buffer): string {
|
||||||
|
return buffer.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidImageMimeType(mimeType: string): boolean {
|
||||||
|
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
return validTypes.includes(mimeType);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import winston from 'winston';
|
||||||
|
|
||||||
|
export type Logger = winston.Logger;
|
||||||
|
|
||||||
|
export function createLogger(): Logger {
|
||||||
|
return winston.createLogger({
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp(),
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.json()
|
||||||
|
),
|
||||||
|
transports: [
|
||||||
|
new winston.transports.Console({
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
winston.format.printf(({ level, message, timestamp, ...meta }) => {
|
||||||
|
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
|
||||||
|
return `${timestamp} [${level}]: ${message}${metaStr}`;
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: 'logs/error.log',
|
||||||
|
level: 'error',
|
||||||
|
}),
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: 'logs/combined.log',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import PQueue from 'p-queue';
|
||||||
|
import type { Config } from '../config/index.js';
|
||||||
|
import type { Logger } from './logger.js';
|
||||||
|
|
||||||
|
export class RateLimiter {
|
||||||
|
private queue: PQueue;
|
||||||
|
private lastMessageTime: number = 0;
|
||||||
|
private messagesThisHour: number = 0;
|
||||||
|
private hourStartTime: number = Date.now();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: Config,
|
||||||
|
private logger: Logger
|
||||||
|
) {
|
||||||
|
this.queue = new PQueue({ concurrency: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async throttle<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
return this.queue.add(async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Reset hourly counter if needed
|
||||||
|
if (now - this.hourStartTime > 3600000) {
|
||||||
|
this.messagesThisHour = 0;
|
||||||
|
this.hourStartTime = now;
|
||||||
|
this.logger.debug('Hourly rate limit counter reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check hourly limit
|
||||||
|
if (this.messagesThisHour >= this.config.rateLimit.maxMessagesPerHour) {
|
||||||
|
const waitTime = 3600000 - (now - this.hourStartTime);
|
||||||
|
this.logger.info(`Hourly limit reached, waiting ${Math.round(waitTime / 60000)} minutes`);
|
||||||
|
await this.sleep(waitTime);
|
||||||
|
this.messagesThisHour = 0;
|
||||||
|
this.hourStartTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce minimum delay with jitter
|
||||||
|
const timeSinceLastMessage = now - this.lastMessageTime;
|
||||||
|
const minDelay = this.config.rateLimit.minDelayBetweenMessagesMs;
|
||||||
|
const jitter = Math.random() * this.config.rateLimit.jitterMs;
|
||||||
|
|
||||||
|
if (this.lastMessageTime > 0 && timeSinceLastMessage < minDelay) {
|
||||||
|
const waitTime = minDelay - timeSinceLastMessage + jitter;
|
||||||
|
this.logger.debug(`Rate limiting: waiting ${Math.round(waitTime / 1000)} seconds`);
|
||||||
|
await this.sleep(waitTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastMessageTime = Date.now();
|
||||||
|
this.messagesThisHour++;
|
||||||
|
|
||||||
|
return fn();
|
||||||
|
}) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats(): { messagesThisHour: number; minutesSinceLastMessage: number } {
|
||||||
|
return {
|
||||||
|
messagesThisHour: this.messagesThisHour,
|
||||||
|
minutesSinceLastMessage: Math.round((Date.now() - this.lastMessageTime) / 60000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import pRetry from 'p-retry';
|
||||||
|
import type { Logger } from './logger.js';
|
||||||
|
|
||||||
|
export async function withRetry<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: {
|
||||||
|
retries?: number;
|
||||||
|
logger?: Logger;
|
||||||
|
operationName?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const { retries = 3, logger, operationName = 'operation' } = options;
|
||||||
|
|
||||||
|
return pRetry(fn, {
|
||||||
|
retries,
|
||||||
|
minTimeout: 5000,
|
||||||
|
maxTimeout: 60000,
|
||||||
|
factor: 2,
|
||||||
|
randomize: true,
|
||||||
|
onFailedAttempt: (context) => {
|
||||||
|
const errorMessage = context.error instanceof Error ? context.error.message : String(context.error);
|
||||||
|
logger?.warn(
|
||||||
|
`${operationName} attempt ${context.attemptNumber} failed: ${errorMessage}. ` +
|
||||||
|
`${context.retriesLeft} retries left.`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import type { Server } from 'http';
|
||||||
|
import type { Logger } from '../utils/logger.js';
|
||||||
|
import type { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from '../database/repositories.js';
|
||||||
|
import type { Config } from '../config/index.js';
|
||||||
|
|
||||||
|
export interface BotStats {
|
||||||
|
isRunning: boolean;
|
||||||
|
dryRunMode: boolean;
|
||||||
|
totalProcessed: number;
|
||||||
|
totalMessagesSent: number;
|
||||||
|
totalResponses: number;
|
||||||
|
responseRate: number;
|
||||||
|
messagesThisHour: number;
|
||||||
|
maxMessagesPerHour: number;
|
||||||
|
subreddits: string[];
|
||||||
|
targetGenders: string[];
|
||||||
|
recentActivity: ActivityItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityItem {
|
||||||
|
timestamp: number;
|
||||||
|
type: 'sent' | 'skipped' | 'response' | 'error';
|
||||||
|
message: string;
|
||||||
|
recipient?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebServer {
|
||||||
|
private app: express.Application;
|
||||||
|
private server: Server | null = null;
|
||||||
|
private activityLog: ActivityItem[] = [];
|
||||||
|
private botStats = {
|
||||||
|
isRunning: true,
|
||||||
|
messagesThisHour: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private port: number,
|
||||||
|
private config: Config,
|
||||||
|
private postsRepo: PostsRepository,
|
||||||
|
private messagesRepo: MessagesRepository,
|
||||||
|
private successfulMessagesRepo: SuccessfulMessagesRepository,
|
||||||
|
private logger: Logger
|
||||||
|
) {
|
||||||
|
this.app = express();
|
||||||
|
this.setupRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRoutes(): void {
|
||||||
|
// Serve static files
|
||||||
|
this.app.use(express.static(path.join(process.cwd(), 'public')));
|
||||||
|
this.app.use(express.json());
|
||||||
|
|
||||||
|
// API: Get stats
|
||||||
|
this.app.get('/api/stats', (req, res) => {
|
||||||
|
const stats = this.getStats();
|
||||||
|
res.json(stats);
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Get recent activity
|
||||||
|
this.app.get('/api/activity', (req, res) => {
|
||||||
|
res.json(this.activityLog.slice(-50).reverse());
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Get recent messages
|
||||||
|
this.app.get('/api/messages', (req, res) => {
|
||||||
|
const messages = this.messagesRepo.getRecent(20);
|
||||||
|
res.json(messages);
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Get successful messages (ones that got responses)
|
||||||
|
this.app.get('/api/successful', (req, res) => {
|
||||||
|
const successful = this.successfulMessagesRepo.getRecent(10);
|
||||||
|
res.json(successful);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve dashboard for all other routes
|
||||||
|
this.app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStats(): BotStats {
|
||||||
|
const messages = this.messagesRepo.getRecent(1000);
|
||||||
|
const totalSent = messages.length;
|
||||||
|
const totalResponses = messages.filter((m) => m.gotResponse).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRunning: this.botStats.isRunning,
|
||||||
|
dryRunMode: this.config.browser.dryRun,
|
||||||
|
totalProcessed: this.getTotalProcessed(),
|
||||||
|
totalMessagesSent: totalSent,
|
||||||
|
totalResponses,
|
||||||
|
responseRate: totalSent > 0 ? Math.round((totalResponses / totalSent) * 100) : 0,
|
||||||
|
messagesThisHour: this.botStats.messagesThisHour,
|
||||||
|
maxMessagesPerHour: this.config.rateLimit.maxMessagesPerHour,
|
||||||
|
subreddits: this.config.reddit.subreddits,
|
||||||
|
targetGenders: this.config.reddit.targetGenders,
|
||||||
|
recentActivity: this.activityLog.slice(-10).reverse(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTotalProcessed(): number {
|
||||||
|
// This is a simple count - in production you'd query the DB
|
||||||
|
return this.activityLog.filter((a) => a.type === 'sent' || a.type === 'skipped').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
logActivity(type: ActivityItem['type'], message: string, recipient?: string): void {
|
||||||
|
const item: ActivityItem = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
recipient,
|
||||||
|
};
|
||||||
|
this.activityLog.push(item);
|
||||||
|
|
||||||
|
// Keep only last 200 items
|
||||||
|
if (this.activityLog.length > 200) {
|
||||||
|
this.activityLog = this.activityLog.slice(-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStats(messagesThisHour: number): void {
|
||||||
|
this.botStats.messagesThisHour = messagesThisHour;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.server = this.app.listen(this.port, () => {
|
||||||
|
this.logger.info(`Web dashboard running at http://localhost:${this.port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.close();
|
||||||
|
this.server = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user