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

No files matched your search

+3 -3
View File
@@ -31,7 +31,7 @@ export class OpenRouterClient {
this.logger.debug('Quick gender detection...');
const response = await this.client.chat.completions.create({
model: this.config.openrouter.model,
model: this.config.openrouter.imageAnalysisModel,
messages: [
{
role: 'user',
@@ -75,7 +75,7 @@ Then provide your analysis:
${this.config.openrouter.imageAnalysisPrompt}`;
const response = await this.client.chat.completions.create({
model: this.config.openrouter.model,
model: this.config.openrouter.imageAnalysisModel,
messages: [
{
role: 'user',
@@ -144,7 +144,7 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
}
const response = await this.client.chat.completions.create({
model: this.config.openrouter.model,
model: this.config.openrouter.chatModel,
messages: [{ role: 'user', content: prompt }],
max_tokens: 300,
temperature: 0.8, // Add some creativity
+72 -1
View File
@@ -10,7 +10,7 @@ export class BrowserClient {
constructor(
private config: Config,
private logger: Logger
) {}
) { }
async initialize(): Promise<BrowserContext> {
const sessionDir = path.resolve(process.cwd(), this.config.browser.sessionDir);
@@ -82,4 +82,75 @@ export class BrowserClient {
return false;
}
}
async login(): Promise<boolean> {
if (!this.config.reddit.username || !this.config.reddit.password) {
this.logger.error('Login failed: Missing credentials in config/env');
return false;
}
try {
this.logger.info(`Attempting to log in as ${this.config.reddit.username}...`);
const page = await this.getPage();
// Navigate to login page
await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' });
// Wait for inputs
// Reddit uses various IDs but name="username" and name="password" are reliable
this.logger.debug('Waiting for login form...');
// Handle the new Reddit login UI (sometimes it's split into two steps or inside a shadow DOM/iframe,
// but usually the specific login page is standard)
await page.waitForSelector('input[name="username"]', { timeout: 10000 });
await page.waitForSelector('input[name="password"]', { timeout: 10000 });
// Fill credentials
await page.fill('input[name="username"]', this.config.reddit.username);
await page.fill('input[name="password"]', this.config.reddit.password);
// Click login button
// Look for a button with type="submit" or text containing "Log In"
await page.click('button[type="submit"]'); // Standard form submit
this.logger.info('Credentials submitted. Waiting for navigation...');
// Wait for successful login indicator
// We can reuse the checks from isSessionValid logic, but with a longer timeout
try {
await page.waitForSelector('[data-testid="user-drawer-button"]', { timeout: 20000 });
this.logger.info('Login successful!');
return true;
} catch (e) {
// Double check if we are redirected to home
const url = page.url();
if (url === 'https://www.reddit.com/' || url.match(/reddit\.com\/?$/)) {
// Verify one more time
const isValid = await this.isSessionValid();
if (isValid) {
this.logger.info('Login successful (verified by session check)');
return true;
}
}
// Check for error messages
const errorContent = await page.evaluate(() => {
const err = document.querySelector('[role="alert"], .AnimatedForm__errorMessage');
return err ? err.textContent : null;
});
if (errorContent) {
this.logger.error(`Login failed with error: ${errorContent}`);
} else {
this.logger.error('Login flow finished but session is invalid.');
}
return false;
}
} catch (error) {
this.logger.error('Error during automated login:', error);
return false;
}
}
}
+22 -19
View File
@@ -7,7 +7,7 @@ export class RedditMessagePage {
constructor(
private page: Page,
private logger: Logger
) {}
) { }
setDryRun(enabled: boolean): void {
this.dryRun = enabled;
@@ -60,7 +60,7 @@ export class RedditMessagePage {
}
}
if (!found) {
throw new Error('Could not find recipient input field');
throw new Error('Could not find recipient input field');
}
}
@@ -69,7 +69,7 @@ export class RedditMessagePage {
['faceplate-text-input[name="message-title"]', 'input[name="subject"]', '#subject'],
subject
);
if (!subjectFilled) {
throw new Error('Could not find subject input field');
}
@@ -98,8 +98,11 @@ export class RedditMessagePage {
return true;
}
// Find and click submit button
// Find and click submit button - prioritized by specificity to user provided snippet
const submitButton =
(await this.page.$('button[type="submit"]:has-text("Send")')) ||
(await this.page.$('button.button-primary:has-text("Send")')) ||
(await this.page.$('button:has(span:has-text("Send"))')) ||
(await this.page.$('button[type="submit"]')) ||
(await this.page.$('button:has-text("Send")')) ||
(await this.page.$('input[type="submit"]'));
@@ -114,7 +117,7 @@ export class RedditMessagePage {
await submitButton.waitForElementState('enabled', { timeout: 5000 });
} catch (e) {
this.logger.warn('Submit button still disabled after filling fields. Attempting to trigger validation...');
// Try to trigger validation by blurring/focusing the last field
await this.page.keyboard.press('Tab');
await this.page.waitForTimeout(500);
@@ -124,15 +127,15 @@ export class RedditMessagePage {
// One last check
const isEnabled = await submitButton.isEnabled();
if (!isEnabled) {
this.logger.warn('Button still disabled. Attempting force click...');
await submitButton.click({ force: true });
// If force click fails/doesn't work, we proceed to success check anyway
// (sometimes it works even if reported disabled in shadow dom scenarios)
this.logger.warn('Button still disabled. Attempting force click...');
await submitButton.click({ force: true });
// If force click fails/doesn't work, we proceed to success check anyway
// (sometimes it works even if reported disabled in shadow dom scenarios)
} else {
await submitButton.click();
await submitButton.click();
}
}
}
// If we didn't force click above (normal path)
// Actually, the try-catch block above handles all clicking scenarios.
// We don't need a second click here.
@@ -144,8 +147,8 @@ export class RedditMessagePage {
this.page.waitForSelector('.success, [class*="success"], [class*="Success"]', { timeout: 15000 }),
// Check for the specific success message in the new UI
this.page.waitForFunction(() => {
const confirmation = document.querySelector('.message-sent-confirmation-text');
return confirmation && !confirmation.hasAttribute('hidden') && confirmation.textContent?.trim().length > 0;
const confirmation = document.querySelector('.message-sent-confirmation-text');
return confirmation && !confirmation.hasAttribute('hidden') && confirmation.textContent?.trim().length > 0;
}, null, { timeout: 15000 })
]);
@@ -162,8 +165,8 @@ export class RedditMessagePage {
// Also check if the success message is visible now (in case race failed slightly early)
const successMsg = await this.page.$('.message-sent-confirmation-text:not([hidden])');
if (successMsg && (await successMsg.isVisible())) {
this.logger.info(`Message sent successfully to u/${recipient}`);
return true;
this.logger.info(`Message sent successfully to u/${recipient}`);
return true;
}
// Check for error messages
@@ -172,7 +175,7 @@ export class RedditMessagePage {
if (errorElement && await errorElement.isVisible()) {
const errorText = await errorElement.textContent();
if (errorText && errorText.trim().length > 0) {
throw new Error(`Reddit error: ${errorText}`);
throw new Error(`Reddit error: ${errorText}`);
}
}
@@ -181,7 +184,7 @@ export class RedditMessagePage {
// Or just log warning.
// For now, let's trust the user report that it works and be less aggressive with throwing.
this.logger.warn('Message send status unclear (no success indicator found, but no error either). Assuming success if no error alert appeared.');
return true;
return true;
}
} catch (error) {
this.logger.error(`Failed to send message to u/${recipient}:`, error);
@@ -198,7 +201,7 @@ export class RedditMessagePage {
// Clear potentially existing content if it's an input/textarea
// Note: .fill() usually clears, but for custom elements or div-inputs we might need to select all + delete
// For now, we assume click focuses it.
if (useDelay) {
await this.typeWithDelay(value);
} else {
+12 -1
View File
@@ -12,10 +12,13 @@ export const ConfigSchema = z.object({
pollIntervalMs: z.number().default(120000),
userAgent: z.string().default('RedditBot/1.0'),
targetGenders: z.array(z.enum(['male', 'female', 'other', 'unknown'])).default(['female']),
username: z.string().optional(),
password: z.string().optional(),
}),
openrouter: z.object({
apiKey: z.string(),
model: z.string().default('google/gemini-3-flash-preview'),
imageAnalysisModel: z.string().default('google/gemini-3-flash-preview'),
chatModel: z.string().default('google/gemini-3-flash-preview'),
imageAnalysisPrompt: z.string(),
messageGenerationPrompt: z.string(),
}),
@@ -56,6 +59,14 @@ export function loadConfig(): Config {
}
rawConfig.openrouter.apiKey = process.env.OPENROUTER_API_KEY || rawConfig.openrouter.apiKey;
// Inject Reddit credentials from environment
if (process.env.REDDIT_USERNAME) {
rawConfig.reddit.username = process.env.REDDIT_USERNAME;
}
if (process.env.REDDIT_PASSWORD) {
rawConfig.reddit.password = process.env.REDDIT_PASSWORD;
}
// Override browser settings from environment
if (process.env.BROWSER_HEADLESS !== undefined) {
if (!rawConfig.browser) rawConfig.browser = {};
+35 -3
View File
@@ -49,13 +49,12 @@ export function runMigrations(db: Database.Database): void {
CREATE TABLE IF NOT EXISTS successful_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sent_message_id INTEGER NOT NULL,
sent_message_id INTEGER,
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)
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_posts_subreddit
@@ -80,4 +79,37 @@ export function runMigrations(db: Database.Database): void {
// Create index for response checking
db.exec(`CREATE INDEX IF NOT EXISTS idx_sent_messages_response ON sent_messages(got_response)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_sent_messages_recipient ON sent_messages(recipient)`);
// Migration: Make sent_message_id nullable in successful_messages
// SQLite doesn't support ALTER COLUMN, so we need to recreate the table
const successfulMsgColumns = db.prepare(`PRAGMA table_info(successful_messages)`).all() as { name: string; notnull: number }[];
const sentMsgIdCol = successfulMsgColumns.find((c) => c.name === 'sent_message_id');
if (sentMsgIdCol && sentMsgIdCol.notnull === 1) {
// Column is currently NOT NULL, we need to migrate
db.exec(`
-- Create new table with nullable sent_message_id
CREATE TABLE successful_messages_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sent_message_id INTEGER,
original_message TEXT NOT NULL,
image_analysis TEXT,
post_title TEXT,
response_snippet TEXT,
created_at INTEGER NOT NULL
);
-- Copy existing data
INSERT INTO successful_messages_new
SELECT id, sent_message_id, original_message, image_analysis, post_title, response_snippet, created_at
FROM successful_messages;
-- Drop old table
DROP TABLE successful_messages;
-- Rename new table
ALTER TABLE successful_messages_new RENAME TO successful_messages;
`);
}
}
+26 -2
View File
@@ -22,6 +22,7 @@ export interface SentMessageRecord {
sentAt: number;
gotResponse: boolean;
responseCheckedAt: number | null;
subreddit?: string;
}
export interface SuccessfulMessageRecord {
@@ -123,6 +124,7 @@ export class MessagesRepository {
private getUncheckedStmt: Database.Statement;
private markResponseStmt: Database.Statement;
private markCheckedStmt: Database.Statement;
private hasSentToStmt: Database.Statement;
constructor(private db: Database.Database) {
this.insertStmt = db.prepare(`
@@ -133,7 +135,11 @@ export class MessagesRepository {
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 ?
SELECT sm.*, pp.subreddit
FROM sent_messages sm
LEFT JOIN processed_posts pp ON sm.post_id = pp.id
ORDER BY sm.sent_at DESC
LIMIT ?
`);
this.getUncheckedStmt = db.prepare(`
@@ -152,6 +158,10 @@ export class MessagesRepository {
this.markCheckedStmt = db.prepare(`
UPDATE sent_messages SET response_checked_at = ? WHERE id = ?
`);
this.hasSentToStmt = db.prepare(`
SELECT 1 FROM sent_messages WHERE recipient = ? LIMIT 1
`);
}
insert(message: {
@@ -198,6 +208,10 @@ export class MessagesRepository {
this.markCheckedStmt.run(Date.now(), messageId);
}
hasSentTo(recipient: string): boolean {
return this.hasSentToStmt.get(recipient) !== undefined;
}
private mapRow(row: Record<string, unknown>): SentMessageRecord {
return {
id: row.id as number,
@@ -208,6 +222,7 @@ export class MessagesRepository {
sentAt: row.sent_at as number,
gotResponse: Boolean(row.got_response),
responseCheckedAt: row.response_checked_at as number | null,
subreddit: (row.subreddit as string) || undefined,
};
}
}
@@ -216,6 +231,7 @@ export class SuccessfulMessagesRepository {
private insertStmt: Database.Statement;
private getRecentStmt: Database.Statement;
private countStmt: Database.Statement;
private existsByMessageStmt: Database.Statement;
constructor(private db: Database.Database) {
this.insertStmt = db.prepare(`
@@ -228,10 +244,14 @@ export class SuccessfulMessagesRepository {
`);
this.countStmt = db.prepare(`SELECT COUNT(*) as count FROM successful_messages`);
this.existsByMessageStmt = db.prepare(`
SELECT 1 FROM successful_messages WHERE original_message = ? LIMIT 1
`);
}
insert(data: {
sentMessageId: number;
sentMessageId: number | null;
originalMessage: string;
imageAnalysis: string | null;
postTitle: string | null;
@@ -247,6 +267,10 @@ export class SuccessfulMessagesRepository {
);
}
existsByMessage(originalMessage: string): boolean {
return this.existsByMessageStmt.get(originalMessage) !== undefined;
}
getRecent(limit: number = 5): SuccessfulMessageRecord[] {
const rows = this.getRecentStmt.all(limit) as Record<string, unknown>[];
return rows.map((row) => ({
+75 -27
View File
@@ -8,6 +8,7 @@ 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 { ChatService } from './services/chat.js';
import { RateLimiter } from './utils/rate-limiter.js';
import { createLogger } from './utils/logger.js';
import { withRetry } from './utils/retry.js';
@@ -56,10 +57,24 @@ async function main(): Promise<void> {
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.warn('Session invalid or not logged in.');
// Attempt automated login if credentials are provided
if (config.reddit.username && config.reddit.password) {
logger.info('Credentials found in config/env. Attempting automated login...');
const loginSuccess = await browserClient.login();
if (!loginSuccess) {
logger.error('Automated login failed. Please check your credentials or run "npm run login" locally.');
await browserClient.close();
process.exit(1);
}
} else {
logger.error('Not logged in to Reddit and no credentials provided!');
logger.error('Please run "npm run login" first to authenticate, OR set REDDIT_USERNAME and REDDIT_PASSWORD environment variables.');
await browserClient.close();
process.exit(1);
}
}
logger.info('Reddit login verified');
@@ -68,7 +83,7 @@ async function main(): Promise<void> {
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 monitorService = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger);
const processorService = new ProcessorService(redditApi, aiClient, config, logger);
const responseChecker = new ResponseCheckerService(
page,
@@ -77,6 +92,7 @@ async function main(): Promise<void> {
postsRepo,
logger
);
const chatService = new ChatService(page, logger, successfulMessagesRepo);
const rateLimiter = new RateLimiter(config, logger);
// Initialize web dashboard
@@ -90,6 +106,8 @@ async function main(): Promise<void> {
successfulMessagesRepo,
logger
);
webServer.setChatService(chatService);
webServer.setResponseChecker(responseChecker);
webServer.start();
}
@@ -127,19 +145,20 @@ async function main(): Promise<void> {
logger.info(`Max messages per hour: ${config.rateLimit.maxMessagesPerHour}`);
logger.info('');
// Track poll cycles for periodic response checking
// Track poll states
let pollCycle = 0;
const RESPONSE_CHECK_INTERVAL = 5; // Check for responses every 5 poll cycles
let lastResponseCheck = 0;
const RESPONSE_CHECK_INTERVAL_MS = Math.max(config.reddit.pollIntervalMs * 5, 60000);
let lastPausedLog = 0;
// Main loop
while (!isShuttingDown) {
try {
pollCycle++;
// Periodically check for responses to learn from
if (pollCycle % RESPONSE_CHECK_INTERVAL === 0) {
// Periodically check for responses to learn from (run this even if paused)
if (Date.now() - lastResponseCheck > RESPONSE_CHECK_INTERVAL_MS) {
logger.debug('Checking for responses to sent messages...');
const responses = await responseChecker.checkForResponses();
lastResponseCheck = Date.now();
if (responses.some((r) => r.gotResponse)) {
// Reload successful examples
@@ -148,11 +167,25 @@ async function main(): Promise<void> {
}
}
// Check if bot is running (controlled via web UI)
if (webServer && !webServer.isRunning()) {
if (Date.now() - lastPausedLog > 60000) { // Log every minute
logger.info('Bot is paused. Use the web dashboard to start.');
lastPausedLog = Date.now();
}
await sleep(1000); // Check every second for quick start
continue;
}
pollCycle++;
// Get unprocessed posts
const posts = await monitorService.getUnprocessedPosts();
if (posts.length > 0) {
logger.info(`Found ${posts.length} unprocessed posts`);
} else {
logger.info('No new posts found. Monitoring...');
}
for (const post of posts) {
@@ -185,7 +218,7 @@ async function main(): Promise<void> {
postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage);
// Create subject line
const subject = `Re: ${post.title.slice(0, 50)}${post.title.length > 50 ? '...' : ''}`;
const subject = "Stunning";
// Send message with rate limiting
await rateLimiter.throttle(async () => {
@@ -198,26 +231,41 @@ async function main(): Promise<void> {
);
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)`
// Verify the message actually appears in chat
const verified = await chatService.verifyMessageSent(
post.author,
processed.generatedMessage
);
webServer?.logActivity('sent', `Sent message to u/${post.author}`, post.author);
webServer?.updateStats(stats.messagesThisHour);
if (verified) {
postsRepo.markMessageSent(post.id);
messagesRepo.insert({
postId: post.id,
recipient: post.author,
subject,
message: processed.generatedMessage,
sentAt: Date.now(),
});
const stats = rateLimiter.getStats();
const currentCount = stats.messagesThisHour + 1;
logger.info(
`Message sent and verified! (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)`
);
webServer?.logActivity('sent', `Sent message to u/${post.author}`, post.author);
webServer?.updateStats(currentCount);
} else {
logger.warn(`Message to u/${post.author} likely sent but not found in chat verification.`);
postsRepo.setError(post.id, 'Message sent but verification failed');
webServer?.logActivity('error', `Verification failed for u/${post.author}`, post.author);
}
} else {
postsRepo.setError(post.id, 'Failed to send message');
webServer?.logActivity('error', `Failed to send to u/${post.author}`, post.author);
}
});
return success;
}, (success) => success === true);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Error processing post ${post.id}:`, error);
+604
View File
@@ -0,0 +1,604 @@
import type { Page } from 'playwright';
import type { Logger } from '../utils/logger.js';
import type { SuccessfulMessagesRepository } from '../database/repositories.js';
export interface ChatMessage {
sender: string;
text: string;
timestamp: number;
isSelf: boolean;
}
export interface ActiveChat {
username: string;
roomName: string;
lastMessageTime: number;
lastMessageSnippet: string;
isFromUs: boolean;
unread: boolean;
url?: string;
roomId?: string;
messages?: ChatMessage[];
originMessage?: string; // The first message we sent that started the conversation
}
export class ChatService {
private cachedChats: ActiveChat[] = [];
private lastFetchTime: number = 0;
private readonly CACHE_DURATION_MS = 60 * 1000; // 1 minute cache
constructor(
private page: Page,
private logger: Logger,
private successfulMessagesRepo?: SuccessfulMessagesRepository
) { }
async getActiveChats(): Promise<ActiveChat[]> {
// Return cached data if fresh enough
const now = Date.now();
if (now - this.lastFetchTime < this.CACHE_DURATION_MS && this.cachedChats.length > 0) {
return this.cachedChats;
}
try {
this.logger.debug('Fetching active chats from chat.reddit.com...');
// Navigate to chat.reddit.com
await this.page.goto('https://chat.reddit.com/', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
// Wait for the chat app to initialize
await this.page.waitForTimeout(5000);
const chats = await this.scrapeChats();
// Fetch full history for each chat
this.logger.info(`Fetching full history for ${chats.length} chats...`);
for (const chat of chats) {
try {
await this.loadConversationDetails(chat);
} catch (err) {
this.logger.error(`Failed to load conversation for ${chat.roomName}`, err);
}
}
// Filter to last 96 hours for the "active" view
const cutoffTime = now - (96 * 60 * 60 * 1000);
this.cachedChats = chats.filter(chat => chat.lastMessageTime >= cutoffTime);
this.lastFetchTime = now;
this.logger.info(`Found ${this.cachedChats.length} active chats`);
return this.cachedChats;
} catch (error) {
this.logger.error('Failed to fetch active chats:', error);
return this.cachedChats;
}
}
async loadConversationDetails(chat: ActiveChat): Promise<void> {
if (!chat.url) {
this.logger.warn(`No URL for chat ${chat.roomName}, skipping details.`);
return;
}
this.logger.debug(`Loading conversation: ${chat.roomName} (${chat.url})`);
try {
await this.page.goto(chat.url, { waitUntil: 'domcontentloaded', timeout: 15000 });
} catch (navError) {
this.logger.warn(`Navigation to ${chat.url} timed out or failed`);
throw navError;
}
await this.page.waitForTimeout(2500); // Wait for messages to load
// First, try to extract the actual recipient username from the conversation header
// In Reddit chat, the header typically shows the other user's name
const recipientUsername = await this.extractRecipientUsername();
if (recipientUsername && recipientUsername !== chat.roomName) {
chat.username = recipientUsername;
this.logger.debug(`Extracted recipient username: ${recipientUsername} (room: ${chat.roomName})`);
}
// Now scrape all messages in the conversation
const messages = await this.scrapeConversationMessages(chat.username);
if (messages.length > 0) {
chat.messages = messages;
chat.lastMessageSnippet = messages[messages.length - 1].text;
// Find the origin message - the first message we sent
const firstSentMessage = messages.find(m => m.isSelf);
if (firstSentMessage) {
chat.originMessage = firstSentMessage.text;
this.logger.debug(`[ORIGIN MESSAGE] To: ${chat.username} | Room: "${chat.roomName}" | First message: "${firstSentMessage.text.slice(0, 100)}..."`);
}
// Check if they replied (any non-self message means they responded)
const hasReply = messages.some(m => !m.isSelf);
// If we have an origin message AND they replied, store as successful example
if (firstSentMessage && hasReply && this.successfulMessagesRepo) {
// Check if we already stored this message
if (!this.successfulMessagesRepo.existsByMessage(firstSentMessage.text)) {
// Get the first reply snippet for context
const firstReply = messages.find(m => !m.isSelf);
const replySnippet = firstReply?.text.slice(0, 200) || null;
// Store as successful message
try {
this.successfulMessagesRepo.insert({
sentMessageId: null, // No linked sent message record for chat-originated messages
originalMessage: firstSentMessage.text,
imageAnalysis: null, // We don't have image analysis for chat messages
postTitle: `Chat with ${chat.username}`,
responseSnippet: replySnippet,
});
this.logger.info(`[SUCCESS] Stored successful chat example from ${chat.username}!`);
} catch (err) {
this.logger.warn(`Failed to store successful chat example:`, err);
}
}
}
// Log full conversation for debugging
this.logger.debug(`[CONVERSATION] ${chat.username} (${messages.length} messages):${hasReply ? ' [HAS REPLY]' : ''}`);
for (const msg of messages) {
const prefix = msg.isSelf ? ' [ME]' : ` [${msg.sender}]`;
this.logger.debug(`${prefix}: ${msg.text.slice(0, 80)}${msg.text.length > 80 ? '...' : ''}`);
}
} else {
this.logger.debug(`No messages found in conversation ${chat.roomName}`);
}
}
async verifyMessageSent(recipient: string, messageText: string): Promise<boolean> {
this.logger.info(`Verifying message to ${recipient} in chat...`);
try {
await this.page.goto('https://chat.reddit.com/', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await this.page.waitForTimeout(5000);
const chats = await this.scrapeChats();
// Look for chat with recipient
const chat = chats.find(c =>
(c.username && c.username.toLowerCase() === recipient.toLowerCase()) ||
(c.roomName && c.roomName.toLowerCase() === recipient.toLowerCase())
);
if (!chat) {
this.logger.warn(`Verification failed: Could not find chat with ${recipient}`);
return false;
}
// Verify the last message is from us
if (!chat.isFromUs) {
this.logger.warn(`Verification failed: Last message is not from us (to ${recipient})`);
return false;
}
// Verify content (loose match as snippet might be truncated)
const snippet = chat.lastMessageSnippet.toLowerCase().trim();
const expected = messageText.toLowerCase().trim();
// Check if one contains the other (handling truncation)
if (expected.includes(snippet) || snippet.includes(expected) || snippet.includes(expected.slice(0, 20))) {
this.logger.info(`Message verified in chat for ${recipient}`);
return true;
}
this.logger.warn(`Verification warning: Text mismatch. Snippet: "${chat.lastMessageSnippet}", Expected: "${messageText.slice(0, 50)}..."`);
// Even if text mismatch slightly, if it's from us and time is recent, likely it's the one.
// But let's be strict or lenient?
// If checking "straight after", assume it's the one if timestamp is very recent?
// "lastMessageTime" is in chat object.
const now = Date.now();
if (now - chat.lastMessageTime < 60000) { // < 1 minute ago
this.logger.info(`Message text mismatch but timestamp is very recent (${Math.round((now - chat.lastMessageTime) / 1000)}s ago). Assuming success.`);
return true;
}
return false;
} catch (error) {
this.logger.error(`Error verifying message to ${recipient}:`, error);
return false;
}
}
private async extractRecipientUsername(): Promise<string | null> {
try {
// Use raw JavaScript to avoid TypeScript transformation issues
// Reddit chat uses Shadow DOM, so we traverse shadow roots
const recipientUsername = await this.page.evaluate(`
(function() {
var myUsername = 'Upset_Marsupial1006';
// Find the rs-room element which contains only the message area (not sidebar)
function findRsRoom() {
var rsApp = document.querySelector('rs-app');
if (!rsApp || !rsApp.shadowRoot) return null;
return rsApp.shadowRoot.querySelector('rs-room');
}
function findAllSpans(root) {
if (!root) return [];
var spans = Array.from(root.querySelectorAll('span'));
var allElements = root.querySelectorAll('*');
for (var i = 0; i < allElements.length; i++) {
var el = allElements[i];
if (el.shadowRoot) {
spans = spans.concat(findAllSpans(el.shadowRoot));
}
}
return spans;
}
var rsRoom = findRsRoom();
if (!rsRoom) return null;
// Start from rs-room's shadow root if it has one
var searchRoot = rsRoom.shadowRoot || rsRoom;
var allSpans = findAllSpans(searchRoot);
// Find spans with user-name class
for (var i = 0; i < allSpans.length; i++) {
var span = allSpans[i];
var text = (span.textContent || '').trim();
if (text.length === 0 || text.length > 40) continue;
if (!/^[A-Za-z0-9_-]+$/.test(text)) continue;
if (text === myUsername) continue;
if (span.className && (span.className.includes('user-name') || span.className.includes('username'))) {
return text;
}
}
// Second pass: find any alphanumeric span that's not ours
for (var i = 0; i < allSpans.length; i++) {
var span = allSpans[i];
var text = (span.textContent || '').trim();
if (text.length === 0 || text.length > 40) continue;
if (!/^[A-Za-z0-9_-]+$/.test(text)) continue;
if (text === myUsername) continue;
if (text.includes('_') || text.includes('-') || (text.length > 3 && text.length < 25)) {
return text;
}
}
return null;
})()
`);
return recipientUsername as string | null;
} catch (error) {
this.logger.warn('Failed to extract recipient username:', error);
return null;
}
}
private async scrapeConversationMessages(recipientUsername: string): Promise<ChatMessage[]> {
const messages: ChatMessage[] = [];
try {
// Use raw JavaScript string to avoid TypeScript transformation issues
const extractedMessages = await this.page.evaluate(`
(function() {
var myUsername = 'Upset_Marsupial1006';
var results = [];
// Find the rs-room element which contains only the message area (not sidebar)
function findRsRoom() {
var rsApp = document.querySelector('rs-app');
if (!rsApp || !rsApp.shadowRoot) return null;
return rsApp.shadowRoot.querySelector('rs-room');
}
function findAllSpans(root) {
if (!root) return [];
var spans = Array.from(root.querySelectorAll('span'));
var allElements = root.querySelectorAll('*');
for (var i = 0; i < allElements.length; i++) {
var el = allElements[i];
if (el.shadowRoot) {
spans = spans.concat(findAllSpans(el.shadowRoot));
}
}
return spans;
}
var rsRoom = findRsRoom();
if (!rsRoom) return [];
// Start from rs-room's shadow root if it has one
var searchRoot = rsRoom.shadowRoot || rsRoom;
var allSpans = findAllSpans(searchRoot);
// Find username spans
var usernameSpans = allSpans.filter(function(span) {
var text = (span.textContent || '').trim();
if (text.length === 0 || text.length > 40) return false;
if (!/^[A-Za-z0-9_-]+$/.test(text)) return false;
if (span.className && (span.className.includes('user-name') || span.className.includes('username'))) {
return true;
}
if (text.includes('_') || text.includes('-') || (text.length > 3 && text.length < 25)) {
return true;
}
return false;
});
// For each username span, find associated message text
for (var j = 0; j < usernameSpans.length; j++) {
var userSpan = usernameSpans[j];
var sender = (userSpan.textContent || '').trim();
if (!sender) continue;
// Walk up to find container with content
var container = userSpan.parentElement;
var attempts = 0;
while (container && attempts < 10) {
var containerText = container.innerText || container.textContent || '';
var senderEscaped = sender.replace(/[-_]/g, '\\\\$&');
var cleaned = containerText
.replace(new RegExp(senderEscaped, 'g'), '')
.replace(/\\d{1,2}:\\d{2}\\s*(AM|PM)?/gi, '')
.replace(/\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}/gi, '')
.replace(/yesterday|today|just now/gi, '')
.trim();
if (cleaned.length > 10 && cleaned.length < 3000) {
break;
}
container = container.parentElement;
attempts++;
}
if (container) {
var fullText = container.innerText || container.textContent || '';
var senderEscaped = sender.replace(/[-_]/g, '\\\\$&');
var messageText = fullText
.replace(new RegExp(senderEscaped, 'g'), '')
.replace(/\\d{1,2}:\\d{2}\\s*(AM|PM)?/gi, '')
.replace(/\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}/gi, '')
.replace(/yesterday|today|just now/gi, '')
.replace(/\\s+/g, ' ')
.replace(/^\\s*[:|-]\\s*/, '')
.trim();
if (messageText.length > 5) {
results.push({
sender: sender,
text: messageText,
isSelf: sender === myUsername
});
}
}
}
// Deduplicate
var unique = [];
for (var k = 0; k < results.length; k++) {
var msg = results[k];
var existingIdx = -1;
for (var m = 0; m < unique.length; m++) {
var u = unique[m];
if (u.sender === msg.sender &&
(u.text === msg.text || u.text.includes(msg.text) || msg.text.includes(u.text))) {
existingIdx = m;
break;
}
}
if (existingIdx === -1) {
unique.push(msg);
} else if (msg.text.length > unique[existingIdx].text.length) {
unique[existingIdx] = msg;
}
}
return unique;
})()
`) as { sender: string; text: string; isSelf: boolean }[];
// Convert to ChatMessage format
for (const msg of extractedMessages) {
messages.push({
sender: msg.isSelf ? 'Me' : msg.sender,
text: msg.text,
timestamp: Date.now(),
isSelf: msg.isSelf,
});
}
this.logger.debug(`Extracted ${messages.length} messages via JS injection`);
} catch (error) {
this.logger.warn('Error scraping conversation messages:', error);
}
return messages;
}
private async scrapeChats(): Promise<ActiveChat[]> {
const chats: ActiveChat[] = [];
try {
// Look for room links in the sidebar
this.logger.debug('Looking for chat room links...');
const roomLinks = await this.page.$$('a[href^="/room/"]');
this.logger.info(`Found ${roomLinks.length} room links`);
if (roomLinks.length === 0) {
this.logger.warn('No chat rooms found!');
const content = await this.page.content();
this.logger.debug(`Page Content Snippet: ${content.slice(0, 2000)}...`);
return [];
}
// Log first few items for debugging
for (let i = 0; i < Math.min(5, roomLinks.length); i++) {
const html = await roomLinks[i].evaluate(el => el.outerHTML);
this.logger.debug(`[ROOM_DEBUG_${i}] HTML: ${html.slice(0, 500)}`);
}
for (const item of roomLinks) {
try {
// Skip "create new chat" links
const href = await item.getAttribute('href');
if (!href || href.includes('/create')) continue;
const url = `https://chat.reddit.com${href}`;
// Extract room ID from URL
const roomIdMatch = href.match(/\/room\/([^/]+)/);
const roomId = roomIdMatch ? decodeURIComponent(roomIdMatch[1]) : undefined;
// Get aria-label which typically has the room name
const ariaLabel = await item.getAttribute('aria-label');
// Get the room name from .room-name element
const roomNameEl = await item.$('.room-name, [class*="room-name"]');
let roomName = roomNameEl ? (await roomNameEl.textContent())?.trim() : null;
// Fallback to aria-label if room-name not found
if (!roomName) {
roomName = ariaLabel || 'Unknown';
}
// Get last message preview
const lastMsgEl = await item.$('.last-message, [class*="last-message"]');
let lastMessage = lastMsgEl ? (await lastMsgEl.textContent())?.trim() : '';
// Check if last message is from us
const isFromUs = lastMessage?.startsWith('You:') || false;
if (isFromUs && lastMessage) {
lastMessage = lastMessage.replace(/^You:\s*/, '').trim();
}
// Get timestamp
const timeEl = await item.$('.last-message-time, [class*="time"]');
let timestamp = Date.now();
if (timeEl) {
const timeText = await timeEl.textContent();
if (timeText) {
timestamp = this.parseRelativeTime(timeText);
}
}
// Check for unread indicator
const unread = await item.evaluate((el) => {
const badges = el.querySelectorAll('[class*="badge"], [class*="unread"]');
return badges.length > 0;
});
// Initially, username = roomName (will be updated when we load conversation)
const chat: ActiveChat = {
username: roomName, // Will be updated with actual username later
roomName,
lastMessageTime: timestamp,
lastMessageSnippet: lastMessage || '',
isFromUs,
unread,
url,
roomId,
};
this.logger.debug(`Scraped room: "${roomName}" | Last: "${(lastMessage || '').slice(0, 30)}..." | FromUs: ${isFromUs}`);
chats.push(chat);
} catch (e) {
this.logger.error('Error processing chat item:', e);
}
}
// Deduplicate by URL
const chatMap = new Map<string, ActiveChat>();
for (const chat of chats) {
if (chat.url && !chatMap.has(chat.url)) {
chatMap.set(chat.url, chat);
}
}
this.logger.info(`Scraped ${chats.length} raw rooms, ${chatMap.size} unique rooms`);
return Array.from(chatMap.values())
.sort((a, b) => b.lastMessageTime - a.lastMessageTime);
} catch (error) {
this.logger.error('Error scraping chats from chat.reddit.com:', error);
return [];
}
}
private parseRelativeTime(text: string): number {
const now = Date.now();
const clean = text.toLowerCase().trim();
if (clean.includes('just now')) return now;
// "5m", "2h", "1d" compact format
const compactMatch = clean.match(/^(\d+)([mhdy])$/);
if (compactMatch) {
const val = parseInt(compactMatch[1]);
const unit = compactMatch[2];
if (unit === 'm') return now - val * 60 * 1000;
if (unit === 'h') return now - val * 60 * 60 * 1000;
if (unit === 'd') return now - val * 24 * 60 * 60 * 1000;
if (unit === 'y') return now - val * 365 * 24 * 60 * 60 * 1000;
}
// Standard relative time
const minuteMatch = clean.match(/(\d+)\s*(?:minute|min)/);
if (minuteMatch) return now - parseInt(minuteMatch[1]) * 60 * 1000;
const hourMatch = clean.match(/(\d+)\s*(?:hour|hr)/);
if (hourMatch) return now - parseInt(hourMatch[1]) * 60 * 60 * 1000;
const dayMatch = clean.match(/(\d+)\s*(?:day)/);
if (dayMatch) return now - parseInt(dayMatch[1]) * 24 * 60 * 60 * 1000;
// Time like "7:56 PM" - assume today
const timeMatch = clean.match(/(\d{1,2}):(\d{2})\s*(am|pm)?/i);
if (timeMatch) {
const date = new Date();
let hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const period = timeMatch[3]?.toLowerCase();
if (period === 'pm' && hours < 12) hours += 12;
if (period === 'am' && hours === 12) hours = 0;
date.setHours(hours, minutes, 0, 0);
// If the resulting time is in the future, it must be yesterday
if (date.getTime() > now) {
date.setDate(date.getDate() - 1);
}
return date.getTime();
}
// Date formats like "Aug 1, 2025" or "Jan 4"
const dateMatch = clean.match(/([a-z]{3})\s+(\d{1,2})(?:,?\s*(\d{4}))?/i);
if (dateMatch) {
const months: { [key: string]: number } = {
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11
};
const month = months[dateMatch[1].toLowerCase()];
const day = parseInt(dateMatch[2]);
const year = dateMatch[3] ? parseInt(dateMatch[3]) : new Date().getFullYear();
if (month !== undefined) {
return new Date(year, month, day).getTime();
}
}
return now;
}
}
+19 -5
View File
@@ -1,5 +1,5 @@
import type { RedditApiClient, RedditPost } from '../reddit/api.js';
import type { PostsRepository } from '../database/repositories.js';
import type { PostsRepository, MessagesRepository } from '../database/repositories.js';
import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js';
@@ -7,9 +7,10 @@ export class MonitorService {
constructor(
private redditApi: RedditApiClient,
private postsRepo: PostsRepository,
private messagesRepo: MessagesRepository,
private config: Config,
private logger: Logger
) {}
) { }
async getUnprocessedPosts(): Promise<RedditPost[]> {
const unprocessedPosts: RedditPost[] = [];
@@ -21,10 +22,23 @@ export class MonitorService {
let newCount = 0;
for (const post of posts) {
if (!this.postsRepo.isProcessed(post.id)) {
unprocessedPosts.push(post);
newCount++;
// Check if post is processed OR if we have already messaged this user
if (this.postsRepo.isProcessed(post.id)) {
continue;
}
if (this.messagesRepo.hasSentTo(post.author)) {
this.logger.debug(`Skipping post ${post.id} from u/${post.author} - already messaged this user`);
// Optionally mark this post as processed so we don't check it again?
// Or just rely on the check. Checking DB is fast enough.
// If we mark it processed, we should probably record WHY (e.g. "author_already_messaged").
// For now, let's just skip it.
continue;
}
unprocessedPosts.push(post);
newCount++;
}
if (newCount > 0) {
+12 -3
View File
@@ -17,7 +17,7 @@ export class ResponseCheckerService {
private successfulMessagesRepo: SuccessfulMessagesRepository,
private postsRepo: PostsRepository,
private logger: Logger
) {}
) { }
async checkForResponses(): Promise<ResponseCheckResult[]> {
const results: ResponseCheckResult[] = [];
@@ -37,7 +37,7 @@ export class ResponseCheckerService {
// Navigate to inbox
try {
await this.page.goto('https://www.reddit.com/message/inbox/', {
waitUntil: 'networkidle',
waitUntil: 'domcontentloaded',
timeout: 30000,
});
@@ -45,6 +45,7 @@ export class ResponseCheckerService {
// Get all message threads/conversations
const conversations = await this.getInboxConversations();
this.logger.debug(`Found ${conversations.size} conversations in inbox`);
for (const message of uncheckedMessages) {
const result = await this.checkMessageResponse(message, conversations);
@@ -106,11 +107,14 @@ export class ResponseCheckerService {
const bodyEl = await item.$('.md, .message-body, [data-testid="message-body"]');
const snippet = bodyEl ? (await bodyEl.textContent())?.slice(0, 200) || '' : '';
this.logger.debug(`Checking inbox item from: ${cleanAuthor}, isReply: ${isReply}`);
if (isReply && cleanAuthor) {
conversations.set(cleanAuthor.toLowerCase(), {
hasReply: true,
snippet: snippet.trim(),
});
this.logger.info(`Found reply from ${cleanAuthor}`);
}
} catch {
// Skip problematic items
@@ -130,6 +134,8 @@ export class ResponseCheckerService {
const recipientLower = message.recipient.toLowerCase();
const conversation = conversations.get(recipientLower);
this.logger.debug(`Checking response for ${message.recipient} (key: ${recipientLower}) - Found: ${!!conversation}`);
if (conversation?.hasReply) {
return {
messageId: message.id,
@@ -151,8 +157,11 @@ export class ResponseCheckerService {
return successful.map((s) => {
let example = `Message: "${s.originalMessage}"`;
if (s.imageAnalysis) {
example = `Context (Image Analysis): "${s.imageAnalysis.slice(0, 300)}..."\n${example}`;
}
if (s.postTitle) {
example = `Post: "${s.postTitle}"\n${example}`;
example = `Post Title: "${s.postTitle}"\n${example}`;
}
return example;
});
+11 -4
View File
@@ -15,7 +15,7 @@ export class RateLimiter {
this.queue = new PQueue({ concurrency: 1 });
}
async throttle<T>(fn: () => Promise<T>): Promise<T> {
async throttle<T>(fn: () => Promise<T>, shouldCount: (result: T) => boolean = () => true): Promise<T> {
return this.queue.add(async () => {
const now = Date.now();
@@ -36,6 +36,7 @@ export class RateLimiter {
}
// Enforce minimum delay with jitter
// We check against the previously completed message time
const timeSinceLastMessage = now - this.lastMessageTime;
const minDelay = this.config.rateLimit.minDelayBetweenMessagesMs;
const jitter = Math.random() * this.config.rateLimit.jitterMs;
@@ -46,10 +47,16 @@ export class RateLimiter {
await this.sleep(waitTime);
}
this.lastMessageTime = Date.now();
this.messagesThisHour++;
// Execute function
const result = await fn();
return fn();
// Update stats only if shouldCount returns true
if (shouldCount(result)) {
this.lastMessageTime = Date.now();
this.messagesThisHour++;
}
return result;
}) as Promise<T>;
}
+43 -2
View File
@@ -4,6 +4,8 @@ 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';
import type { ChatService, ActiveChat } from '../services/chat.js';
import type { ResponseCheckerService } from '../services/response-checker.js';
export interface BotStats {
isRunning: boolean;
@@ -32,6 +34,8 @@ export class WebServer {
private activityLog: ActivityItem[] = [];
private botRunning = false; // Start stopped
private messagesThisHour = 0;
private chatService: ChatService | null = null;
private responseChecker: ResponseCheckerService | null = null;
constructor(
private port: number,
@@ -44,7 +48,6 @@ export class WebServer {
this.app = express();
this.setupRoutes();
}
private setupRoutes(): void {
// Serve static files
this.app.use(express.static(path.join(process.cwd(), 'public')));
@@ -73,6 +76,21 @@ export class WebServer {
res.json(successful);
});
// API: Get active chats (last 48 hours)
this.app.get('/api/chats', async (req, res) => {
if (!this.chatService) {
res.json([]);
return;
}
try {
const chats = await this.chatService.getActiveChats();
res.json(chats);
} catch (error) {
this.logger.error('Failed to fetch chats:', error);
res.json([]);
}
});
// API: Start bot
this.app.post('/api/start', (req, res) => {
this.botRunning = true;
@@ -89,16 +107,39 @@ export class WebServer {
res.json({ success: true, running: false });
});
// API: Debug check responses
this.app.post('/api/debug/check-responses', async (req, res) => {
if (!this.responseChecker) {
res.status(500).json({ error: 'Response checker not initialized' });
return;
}
try {
this.logger.info('Manual response check triggered via API');
const results = await this.responseChecker.checkForResponses();
res.json({ success: true, results });
} catch (error) {
this.logger.error('Manual response check failed:', error);
res.status(500).json({ error: String(error) });
}
});
// Serve dashboard for all other routes
this.app.get(/.*/, (req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
});
}
isRunning(): boolean {
return this.botRunning;
}
setChatService(chatService: ChatService): void {
this.chatService = chatService;
}
setResponseChecker(responseChecker: ResponseCheckerService): void {
this.responseChecker = responseChecker;
}
private getStats(): BotStats {
const messages = this.messagesRepo.getRecent(1000);
const totalSent = messages.length;