Update
This commit is contained in:
1 parent
b780bff850
commit
7b3ea37127
1752 files changed
+1341
-166
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user