Restructure chat service, web routes, and add test suite

Split monolithic chat service into modular structure with Matrix client
support, extract web server into per-resource route modules, introduce
bootstrap/main-loop separation, and add comprehensive vitest coverage
across services, repositories, and utilities. Adds project docs and
CLAUDE.md agent instructions.
This commit is contained in:
jjbrunton committed 2026-05-21 01:32:10 +01:00
1 parent dfa0580859
commit 633bb0ec04
84 files changed
+19278 -3888

No files matched your search

+211 -27
View File
@@ -4,9 +4,18 @@ import type { Logger } from '../utils/logger.js';
export type DetectedGender = 'male' | 'female' | 'other' | 'unknown';
export function stripDashes(text: string): string {
return text
.replace(/\s*[—–]\s*/g, ', ')
.replace(/,\s*,/g, ',')
.replace(/,\s*\./g, '.')
.trim();
}
export interface ImageAnalysisResult {
description: string;
detectedGender: DetectedGender;
attractivenessScore: number | null;
}
export class OpenRouterClient {
@@ -14,9 +23,10 @@ export class OpenRouterClient {
constructor(
private config: Config,
private logger: Logger
private logger: Logger,
client?: OpenAI,
) {
this.client = new OpenAI({
this.client = client ?? new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: config.openrouter.apiKey,
defaultHeaders: {
@@ -70,6 +80,9 @@ export class OpenRouterClient {
...this.config.openrouter.imageAnalysisFallbackModels,
];
let lastError: unknown = null;
let sawRefusal = false;
for (let i = 0; i < models.length; i++) {
const model = models[i];
const isFallback = i > 0;
@@ -90,14 +103,22 @@ export class OpenRouterClient {
}
// Result was null = refusal, try next model
sawRefusal = true;
this.logger.warn(`Model ${model} refused to analyze image${i < models.length - 1 ? ', trying fallback...' : ''}`);
} catch (error) {
// API error (not a refusal) - still try fallback
// API error (not a refusal) — try next model but remember the error.
lastError = error;
this.logger.warn(`Model ${model} failed: ${error instanceof Error ? error.message : error}${i < models.length - 1 ? ', trying fallback...' : ''}`);
}
}
// All models refused or failed
// If any model returned a refusal, surface AI_REFUSAL so callers can branch
// on it. Otherwise propagate the most recent API error so failures aren't
// masked as refusals.
if (sawRefusal) {
throw new Error('AI_REFUSAL: All models refused to analyze image');
}
if (lastError) throw lastError;
throw new Error('AI_REFUSAL: All models refused to analyze image');
}
@@ -134,23 +155,37 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
// Parse gender from response
let detectedGender: DetectedGender = 'unknown';
const genderMatch = fullResponse.match(/GENDER:(male|female|other|unknown)/i);
const genderMatch = fullResponse.match(/GENDER:\s*(male|female|other|unknown)/i);
if (genderMatch) {
detectedGender = genderMatch[1].toLowerCase() as DetectedGender;
}
// Remove gender line from description
const description = fullResponse.replace(/GENDER:(male|female|other|unknown)\n?/i, '').trim();
// Parse attractiveness rating (1-100 scale) from response.
// Tolerate markdown wrapping like **RATING:** 63 or RATING: **63**.
let attractivenessScore: number | null = null;
const ratingMatch = fullResponse.match(/RATING[:\s*]*[*_\s]*(\d{1,3})/i);
if (ratingMatch) {
const parsed = parseInt(ratingMatch[1], 10);
if (parsed >= 1 && parsed <= 100) {
attractivenessScore = parsed;
}
}
// Remove gender and rating lines from description
const description = fullResponse
.replace(/GENDER:\s*(male|female|other|unknown)\n?/i, '')
.replace(/\**\s*RATING[:\s*]*[*_\s]*\d{1,3}\**\n?/i, '')
.trim();
// Detect AI refusal - return null to signal fallback should be tried
if (this.isRefusal(description)) {
return null;
}
this.logger.debug(`Image analysis complete (${model}) - Gender: ${detectedGender}`);
this.logger.debug(`Image analysis complete (${model}) - Gender: ${detectedGender}, Rating: ${attractivenessScore ?? 'n/a'}`);
this.logger.debug(`Description: ${description}`);
return { description, detectedGender };
return { description, detectedGender, attractivenessScore };
}
private isRefusal(text: string): boolean {
@@ -189,7 +224,8 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
postTitle: string,
authorName: string,
successfulExamples: string[] = [],
strategyPrompt?: string
strategyPrompt?: string,
attractivenessScore: number | null = null,
): Promise<string> {
try {
this.logger.debug('Generating message...');
@@ -197,7 +233,8 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
let prompt = this.config.openrouter.messageGenerationPrompt
.replace('{analysis}', imageAnalysis)
.replace('{title}', postTitle)
.replace('{author}', authorName);
.replace('{author}', authorName)
.replace('{rating}', attractivenessScore != null ? String(attractivenessScore) : 'unknown');
// Append strategy-specific style guidance if provided
if (strategyPrompt) {
@@ -215,16 +252,11 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
this.logger.debug(`Including ${successfulExamples.length} successful examples in prompt`);
}
const response = await this.client.chat.completions.create({
model: this.config.openrouter.chatModel,
messages: [{ role: 'user', content: prompt }],
const message = await this.generateTextWithFallback('generateMessage', prompt, {
max_tokens: 300,
temperature: 0.8, // Add some creativity
temperature: 0.8,
});
const message = response.choices[0]?.message?.content || '';
this.logger.debug(`Message generated: ${message}`);
return message.trim();
return message;
} catch (error) {
this.logger.error('Message generation failed:', error);
throw error;
@@ -256,22 +288,62 @@ DO NOT:
Just write the message, nothing else.`;
const response = await this.client.chat.completions.create({
model: this.config.openrouter.chatModel,
messages: [{ role: 'user', content: prompt }],
const message = await this.generateTextWithFallback('generateReply', prompt, {
max_tokens: 300,
temperature: 0.8,
});
const message = response.choices[0]?.message?.content || '';
this.logger.debug(`Suggested reply generated: ${message}`);
return message.trim();
return message;
} catch (error) {
this.logger.error('Reply generation failed:', error);
throw error;
}
}
async generateMediaAsk(
conversationHistory: { sender: string; text: string; isSelf: boolean }[],
username: string
): Promise<string> {
try {
this.logger.debug(`Generating media-ask message for conversation with ${username}...`);
const historyText = conversationHistory
.map(msg => `${msg.isSelf ? 'You' : msg.sender}: ${msg.text}`)
.join('\n');
const prompt = `You are helping continue a Reddit chat conversation. Here is the conversation so far:
${historyText}
Write the next message to send to ${username}. The goal of THIS message is to playfully nudge them into sending a pic or video OF THEMSELVES — their body, face, outfit, what they look like right now, a selfie. NOT a pic of an object, place, food, pet, or scenery. Keep the same tone and style as the previous messages from "You" — casual, flirty, direct, never robotic. Keep it to 1-3 sentences. You can reference something they just said as a hook, but the ask itself must be about seeing THEM.
Pick ONE technique that fits the current vibe — don't combine them, don't telegraph that you're using one:
- Playful dare ("bet you won't send a selfie right now")
- Direct ask for them ("send me a pic", "lemme see you", "selfie?")
- Tease about how they look / sound ("now I have to see what you look like")
- Curiosity hook about their appearance ("ok I need a face to picture here")
- Compliment + ask ("you sound hot, prove it 😏", "show me that smile")
- Outfit / "what are you wearing" angle ("send a pic of the fit")
DO NOT:
- Sound like an AI or use formal language
- Use words like "captivating", "alluring", "exquisite", "radiant"
- Beg, sound desperate, or repeat an ask they already declined
- Write long elaborate sentences
- Mention OnlyFans, paying, or anything transactional
Just write the message, nothing else.`;
const message = await this.generateTextWithFallback('generateMediaAsk', prompt, {
max_tokens: 300,
temperature: 0.9,
});
return message;
} catch (error) {
this.logger.error('Media-ask generation failed:', error);
throw error;
}
}
/**
* Check if message content contains upsell/spam keywords (OnlyFans, selling, etc.)
*/
@@ -292,6 +364,118 @@ Just write the message, nothing else.`;
return { isUpsell: false };
}
async generateChatOpener(
postTitle: string,
selftext: string,
authorName: string,
detectedGender: DetectedGender,
successfulExamples: string[] = [],
strategyPrompt?: string,
): Promise<string> {
try {
this.logger.debug('Generating chat opener...');
let prompt = this.config.openrouter.chatOpenerPrompt
.replace('{title}', postTitle)
.replace('{selftext}', selftext || '(no body)')
.replace('{author}', authorName)
.replace('{gender}', detectedGender);
if (strategyPrompt) {
prompt += `\n\n${strategyPrompt}`;
}
if (successfulExamples.length > 0) {
const examplesText = successfulExamples
.map((ex, i) => `Example ${i + 1}:\n${ex}`)
.join('\n\n');
prompt = `Here are openers that got responses:\n\n${examplesText}\n\n---\n\nNow generate a new opener in a similar style:\n\n${prompt}`;
}
const message = await this.generateTextWithFallback('generateChatOpener', prompt, {
max_tokens: 200,
temperature: 0.85,
});
return message;
} catch (error) {
this.logger.error('Chat opener generation failed:', error);
throw error;
}
}
/**
* Try the primary chatModel and then chatFallbackModels in order. A model is
* considered to have failed (and the next is tried) when it throws, returns
* empty content, or returns a refusal. Returns the trimmed message from the
* first model that produces usable content; returns '' if none do.
*/
private async generateTextWithFallback(
label: string,
prompt: string,
opts: { max_tokens: number; temperature: number },
): Promise<string> {
const models = [
this.config.openrouter.chatModel,
...this.config.openrouter.chatFallbackModels,
];
this.logger.info(`[AI] ${label} prompt (${prompt.length} chars):\n${prompt}`);
let lastError: unknown = null;
for (let i = 0; i < models.length; i++) {
const model = models[i];
const isFallback = i > 0;
try {
if (isFallback) {
this.logger.info(`[AI] ${label} trying fallback model: ${model}`);
}
const response = await this.client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
max_tokens: opts.max_tokens,
temperature: opts.temperature,
});
const raw = response.choices[0]?.message?.content || '';
const choice = response.choices[0] as unknown as Record<string, unknown> | undefined;
const choiceMessage = choice?.message as Record<string, unknown> | undefined;
const respAny = response as unknown as Record<string, unknown>;
const meta = {
finish_reason: choice?.finish_reason,
native_finish_reason: choice?.native_finish_reason,
refusal: choiceMessage?.refusal,
model: response.model,
provider: respAny.provider,
usage: response.usage,
};
this.logger.info(`[AI] ${label} response from ${model} (${raw.length} chars) meta=${JSON.stringify(meta)}: ${JSON.stringify(raw)}`);
const trimmed = raw.trim();
if (!trimmed) {
this.logger.warn(`[AI] ${label}: ${model} returned empty content${i < models.length - 1 ? ', trying fallback...' : ''}`);
continue;
}
if (this.isRefusal(trimmed)) {
this.logger.warn(`[AI] ${label}: ${model} returned a refusal${i < models.length - 1 ? ', trying fallback...' : ''}`);
continue;
}
if (isFallback) {
this.logger.info(`[AI] ${label}: fallback model ${model} succeeded`);
}
return stripDashes(trimmed);
} catch (error) {
lastError = error;
this.logger.warn(`[AI] ${label}: ${model} threw ${error instanceof Error ? error.message : String(error)}${i < models.length - 1 ? ', trying fallback...' : ''}`);
}
}
if (lastError) {
throw lastError;
}
return '';
}
async analyzeAndGenerateMessage(
imageBase64: string,
mimeType: string,
@@ -301,7 +485,7 @@ Just write the message, nothing else.`;
strategyPrompt?: string
): Promise<{ analysis: ImageAnalysisResult; message: string }> {
const analysis = await this.analyzeImage(imageBase64, mimeType);
const message = await this.generateMessage(analysis.description, postTitle, authorName, successfulExamples, strategyPrompt);
const message = await this.generateMessage(analysis.description, postTitle, authorName, successfulExamples, strategyPrompt, analysis.attractivenessScore);
return { analysis, message };
}
}
+297
View File
@@ -0,0 +1,297 @@
import type { Config } from './config/index.js';
import type { Logger } from './utils/logger.js';
import type Database from 'better-sqlite3';
import { createDatabase, runMigrations } from './database/client.js';
import {
PostsRepository,
MessagesRepository,
SuccessfulMessagesRepository,
ActivityLogRepository,
} from './database/repositories.js';
import { BrowserClient } from './browser/client.js';
import { RedditMessagePage } from './browser/message-page.js';
import { RedditApiClient } from './reddit/api.js';
import { OpenRouterClient } from './ai/openrouter.js';
import { MonitorService } from './services/monitor.js';
import { ProcessorService } from './services/processor.js';
import { ResponseCheckerService } from './services/response-checker.js';
import { StrategySelector } from './services/strategy-selector.js';
import {
ChatService,
MatrixClient,
MatrixMediaDownloader,
extractMatrixCredentials,
loadCachedCredentials,
type TokenRefresher,
type UsernameResolver,
type MatrixCredentials,
} from './services/chat/index.js';
import { MediaService } from './services/media.js';
import { MediaSyncService } from './services/media-sync.js';
import { RateLimiter } from './utils/rate-limiter.js';
import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
import { WebServer } from './web/server.js';
export interface AppContainer {
config: Config;
logger: Logger;
db: Database.Database;
browserClient: BrowserClient;
messagePage: RedditMessagePage;
postsRepo: PostsRepository;
messagesRepo: MessagesRepository;
successfulMessagesRepo: SuccessfulMessagesRepository;
redditApi: RedditApiClient;
aiClient: OpenRouterClient;
monitorService: MonitorService;
processorService: ProcessorService;
strategySelector: StrategySelector;
responseChecker: ResponseCheckerService;
mediaService: MediaService;
chatService: ChatService;
mediaSyncService: MediaSyncService;
rateLimiter: RateLimiter;
webServer: WebServer | null;
}
/**
* Builds and wires every service required for the bot.
*
* Performs:
* 1. DB + repos
* 2. Browser + login
* 3. Three pages (compose / response-check / web-UI)
* 4. All services
* 5. Optional WebServer
*
* Returns a populated container. Caller owns lifecycle (start loop, handle
* shutdown).
*/
export async function bootstrap(config: Config, logger: Logger): Promise<AppContainer> {
// Database
const db = createDatabase(config);
runMigrations(db);
const postsRepo = new PostsRepository(db);
const messagesRepo = new MessagesRepository(db);
const successfulMessagesRepo = new SuccessfulMessagesRepository(db);
const activityLogRepo = new ActivityLogRepository(db);
logger.info('Database initialized');
// Browser
const browserClient = new BrowserClient(config, logger);
await browserClient.initialize();
const page = await browserClient.getPage();
const messagePage = new RedditMessagePage(page, logger);
messagePage.setDryRun(config.browser.dryRun);
// Login verification. When the web dashboard is enabled we treat a missing
// session as recoverable — the dashboard exposes a noVNC console so a human
// can complete the login flow in the running headed browser.
logger.info('Verifying Reddit login status...');
let loggedIn = await messagePage.isLoggedIn();
if (!loggedIn) {
logger.warn('Session invalid or not logged in.');
if (config.reddit.username && config.reddit.password) {
logger.info('Credentials found in config/env. Attempting automated login...');
loggedIn = await browserClient.login();
}
if (!loggedIn) {
if (config.web.enabled) {
logger.warn('Not logged in. Open the dashboard and click "Log in to Reddit" to authenticate via the noVNC console.');
logger.warn('The bot will stay paused until a valid session is detected.');
} else {
logger.error('Not logged in to Reddit and no recovery path (web dashboard disabled).');
logger.error('Run "npm run login" locally, OR set REDDIT_USERNAME and REDDIT_PASSWORD, OR enable the web dashboard.');
await browserClient.close();
process.exit(1);
}
}
}
if (loggedIn) {
logger.info('Reddit login verified');
}
// Reddit API client (shares browser context for auth)
const redditApi = new RedditApiClient(config, logger);
redditApi.setBrowserContext(browserClient.getContext()!);
// Dedicated page for the response checker (background scraping that
// doesn't conflict with the dashboard).
const chatPage = await browserClient.getContext()!.newPage();
// AI + services
const aiClient = new OpenRouterClient(config, logger);
const monitorService = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger);
const processorService = new ProcessorService(redditApi, aiClient, config, logger);
const strategySelector = new StrategySelector(config, messagesRepo, logger);
processorService.setStrategySelector(strategySelector);
const chatStrategySelector = new StrategySelector(config, messagesRepo, logger, 'chatStrategies');
processorService.setChatStrategySelector(chatStrategySelector);
const responseChecker = new ResponseCheckerService(
chatPage,
messagesRepo,
successfulMessagesRepo,
postsRepo,
logger,
);
// ---- Matrix chat backend (replaces the Playwright scraper) ----
// 1. Get credentials: prefer cached, fall back to a fresh sniff via the
// persistent browser session that we just verified is logged in.
let matrixCreds: MatrixCredentials | null = await loadCachedCredentials();
if (!matrixCreds) {
logger.info('No cached Matrix credentials — extracting via browser session...');
matrixCreds = await extractMatrixCredentials(browserClient.getContext()!, logger);
} else {
logger.info(`Loaded cached Matrix credentials (user=${matrixCreds.userId})`);
}
const matrixClient = new MatrixClient(matrixCreds, logger);
// 2. Build the media downloader + service on top of the Matrix client so
// `mxc://` URIs resolve through the same auth + rate-limiter pipeline.
const mediaDownloader = new MatrixMediaDownloader(matrixClient, logger);
const mediaService = new MediaService(mediaDownloader, logger);
// 3. Token refresher — on 401, re-sniff via the running browser session
// and swap the new credentials into the chat service.
let chatService: ChatService;
const tokenRefresher: TokenRefresher = {
async refresh() {
const fresh = await extractMatrixCredentials(browserClient.getContext()!, logger);
const next = new MatrixClient(fresh, logger);
chatService.setMatrixClient(next);
},
};
// 4. Username resolver — turns t2 IDs into real Reddit usernames so the
// dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint.
const usernameResolver: UsernameResolver = {
async resolveT2Ids(ids) {
if (ids.length === 0) return {};
const out: Record<string, string> = {};
for (let i = 0; i < ids.length; i += 100) {
const chunk = ids.slice(i, i + 100).join(',');
try {
const res = await fetchWithTimeout(
`https://api.reddit.com/api/user_data_by_account_ids?ids=${encodeURIComponent(chunk)}`,
{ headers: { 'User-Agent': config.reddit.userAgent }, timeoutMs: 15_000 },
);
if (!res.ok) {
logger.debug(`user_data_by_account_ids → ${res.status}`);
continue;
}
const json = (await res.json()) as Record<string, { name?: string }>;
for (const [t2, info] of Object.entries(json)) {
if (info?.name) out[t2] = info.name;
}
} catch (err) {
logger.debug('user_data_by_account_ids failed:', err);
}
}
return out;
},
};
chatService = new ChatService(
matrixClient,
logger,
successfulMessagesRepo,
mediaService,
tokenRefresher,
usernameResolver,
);
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs);
const rateLimiter = new RateLimiter(config, logger);
await chatService.loadCache();
// Lazy enrichment in the background — first sync may show t2 IDs briefly.
chatService.enrichUsernames().catch((err) => logger.debug('enrichUsernames failed:', err));
// Fire-and-forget startup sync: full if we've never backfilled, delta
// otherwise. Non-blocking so the rest of the bot can come up immediately;
// the dashboard's sync-status banner shows progress.
chatService
.runStartupSync()
.then(({ kind, chats }) => {
logger.info(`[chat] startup sync (${kind}) complete: ${chats} chats`);
})
.catch((err) => logger.error('[chat] startup sync failed:', err));
let webServer: WebServer | null = null;
if (config.web.enabled) {
webServer = new WebServer(
config.web.port,
config,
postsRepo,
messagesRepo,
successfulMessagesRepo,
activityLogRepo,
logger,
);
webServer.setChatService(chatService);
// Stream sync-progress frames over the dashboard's SSE channel so the
// full-sync UI can show live phase + per-room progress.
chatService.setProgressListener((progress) => {
webServer!.broadcastSyncProgress(progress as Record<string, unknown> | null);
});
chatService.setNewReplyListener(({ username, replySnippet }) => {
webServer!.logActivity(
'response',
`Reply from u/${username}: ${replySnippet}`,
username,
);
webServer!.pushLog('info', `💬 Reply from u/${username}`);
});
webServer.setMediaService(mediaService);
webServer.setMediaSyncService(mediaSyncService);
webServer.setResponseChecker(responseChecker);
webServer.setRateLimiter(rateLimiter);
webServer.setAiClient(aiClient);
webServer.setStrategySelector(strategySelector);
webServer.setBrowserClient(browserClient);
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
}
// Start background services
if (config.media.syncEnabled) mediaSyncService.start();
if (webServer) webServer.start();
// Prime processor with successful examples already on disk.
const existingExamples = responseChecker.getSuccessfulExamples(3);
processorService.setSuccessfulExamples(existingExamples);
const successCount = responseChecker.getSuccessCount();
if (successCount > 0) {
logger.info(`Loaded ${successCount} successful message(s) from history`);
}
return {
config,
logger,
db,
browserClient,
messagePage,
postsRepo,
messagesRepo,
successfulMessagesRepo,
redditApi,
aiClient,
monitorService,
processorService,
strategySelector,
responseChecker,
mediaService,
chatService,
mediaSyncService,
rateLimiter,
webServer,
};
}
+11
View File
@@ -7,6 +7,7 @@ import fs from 'fs';
export class BrowserClient {
private context: BrowserContext | null = null;
private sessionDir: string | null = null;
private sessionValidityInFlight: Promise<boolean> | null = null;
constructor(
private config: Config,
@@ -59,6 +60,16 @@ export class BrowserClient {
}
async isSessionValid(): Promise<boolean> {
if (this.sessionValidityInFlight) {
return this.sessionValidityInFlight;
}
this.sessionValidityInFlight = this.doSessionValidityCheck().finally(() => {
this.sessionValidityInFlight = null;
});
return this.sessionValidityInFlight;
}
private async doSessionValidityCheck(): Promise<boolean> {
try {
const page = await this.getPage();
await page.goto('https://www.reddit.com', { waitUntil: 'domcontentloaded', timeout: 30000 });
-29
View File
@@ -1,29 +0,0 @@
import type { Page } from 'playwright';
import type { Logger } from '../utils/logger.js';
import type { MediaDownloadResult, MediaDownloader } from '../services/media.js';
export class PlaywrightMediaDownloader implements MediaDownloader {
constructor(
private page: Page,
private logger: Logger
) {}
async fetch(url: string): Promise<MediaDownloadResult | null> {
try {
const response = await this.page.request.get(url, { timeout: 15000 });
if (!response.ok()) {
this.logger.warn(`Media download failed (${response.status()}): ${url}`);
return null;
}
const headers = response.headers();
const contentType = headers['content-type'] ?? null;
const buffer = Buffer.from(await response.body());
return { buffer, contentType };
} catch (error) {
this.logger.warn(`Media download error for ${url}:`, error);
return null;
}
}
}
+59 -18
View File
@@ -6,9 +6,21 @@ import dotenv from 'dotenv';
dotenv.config();
export const SubredditConfigSchema = z.object({
name: z.string(),
type: z.enum(['image', 'chat']).default('image'),
weight: z.number().positive().optional(),
});
export type SubredditConfig = z.infer<typeof SubredditConfigSchema>;
const SubredditEntrySchema = z
.union([z.string().transform((s) => ({ name: s })), z.object({}).passthrough()])
.pipe(SubredditConfigSchema);
export const ConfigSchema = z.object({
reddit: z.object({
subreddits: z.array(z.string()).min(1),
subreddits: z.array(SubredditEntrySchema).min(1),
pollIntervalMs: z.number().default(120000),
userAgent: z.string().default('RedditBot/1.0'),
targetGenders: z.array(z.enum(['male', 'female', 'other', 'unknown'])).default(['female']),
@@ -52,6 +64,35 @@ export const ConfigSchema = z.object({
'wishlist',
'throne.com',
]),
sellerDomains: z
.array(z.string())
.default([
'onlyfans.com',
'fansly.com',
'fanvue.com',
'loyalfans.com',
'manyvids.com',
'linktr.ee',
'allmylinks.com',
'beacons.ai',
'beacons.page',
'throne.com',
'throne.me',
'fan.ly',
'chaturbate.com',
'myfreecams.com',
'pornhub.com',
'xvideos.com',
'cash.app',
'cashapp.com',
'venmo.com',
'paypal.me',
'paypal.com',
'admireme.vip',
'fancentro.com',
'justforfans.app',
'justfor.fans',
]),
username: z.string().optional(),
password: z.string().optional(),
}),
@@ -60,39 +101,30 @@ export const ConfigSchema = z.object({
imageAnalysisModel: z.string().default('google/gemini-3-flash-preview'),
imageAnalysisFallbackModels: z.array(z.string()).default([]),
chatModel: z.string().default('google/gemini-3-flash-preview'),
chatFallbackModels: z.array(z.string()).default([]),
imageAnalysisPrompt: z.string(),
messageGenerationPrompt: z.string(),
chatOpenerPrompt: z.string().default(
'You are writing a cold opener DM to a Reddit user who just posted in a chat/sexting subreddit looking for someone to chat with.\n\nPost title: {title}\nPost body: {selftext}\nAuthor: u/{author}\nDetected gender of poster: {gender}\n\nYOUR PERSONA:\n- You are male, based in the UK, pick a random age between 30 and 35 for this message\n- Roughly half the time, mention (or imply) you\'re married/have a wife and are looking for something on the side; the other half, don\'t mention relationship status\n- Don\'t force the married angle if their post wouldn\'t vibe with it\n- Use British English spelling and phrasing where it feels natural\n\nWrite a casual Reddit DM (1-2 sentences) opening a chat.\n\nMUST follow:\n- Lead with your age/gender/location tag (e.g. "32M UK", "34M UK married") when it fits naturally\n- Reference something SPECIFIC from their title/body (their mood, what they\'re into, vibe)\n- Be confident and direct, not formal\n- Use proper capitalisation, no AI/clinical language\n- End with a hook that invites a reply (ask them something, or offer something playful)\n\nDO NOT:\n- Use words like "captivating", "alluring", "intriguing"\n- Open with "Hey there" / "How\'s your day"\n- Write more than 2 sentences\n- Claim to be a different age/gender than the persona above\n- Use em dashes (—) or en dashes (–). Use commas, full stops, or separate sentences instead.\n\nJust write the message, nothing else.',
),
}),
rateLimit: z.object({
minDelayBetweenMessagesMs: z.number().default(300000),
maxMessagesPerHour: z.number().default(8),
jitterMs: z.number().default(60000),
}),
}).default({ minDelayBetweenMessagesMs: 300000, maxMessagesPerHour: 8, jitterMs: 60000 }),
browser: z.object({
sessionDir: z.string().default('./data/browser-session'),
headless: z.boolean().default(false),
dryRun: z.boolean().default(false),
}),
}).default({ sessionDir: './data/browser-session', headless: false, dryRun: false }),
database: z.object({
path: z.string().default('./data/reddit-bot.db'),
}),
}).default({ path: './data/reddit-bot.db' }),
media: z.object({
syncIntervalMs: z.number().default(1800000), // 30 minutes
syncEnabled: z.boolean().default(true),
}).default({ syncIntervalMs: 1800000, syncEnabled: true }),
autoReply: z.object({
enabled: z.boolean().default(false),
autoSend: z.boolean().default(false), // If true, send replies automatically. If false, require manual approval
checkIntervalMs: z.number().default(300000), // 5 minutes
upsellKeywords: z.array(z.string()).default([
'onlyfans', 'only fans', 'fansly', 'manyvids', 'linktree', 'linktr.ee',
'subscribe', 'premium', 'paid content', 'custom content', 'custom vid',
'cashapp', 'cash app', 'venmo', 'paypal', 'tip me', 'spoil me',
'wishlist', 'throne.com', 'link in bio', 'check my profile',
'fanvue', 'loyalfans', 'cam show', 'buy my', 'selling',
'dm me for price', 'menu', 'rates', 'ppv',
]),
}).default({ enabled: false, autoSend: false, checkIntervalMs: 300000, upsellKeywords: [] }),
strategies: z.object({
explorationRate: z.number().min(0).max(1).default(0.2),
minSamples: z.number().default(5),
@@ -102,10 +134,19 @@ export const ConfigSchema = z.object({
promptOverride: z.string(),
})).default([]),
}).default({ explorationRate: 0.2, minSamples: 5, variants: [] }),
chatStrategies: z.object({
explorationRate: z.number().min(0).max(1).default(0.2),
minSamples: z.number().default(5),
variants: z.array(z.object({
id: z.string(),
weight: z.number().default(1),
promptOverride: z.string(),
})).default([]),
}).default({ explorationRate: 0.2, minSamples: 5, variants: [] }),
web: z.object({
enabled: z.boolean().default(true),
port: z.number().default(3000),
}),
}).default({ enabled: true, port: 3000 }),
});
export type Config = z.infer<typeof ConfigSchema>;
+11
View File
@@ -57,12 +57,23 @@ export function runMigrations(db: Database.Database): void {
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
type TEXT NOT NULL,
message TEXT NOT NULL,
recipient TEXT,
image_url TEXT
);
CREATE INDEX IF NOT EXISTS idx_posts_subreddit
ON processed_posts(subreddit);
CREATE INDEX IF NOT EXISTS idx_posts_processed_at
ON processed_posts(processed_at);
CREATE INDEX IF NOT EXISTS idx_posts_message_sent
ON processed_posts(message_sent);
CREATE INDEX IF NOT EXISTS idx_activity_log_timestamp
ON activity_log(timestamp);
`);
// Add new columns to existing tables (safe to run multiple times)
+96 -2
View File
@@ -26,6 +26,7 @@ export interface SentMessageRecord {
subreddit?: string;
postTitle?: string;
postImageUrl?: string;
postImageAnalysis?: string;
}
export interface SuccessfulMessageRecord {
@@ -60,6 +61,80 @@ export interface DailyTrend {
responseRate: number;
}
export interface ActivityLogRecord {
timestamp: number;
type: string;
message: string;
recipient: string | null;
imageUrl: string | null;
}
export class ActivityLogRepository {
private insertStmt: Database.Statement;
private getRecentStmt: Database.Statement;
private pruneStmt: Database.Statement;
private countSentSkippedStmt: Database.Statement;
constructor(private db: Database.Database) {
this.insertStmt = db.prepare(`
INSERT INTO activity_log (timestamp, type, message, recipient, image_url)
VALUES (?, ?, ?, ?, ?)
`);
this.getRecentStmt = db.prepare(`
SELECT timestamp, type, message, recipient, image_url
FROM activity_log
ORDER BY id DESC
LIMIT ?
`);
this.pruneStmt = db.prepare(`
DELETE FROM activity_log
WHERE id NOT IN (SELECT id FROM activity_log ORDER BY id DESC LIMIT ?)
`);
this.countSentSkippedStmt = db.prepare(`
SELECT COUNT(*) AS count FROM activity_log WHERE type IN ('sent', 'skipped')
`);
}
insert(item: {
timestamp: number;
type: string;
message: string;
recipient?: string;
imageUrl?: string;
}): void {
this.insertStmt.run(
item.timestamp,
item.type,
item.message,
item.recipient ?? null,
item.imageUrl ?? null,
);
}
getRecent(limit: number): ActivityLogRecord[] {
const rows = this.getRecentStmt.all(limit) as Record<string, unknown>[];
return rows.map((row) => ({
timestamp: row.timestamp as number,
type: row.type as string,
message: row.message as string,
recipient: (row.recipient as string) ?? null,
imageUrl: (row.image_url as string) ?? null,
}));
}
prune(keep: number): void {
this.pruneStmt.run(keep);
}
countSentSkipped(): number {
const row = this.countSentSkippedStmt.get() as { count: number };
return row.count;
}
}
export class PostsRepository {
private insertStmt: Database.Statement;
private existsStmt: Database.Statement;
@@ -166,6 +241,7 @@ export class PostsRepository {
export class MessagesRepository {
private insertStmt: Database.Statement;
private getByPostIdStmt: Database.Statement;
private getLatestByRecipientStmt: Database.Statement;
private getRecentStmt: Database.Statement;
private getUncheckedStmt: Database.Statement;
private markResponseStmt: Database.Statement;
@@ -180,8 +256,19 @@ export class MessagesRepository {
this.getByPostIdStmt = db.prepare(`SELECT * FROM sent_messages WHERE post_id = ?`);
this.getLatestByRecipientStmt = db.prepare(`
SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url,
pp.image_analysis AS post_image_analysis
FROM sent_messages sm
LEFT JOIN processed_posts pp ON sm.post_id = pp.id
WHERE sm.recipient = ? COLLATE NOCASE
ORDER BY sm.sent_at DESC
LIMIT 1
`);
this.getRecentStmt = db.prepare(`
SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url
SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url,
pp.image_analysis AS post_image_analysis
FROM sent_messages sm
LEFT JOIN processed_posts pp ON sm.post_id = pp.id
ORDER BY sm.sent_at DESC
@@ -235,6 +322,12 @@ export class MessagesRepository {
return this.mapRow(row);
}
getLatestByRecipient(recipient: string): SentMessageRecord | undefined {
const row = this.getLatestByRecipientStmt.get(recipient) as Record<string, unknown> | undefined;
if (!row) return undefined;
return this.mapRow(row);
}
getRecent(limit: number = 10): SentMessageRecord[] {
const rows = this.getRecentStmt.all(limit) as Record<string, unknown>[];
return rows.map((row) => this.mapRow(row));
@@ -340,6 +433,7 @@ export class MessagesRepository {
subreddit: (row.subreddit as string) || undefined,
postTitle: (row.post_title as string) || undefined,
postImageUrl: (row.post_image_url as string) || undefined,
postImageAnalysis: (row.post_image_analysis as string) || undefined,
};
}
}
@@ -358,7 +452,7 @@ export class SuccessfulMessagesRepository {
`);
this.getRecentStmt = db.prepare(`
SELECT * FROM successful_messages ORDER BY created_at DESC LIMIT ?
SELECT * FROM successful_messages ORDER BY created_at DESC, id DESC LIMIT ?
`);
// Get examples prioritizing same subreddit, then falling back to others
+4 -366
View File
@@ -1,27 +1,7 @@
import { loadConfig } from './config/index.js';
import { createDatabase, runMigrations } from './database/client.js';
import { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from './database/repositories.js';
import { BrowserClient } from './browser/client.js';
import { RedditMessagePage } from './browser/message-page.js';
import { RedditApiClient } from './reddit/api.js';
import { OpenRouterClient } from './ai/openrouter.js';
import { MonitorService } from './services/monitor.js';
import { ProcessorService } from './services/processor.js';
import { ResponseCheckerService } from './services/response-checker.js';
import { StrategySelector } from './services/strategy-selector.js';
import { ChatService } from './services/chat.js';
import { MediaService } from './services/media.js';
import { MediaSyncService } from './services/media-sync.js';
import { AutoReplyService } from './services/auto-reply.js';
import { PlaywrightMediaDownloader } from './browser/media-downloader.js';
import { RateLimiter } from './utils/rate-limiter.js';
import { createLogger } from './utils/logger.js';
import { withRetry } from './utils/retry.js';
import { WebServer } from './web/server.js';
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
import { bootstrap } from './bootstrap.js';
import { runMainLoop } from './main-loop.js';
async function main(): Promise<void> {
const logger = createLogger();
@@ -30,7 +10,6 @@ async function main(): Promise<void> {
logger.info('Reddit Bot Starting');
logger.info('='.repeat(60));
// Load configuration
let config;
try {
config = loadConfig();
@@ -40,349 +19,8 @@ async function main(): Promise<void> {
process.exit(1);
}
// Initialize database
const db = createDatabase(config);
runMigrations(db);
const postsRepo = new PostsRepository(db);
const messagesRepo = new MessagesRepository(db);
const successfulMessagesRepo = new SuccessfulMessagesRepository(db);
logger.info('Database initialized');
// Initialize browser
const browserClient = new BrowserClient(config, logger);
await browserClient.initialize();
const page = await browserClient.getPage();
const messagePage = new RedditMessagePage(page, logger);
// Set dry run mode if configured
messagePage.setDryRun(config.browser.dryRun);
// Verify login
logger.info('Verifying Reddit login status...');
const isLoggedIn = await messagePage.isLoggedIn();
if (!isLoggedIn) {
logger.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');
// Initialize services
const redditApi = new RedditApiClient(config, logger);
redditApi.setBrowserContext(browserClient.getContext()!);
// Create separate pages for concurrent operations
// 1. chatPage: For background response checking
const chatPage = await browserClient.getContext()!.newPage();
// 2. webUIPage: For user-driven chat browsing in the web dashboard
const webUIPage = await browserClient.getContext()!.newPage();
const aiClient = new OpenRouterClient(config, logger);
const monitorService = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger);
const processorService = new ProcessorService(redditApi, aiClient, config, logger);
const strategySelector = new StrategySelector(config, messagesRepo, logger);
processorService.setStrategySelector(strategySelector);
const responseChecker = new ResponseCheckerService(
chatPage,
messagesRepo,
successfulMessagesRepo,
postsRepo,
logger
);
const mediaDownloader = new PlaywrightMediaDownloader(webUIPage, logger);
const mediaService = new MediaService(mediaDownloader, logger);
const chatService = new ChatService(webUIPage, logger, successfulMessagesRepo, mediaService);
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs);
const rateLimiter = new RateLimiter(config, logger);
await chatService.loadCache();
// Initialize auto-reply service
const autoReplyService = new AutoReplyService(
chatService,
aiClient,
config,
logger,
// onReplyGenerated
(chat) => {
webServer?.logActivity('info', `Auto-reply pending approval for u/${chat.username}`, chat.username);
webServer?.pushLog('info', `Reply pending approval for u/${chat.username}: "${chat.pendingReply?.slice(0, 80)}..."`);
},
// onUpsellDetected
(chat, keyword) => {
webServer?.logActivity('skipped', `Upsell detected from u/${chat.username} (keyword: "${keyword}")`, chat.username);
webServer?.pushLog('warn', `Upsell detected from u/${chat.username} — keyword: "${keyword}"`);
},
// onAutoSent
(chat) => {
webServer?.logActivity('sent', `Auto-reply sent to u/${chat.username}`, chat.username);
webServer?.pushLog('info', `Auto-reply sent to u/${chat.username}`);
},
);
// Start background media sync
if (config.media.syncEnabled) {
mediaSyncService.start();
}
// Start auto-reply service
if (config.autoReply.enabled) {
autoReplyService.start();
}
// Initialize web dashboard
let webServer: WebServer | null = null;
if (config.web.enabled) {
webServer = new WebServer(
config.web.port,
config,
postsRepo,
messagesRepo,
successfulMessagesRepo,
logger
);
webServer.setChatService(chatService);
webServer.setMediaService(mediaService);
webServer.setMediaSyncService(mediaSyncService);
webServer.setResponseChecker(responseChecker);
webServer.setRateLimiter(rateLimiter);
webServer.setAiClient(aiClient);
webServer.setStrategySelector(strategySelector);
webServer.setAutoReplyService(autoReplyService);
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
webServer.start();
}
// Load any existing successful examples
const existingExamples = responseChecker.getSuccessfulExamples(3);
processorService.setSuccessfulExamples(existingExamples);
const successCount = responseChecker.getSuccessCount();
if (successCount > 0) {
logger.info(`Loaded ${successCount} successful message(s) from history`);
}
// Graceful shutdown handler
let isShuttingDown = false;
let responseCheckInterval: NodeJS.Timeout | null = null;
const shutdown = async (): Promise<void> => {
if (isShuttingDown) return;
isShuttingDown = true;
if (responseCheckInterval) {
clearInterval(responseCheckInterval);
}
mediaSyncService.stop();
autoReplyService.stop();
logger.info('Shutting down gracefully...');
webServer?.stop();
await browserClient.close();
db.close();
logger.info('Goodbye!');
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
logger.info('');
logger.info('Bot is now running!');
logger.info(`Monitoring subreddits: ${config.reddit.subreddits.join(', ')}`);
logger.info(`Poll interval: ${config.reddit.pollIntervalMs / 1000} seconds`);
logger.info(`Min delay between messages: ${config.rateLimit.minDelayBetweenMessagesMs / 1000} seconds`);
logger.info(`Max messages per hour: ${config.rateLimit.maxMessagesPerHour}`);
logger.info('');
// Start background response checking
const RESPONSE_CHECK_INTERVAL_MS = Math.max(config.reddit.pollIntervalMs * 5, 60000);
const checkResponses = async () => {
if (isShuttingDown) return;
try {
logger.debug('Checking for responses to sent messages (Background Thread)...');
const responses = await responseChecker.checkForResponses();
if (responses.some((r) => r.gotResponse)) {
// Reload successful examples
const updatedExamples = responseChecker.getSuccessfulExamples(3);
processorService.setSuccessfulExamples(updatedExamples);
}
} catch (error) {
logger.error('Error in background response checker:', error);
}
};
// Run immediately then schedule
checkResponses();
responseCheckInterval = setInterval(checkResponses, RESPONSE_CHECK_INTERVAL_MS);
// Track poll states
let pollCycle = 0;
let lastPausedLog = 0;
// Main loop
while (!isShuttingDown) {
try {
// 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`);
webServer?.pushLog('info', `Found ${posts.length} unprocessed posts`);
} else {
logger.info('No new posts found. Monitoring...');
}
for (const post of posts) {
if (isShuttingDown) break;
try {
// Mark as processed immediately to avoid duplicates on restart
const postImageUrl = redditApi.getImageUrl(post);
postsRepo.insert({
id: post.id,
subreddit: post.subreddit,
author: post.author,
title: post.title,
imageUrl: postImageUrl,
processedAt: Date.now(),
});
// Check if user is a seller/OnlyFans profile (before spending on AI)
if (config.reddit.filterSellers && config.reddit.sellerKeywords.length > 0) {
const isSeller = await redditApi.isSellerProfile(post.author, config.reddit.sellerKeywords);
if (isSeller) {
logger.info(`Skipping u/${post.author} - seller/OnlyFans profile detected`);
webServer?.logActivity('skipped', `Skipped u/${post.author} (seller profile)`, post.author);
webServer?.pushLog('info', `🚫 Skipped u/${post.author} - seller/OnlyFans profile`);
postsRepo.setError(post.id, 'Seller profile detected');
continue;
}
}
// Refresh examples weighted toward this post's subreddit
const subredditExamples = responseChecker.getSuccessfulExamples(3, post.subreddit);
processorService.setSuccessfulExamples(subredditExamples);
// Process post (download image, analyze, generate message)
const processed = await withRetry(
() => processorService.processPost(post),
{ logger, retries: 2, operationName: 'Post processing' }
);
if (!processed) {
logger.debug(`Skipping post ${post.id} - processing returned null`);
webServer?.logActivity('skipped', `Skipped post by u/${post.author}`, post.author);
continue;
}
// Update database with analysis
postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage);
// Log image analysis and strategy to realtime log
webServer?.pushLog('info', `🖼️ Image analysis for u/${post.author}: ${processed.imageAnalysis}`);
if (processed.strategyId) {
webServer?.pushLog('info', `🎯 Strategy: ${processed.strategyId}`);
}
// Create subject line
const subject = "Stunning";
// Send message with rate limiting
await rateLimiter.throttle(async () => {
logger.info(`Sending message to u/${post.author}...`);
webServer?.pushLog('info', `Sending message to u/${post.author}...`);
const success = await messagePage.sendMessage(
post.author,
subject,
processed.generatedMessage
);
if (success) {
// Message was sent successfully via /message/compose
// Note: We skip chat.reddit.com verification since /message/compose sends to inbox, not chat
// The sendMessage function already confirms success via form submission indicators
postsRepo.markMessageSent(post.id);
// Refresh top chats in background so dashboard is up-to-date
chatService.refreshTopChats(5).catch(err => logger.error('Failed to trigger chat refresh:', err));
messagesRepo.insert({
postId: post.id,
recipient: post.author,
subject,
message: processed.generatedMessage,
sentAt: Date.now(),
strategyId: processed.strategyId,
});
const stats = rateLimiter.getStats();
const currentCount = stats.messagesThisHour + 1;
logger.info(
`Message sent successfully! (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)`
);
webServer?.logActivity('sent', `Sent message to u/${post.author}`, post.author, postImageUrl ?? undefined);
webServer?.pushLog('info', `✅ Message sent to u/${post.author} (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)`);
webServer?.updateStats(currentCount);
} else {
postsRepo.setError(post.id, 'Failed to send message');
webServer?.logActivity('error', `Failed to send to u/${post.author}`, post.author, postImageUrl ?? undefined);
webServer?.pushLog('error', `❌ Failed to send message to u/${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);
postsRepo.setError(post.id, errorMessage);
}
}
if (!isShuttingDown) {
// Wait before next poll
logger.debug(`Waiting ${config.reddit.pollIntervalMs / 1000} seconds before next poll...`);
await sleep(config.reddit.pollIntervalMs);
}
} catch (error) {
logger.error('Error in main loop:', error);
// Wait a minute on error before retrying
await sleep(60000);
}
}
const app = await bootstrap(config, logger);
await runMainLoop(app);
}
main().catch((error) => {
+276
View File
@@ -0,0 +1,276 @@
import type { AppContainer } from './bootstrap.js';
import { withRetry } from './utils/retry.js';
import { isSkip } from './services/processor.js';
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const PAUSED_LOG_INTERVAL_MS = 60_000;
const PAUSE_POLL_MS = 1_000;
const ERROR_BACKOFF_MS = 60_000;
/**
* Runs the bot's main polling loop and the background response checker until
* SIGINT/SIGTERM. Resolves once shutdown completes.
*/
export async function runMainLoop(app: AppContainer): Promise<void> {
const {
config,
logger,
browserClient,
postsRepo,
messagesRepo,
redditApi,
monitorService,
processorService,
responseChecker,
chatService,
rateLimiter,
mediaSyncService,
db,
webServer,
} = app;
let isShuttingDown = false;
let responseCheckInterval: NodeJS.Timeout | null = null;
const shutdown = async (): Promise<void> => {
if (isShuttingDown) return;
isShuttingDown = true;
if (responseCheckInterval) clearInterval(responseCheckInterval);
mediaSyncService.stop();
logger.info('Shutting down gracefully...');
webServer?.stop();
await browserClient.close();
db.close();
logger.info('Goodbye!');
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
logger.info('');
logger.info('Bot is now running!');
logger.info(`Monitoring subreddits: ${config.reddit.subreddits.map((s) => `${s.name}[${s.type}]`).join(', ')}`);
logger.info(`Poll interval: ${config.reddit.pollIntervalMs / 1000} seconds`);
logger.info(`Min delay between messages: ${config.rateLimit.minDelayBetweenMessagesMs / 1000} seconds`);
logger.info(`Max messages per hour: ${config.rateLimit.maxMessagesPerHour}`);
logger.info('');
// Background response checker
const RESPONSE_CHECK_INTERVAL_MS = Math.max(config.reddit.pollIntervalMs * 5, 60_000);
const checkResponses = async (): Promise<void> => {
if (isShuttingDown) return;
try {
logger.debug('Checking for responses to sent messages (Background Thread)...');
const responses = await responseChecker.checkForResponses();
for (const r of responses) {
if (!r.gotResponse) continue;
const snippet = r.responseSnippet ? `: ${r.responseSnippet.slice(0, 200)}` : '';
webServer?.logActivity(
'response',
`Reply from u/${r.recipient}${snippet}`,
r.recipient,
);
webServer?.pushLog('info', `💬 Reply from u/${r.recipient}`);
}
if (responses.some((r) => r.gotResponse)) {
const updatedExamples = responseChecker.getSuccessfulExamples(3);
processorService.setSuccessfulExamples(updatedExamples);
}
} catch (error) {
logger.error('Error in background response checker:', error);
}
};
checkResponses();
responseCheckInterval = setInterval(checkResponses, RESPONSE_CHECK_INTERVAL_MS);
let lastPausedLog = 0;
let lastCooldownLog = 0;
const COOLDOWN_LOG_INTERVAL_MS = 5 * 60_000;
while (!isShuttingDown) {
try {
if (webServer && !webServer.isRunning()) {
if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) {
logger.info('Bot is paused. Use the web dashboard to start.');
lastPausedLog = Date.now();
}
await sleep(PAUSE_POLL_MS);
continue;
}
// If Reddit told us to take a break on creating new chats, don't even
// pull new posts — they'd just queue up and skip. Wait the cooldown out.
const cooldown = chatService.getInvitationCooldown();
if (cooldown) {
const minutesLeft = Math.ceil((cooldown.until - Date.now()) / 60_000);
if (Date.now() - lastCooldownLog > COOLDOWN_LOG_INTERVAL_MS) {
logger.warn(`Chat invitation cooldown active — ~${minutesLeft}m left. ${cooldown.reason}`);
webServer?.pushLog(
'warn',
`⏸️ Chat throttle: ${cooldown.reason} (~${minutesLeft}m remaining)`,
);
lastCooldownLog = Date.now();
}
await sleep(config.reddit.pollIntervalMs);
continue;
}
lastCooldownLog = 0;
const posts = await monitorService.getUnprocessedPosts();
if (posts.length > 0) {
logger.info(`Found ${posts.length} unprocessed posts`);
webServer?.pushLog('info', `Found ${posts.length} unprocessed posts`);
} else {
logger.info('No new posts found. Monitoring...');
}
for (const post of posts) {
if (isShuttingDown) break;
try {
const subredditEntry = config.reddit.subreddits.find(
(s) => s.name.toLowerCase() === post.subreddit.toLowerCase(),
);
const isChatSub = subredditEntry?.type === 'chat';
const postImageUrl = isChatSub ? null : redditApi.getImageUrl(post);
postsRepo.insert({
id: post.id,
subreddit: post.subreddit,
author: post.author,
title: post.title,
imageUrl: postImageUrl,
processedAt: Date.now(),
});
// Skip OF/seller accounts before spending on AI.
if (
config.reddit.filterSellers &&
(config.reddit.sellerKeywords.length > 0 || config.reddit.sellerDomains.length > 0)
) {
const isSeller = await redditApi.isSellerProfile(
post.author,
config.reddit.sellerKeywords,
config.reddit.sellerDomains
);
if (isSeller) {
logger.info(`Skipping u/${post.author} - seller/OnlyFans profile detected`);
webServer?.logActivity(
'skipped',
`Skipped post by u/${post.author} (seller/OnlyFans profile)`,
post.author,
);
webServer?.pushLog('info', `🚫 Skipped u/${post.author} - seller/OnlyFans profile`);
postsRepo.setError(post.id, 'Seller profile detected');
continue;
}
}
// Refresh examples weighted toward this post's subreddit.
const subredditExamples = responseChecker.getSuccessfulExamples(3, post.subreddit);
processorService.setSuccessfulExamples(subredditExamples);
const processed = await withRetry(
() => processorService.processPost(post),
{ logger, retries: 2, operationName: 'Post processing' },
);
if (isSkip(processed)) {
logger.debug(`Skipping post ${post.id} - ${processed.skipReason}`);
webServer?.logActivity(
'skipped',
`Skipped post by u/${post.author} (${processed.skipReason})`,
post.author,
);
webServer?.pushLog('info', `⏭️ Skipped u/${post.author} — ${processed.skipReason}`);
continue;
}
postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage);
webServer?.pushLog(
'info',
`${isChatSub ? '💬' : '🖼️'} ${isChatSub ? 'Chat post' : 'Image analysis'} for u/${post.author}: ${processed.imageAnalysis}`,
);
if (processed.strategyId) {
webServer?.pushLog('info', `🎯 Strategy: ${processed.strategyId}`);
}
await rateLimiter.throttle(
async () => {
logger.info(`Sending message to u/${post.author}...`);
webServer?.pushLog('info', `Sending message to u/${post.author}...`);
const chat = await chatService.startConversation(post.author, processed.generatedMessage);
const success = chat !== null;
if (success) {
postsRepo.markMessageSent(post.id);
// Refresh top chats in background so dashboard is up-to-date.
chatService
.refreshTopChats(5)
.catch((err) => logger.error('Failed to trigger chat refresh:', err));
messagesRepo.insert({
postId: post.id,
recipient: post.author,
subject: post.author,
message: processed.generatedMessage,
sentAt: Date.now(),
strategyId: processed.strategyId,
});
const stats = rateLimiter.getStats();
const currentCount = stats.messagesThisHour + 1;
logger.info(
`Message sent successfully! (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)`,
);
webServer?.logActivity(
'sent',
`Sent message to u/${post.author} (r/${post.subreddit})`,
post.author,
postImageUrl ?? undefined,
);
webServer?.pushLog('info', `✅ Message sent to u/${post.author} (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)`);
webServer?.updateStats(currentCount);
} else {
postsRepo.setError(post.id, 'Failed to send message');
webServer?.logActivity(
'error',
`Failed to send to u/${post.author} (r/${post.subreddit})`,
post.author,
postImageUrl ?? undefined,
);
webServer?.pushLog('error', `❌ Failed to send message to u/${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);
postsRepo.setError(post.id, errorMessage);
}
}
if (!isShuttingDown) {
logger.debug(`Waiting ${config.reddit.pollIntervalMs / 1000} seconds before next poll...`);
await sleep(config.reddit.pollIntervalMs);
}
} catch (error) {
logger.error('Error in main loop:', error);
await sleep(ERROR_BACKOFF_MS);
}
}
}
+49 -13
View File
@@ -13,6 +13,7 @@ export interface RedditPost {
permalink: string;
thumbnail: string;
is_self: boolean;
selftext?: string;
post_hint?: string;
preview?: {
images: Array<{
@@ -25,6 +26,25 @@ export interface RedditPost {
spoiler: boolean;
}
// Extracts URL-ish tokens (with or without scheme) and returns the first
// domain match. Matches `host` or any subdomain (`*.host`), case-insensitive.
export function findSellerDomain(text: string, domains: string[]): string | null {
if (!text || domains.length === 0) return null;
const normalized = text.toLowerCase();
const urlRegex = /(?:https?:\/\/|(?<![\w.@])(?=[a-z0-9-]+\.[a-z]))([a-z0-9.-]+\.[a-z]{2,})(?:[/?#][^\s)]*)?/gi;
const hosts: string[] = [];
for (const m of normalized.matchAll(urlRegex)) {
hosts.push(m[1].replace(/\.+$/, ''));
}
for (const host of hosts) {
for (const domain of domains) {
const d = domain.toLowerCase();
if (host === d || host.endsWith('.' + d)) return domain;
}
}
return null;
}
export class RedditApiClient {
private client: AxiosInstance;
private browserContext: BrowserContext | null = null;
@@ -62,7 +82,7 @@ export class RedditApiClient {
}
}
async getNewPosts(subreddit: string, limit = 25): Promise<RedditPost[]> {
async getNewPosts(subreddit: string, limit = 25, type: 'image' | 'chat' = 'image'): Promise<RedditPost[]> {
try {
const cookieHeader = await this.getCookieHeader();
const headers: Record<string, string> = {};
@@ -77,9 +97,9 @@ export class RedditApiClient {
const posts: RedditPost[] = response.data.data.children
.map((child: { data: RedditPost }) => child.data)
.filter((post: RedditPost) => this.isValidPost(post));
.filter((post: RedditPost) => this.isValidPost(post, type));
this.logger.debug(`Fetched ${posts.length} valid image posts from r/${subreddit}`);
this.logger.debug(`Fetched ${posts.length} valid ${type} posts from r/${subreddit}`);
return posts;
} catch (error) {
if (axios.isAxiosError(error)) {
@@ -101,17 +121,19 @@ export class RedditApiClient {
}
}
private isValidPost(post: RedditPost): boolean {
// Skip self posts (text only)
if (post.is_self) return false;
private isValidPost(post: RedditPost, type: 'image' | 'chat' = 'image'): boolean {
// Skip deleted/removed authors
if (post.author === '[deleted]' || post.author === 'AutoModerator') return false;
// Skip NSFW if we want to be safe (configurable later)
// if (post.over_18) return false;
if (type === 'chat') {
// Chat subs are text-driven. Require a title (always present) and
// ideally some self text — but allow title-only since some tags live
// in the title alone (e.g. "[25F] up late, dm me").
return true;
}
// Check if it's an image post
// image type:
if (post.is_self) return false;
return this.hasImage(post);
}
@@ -180,8 +202,12 @@ export class RedditApiClient {
return url.replace(/&amp;/g, '&');
}
async isSellerProfile(username: string, sellerKeywords: string[]): Promise<boolean> {
if (sellerKeywords.length === 0) return false;
async isSellerProfile(
username: string,
sellerKeywords: string[],
sellerDomains: string[] = []
): Promise<boolean> {
if (sellerKeywords.length === 0 && sellerDomains.length === 0) return false;
try {
const cookieHeader = await this.getCookieHeader();
@@ -210,7 +236,17 @@ export class RedditApiClient {
for (const keyword of sellerKeywords) {
if (textToCheck.includes(keyword.toLowerCase())) {
this.logger.info(
`u/${username} flagged as seller profile (matched: "${keyword}")`
`u/${username} flagged as seller profile (matched keyword: "${keyword}")`
);
return true;
}
}
if (sellerDomains.length > 0) {
const matchedDomain = findSellerDomain(textToCheck, sellerDomains);
if (matchedDomain) {
this.logger.info(
`u/${username} flagged as seller profile (matched domain: "${matchedDomain}")`
);
return true;
}
-199
View File
@@ -1,199 +0,0 @@
import type { Logger } from '../utils/logger.js';
import type { Config } from '../config/index.js';
import type { OpenRouterClient } from '../ai/openrouter.js';
import type { ChatService, ActiveChat } from './chat.js';
export class AutoReplyService {
private interval: NodeJS.Timeout | null = null;
private processing = false;
// Track which conversations we've already processed to avoid re-generating replies
private processedConversations = new Set<string>();
constructor(
private chatService: ChatService,
private aiClient: OpenRouterClient,
private config: Config,
private logger: Logger,
private onReplyGenerated?: (chat: ActiveChat) => void,
private onUpsellDetected?: (chat: ActiveChat, keyword: string) => void,
private onAutoSent?: (chat: ActiveChat) => void,
) { }
async start(): Promise<void> {
if (!this.config.autoReply.enabled) {
this.logger.info('Auto-reply is disabled');
return;
}
// Seed processedConversations from cache so we don't re-generate on restart
const cached = await this.chatService.getCachedConversations();
for (const chat of cached) {
if (chat.replyStatus) {
const stateKey = `${chat.username}:${chat.lastMessageTime}:${chat.lastMessageSnippet}`;
this.processedConversations.add(stateKey);
}
}
this.logger.info(`Auto-reply: Seeded ${this.processedConversations.size} already-processed conversation(s) from cache`);
const intervalMs = this.config.autoReply.checkIntervalMs;
this.logger.info(`Auto-reply service started (interval: ${intervalMs / 1000}s, autoSend: ${this.config.autoReply.autoSend})`);
// Run immediately then schedule
this.processUnreadChats();
this.interval = setInterval(() => this.processUnreadChats(), intervalMs);
}
stop(): void {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
/**
* Manually trigger processing (e.g., after a chat refresh)
*/
async triggerProcess(): Promise<void> {
if (!this.config.autoReply.enabled) return;
await this.processUnreadChats();
}
private async processUnreadChats(): Promise<void> {
if (this.processing) return;
this.processing = true;
try {
const chats = await this.chatService.getCachedConversations();
// Find conversations where:
// 1. The last message is NOT from us (they sent a message)
// 2. The conversation is marked as unread OR the last message is from them
// 3. We haven't already processed this state (using message snippet + time as key)
const unreadChats = chats.filter(chat => {
if (!chat.messages || chat.messages.length === 0) return false;
if (chat.replyStatus) return false; // Already processed (pending, upsell_detected, or sent)
// The definitive check: the last message in the actual history must be from THEM, not us
const lastMessage = chat.messages[chat.messages.length - 1];
if (lastMessage.isSelf) return false;
// Create a unique key for this conversation state
const stateKey = `${chat.username}:${chat.lastMessageTime}:${chat.lastMessageSnippet}`;
if (this.processedConversations.has(stateKey)) return false;
return true;
});
if (unreadChats.length === 0) {
this.logger.debug('Auto-reply: No unread conversations to process');
this.processing = false;
return;
}
this.logger.info(`Auto-reply: Processing ${unreadChats.length} unread conversation(s)...`);
for (const chat of unreadChats) {
try {
await this.processChat(chat);
} catch (error) {
this.logger.error(`Auto-reply: Error processing chat with ${chat.username}:`, error);
}
}
} catch (error) {
this.logger.error('Auto-reply: Error in processUnreadChats:', error);
} finally {
this.processing = false;
}
}
private async processChat(chat: ActiveChat): Promise<void> {
const stateKey = `${chat.username}:${chat.lastMessageTime}:${chat.lastMessageSnippet}`;
this.logger.info(`Auto-reply: Checking conversation with ${chat.username}...`);
// Safety gate: verify the last message is truly from them, not us
const lastMsg = chat.messages![chat.messages!.length - 1];
if (lastMsg.isSelf) {
this.logger.info(`Auto-reply: Skipping ${chat.username} — we sent the last message`);
this.processedConversations.add(stateKey);
return;
}
// Step 1: Check for upsell/spam
const upsellResult = this.aiClient.detectUpsell(
chat.messages!.map(m => ({ text: m.text, isSelf: m.isSelf })),
this.config.autoReply.upsellKeywords
);
if (upsellResult.isUpsell) {
this.logger.info(`Auto-reply: Upsell detected from ${chat.username} (keyword: "${upsellResult.matchedKeyword}")`);
chat.replyStatus = 'upsell_detected';
chat.pendingReply = undefined;
this.processedConversations.add(stateKey);
this.onUpsellDetected?.(chat, upsellResult.matchedKeyword!);
// Save the updated status to cache
await this.chatService.saveCacheExternal();
return;
}
// Step 2: Generate a proposed reply
this.logger.info(`Auto-reply: Generating reply for ${chat.username}...`);
const suggestedReply = await this.aiClient.generateReply(
chat.messages!.map(m => ({ sender: m.sender, text: m.text, isSelf: m.isSelf })),
chat.username
);
if (!suggestedReply) {
this.logger.warn(`Auto-reply: Failed to generate reply for ${chat.username}`);
this.processedConversations.add(stateKey);
return;
}
// Step 3: Auto-send or store as pending
if (this.config.autoReply.autoSend) {
if (chat.url) {
this.logger.info(`Auto-reply: Auto-sending reply to ${chat.username}: "${suggestedReply.slice(0, 80)}..."`);
const sent = await this.chatService.sendChatMessage(chat.url, suggestedReply);
if (sent) {
chat.replyStatus = 'sent';
chat.pendingReply = suggestedReply;
this.onAutoSent?.(chat);
// Refresh chats in background so cache reflects the sent message
this.chatService.refreshTopChats(5).catch(err =>
this.logger.error('Failed to refresh chats after auto-send:', err)
);
} else {
this.logger.error(`Auto-reply: Failed to send message to ${chat.username}`);
}
} else {
this.logger.warn(`Auto-reply: No chat URL for ${chat.username}, cannot auto-send`);
}
} else {
// Store as pending approval
chat.pendingReply = suggestedReply;
chat.replyStatus = 'pending';
this.logger.info(`Auto-reply: Reply pending approval for ${chat.username}: "${suggestedReply.slice(0, 80)}..."`);
this.onReplyGenerated?.(chat);
}
this.processedConversations.add(stateKey);
// Save the updated status to cache
await this.chatService.saveCacheExternal();
}
/**
* Clear the processed state for a conversation so it can be re-evaluated
*/
clearProcessedState(username: string): void {
const keysToRemove: string[] = [];
for (const key of this.processedConversations) {
if (key.startsWith(`${username}:`)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(k => this.processedConversations.delete(k));
}
}
+1 -1178
View File
File diff suppressed because it is too large. Load diff
+108
View File
@@ -0,0 +1,108 @@
import fs from 'fs/promises';
import path from 'path';
import type { Logger } from '../../utils/logger.js';
import type { ActiveChat, ChatCacheFile } from './types.js';
export interface SyncMarkers {
nextBatch: string | null;
lastFullSyncAt: number | null;
lastDeltaSyncAt: number | null;
}
/**
* In-memory + on-disk cache of Reddit chats plus the Matrix delta cursor
* so the bot can resume incremental sync across restarts.
*/
export class ChatCache {
private chats: ActiveChat[] = [];
private lastFetchTime = 0;
readonly cacheDurationMs = 60 * 1000;
private readonly filePath: string;
private nextBatch: string | null = null;
private lastFullSyncAt: number | null = null;
private lastDeltaSyncAt: number | null = null;
constructor(private logger: Logger, filePath?: string) {
this.filePath = filePath ?? path.join(process.cwd(), 'data', 'chat-cache.json');
}
get(): ActiveChat[] {
return this.chats;
}
set(chats: ActiveChat[]): void {
this.chats = chats;
this.lastFetchTime = Date.now();
}
isFresh(): boolean {
return Date.now() - this.lastFetchTime < this.cacheDurationMs && this.chats.length > 0;
}
getMarkers(): SyncMarkers {
return {
nextBatch: this.nextBatch,
lastFullSyncAt: this.lastFullSyncAt,
lastDeltaSyncAt: this.lastDeltaSyncAt,
};
}
setNextBatch(token: string | null): void {
this.nextBatch = token;
}
markFullSync(at: number = Date.now()): void {
this.lastFullSyncAt = at;
this.lastDeltaSyncAt = at;
}
markDeltaSync(at: number = Date.now()): void {
this.lastDeltaSyncAt = at;
}
async load(): Promise<void> {
try {
const raw = await fs.readFile(this.filePath, 'utf-8');
const parsed = JSON.parse(raw) as ChatCacheFile;
if (!parsed || !Array.isArray(parsed.chats)) {
this.logger.warn('Chat cache file is invalid, ignoring.');
return;
}
this.chats = parsed.chats;
this.lastFetchTime = Date.now();
this.nextBatch = parsed.nextBatch ?? null;
this.lastFullSyncAt = parsed.lastFullSyncAt ?? null;
this.lastDeltaSyncAt = parsed.lastDeltaSyncAt ?? null;
this.logger.info(
`Loaded ${parsed.chats.length} chats from cache` +
(this.lastFullSyncAt
? ` (last full sync ${new Date(this.lastFullSyncAt).toISOString()})`
: ' (no full sync recorded)'),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
this.logger.info('No chat cache found to load');
return;
}
this.logger.warn('Failed to load chat cache:', error);
}
}
async save(chats: ActiveChat[] = this.chats): Promise<void> {
const payload: ChatCacheFile = {
version: 1,
savedAt: Date.now(),
chats,
nextBatch: this.nextBatch,
lastFullSyncAt: this.lastFullSyncAt,
lastDeltaSyncAt: this.lastDeltaSyncAt,
};
try {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
await fs.writeFile(this.filePath, JSON.stringify(payload, null, 2), 'utf-8');
} catch (error) {
this.logger.warn('Failed to save chat cache:', error);
}
}
}
+24
View File
@@ -0,0 +1,24 @@
export { ChatService } from './service.js';
export type {
TokenRefresher,
UsernameResolver,
SyncStatus,
SyncProgress,
SyncPhase,
SyncProgressListener,
} from './service.js';
export type {
ActiveChat,
ChatMessage,
ChatMedia,
ChatCacheFile,
ScrapedChatMessage,
ScrapedRoom,
} from './types.js';
export { MatrixClient, MatrixAuthError } from './matrix/client.js';
export type { MatrixCredentials } from './matrix/types.js';
export {
extractMatrixCredentials,
loadCachedCredentials,
} from './matrix/token-extractor.js';
export { MatrixMediaDownloader } from './matrix/http-media-downloader.js';
+529
View File
@@ -0,0 +1,529 @@
import type { Logger } from '../../../utils/logger.js';
import type {
MatrixCredentials,
MatrixSyncResponse,
MatrixMessagesResponse,
MatrixTimelineEvent,
MatrixJoinedRoom,
} from './types.js';
export class MatrixAuthError extends Error {
constructor(message: string) {
super(message);
this.name = 'MatrixAuthError';
}
}
/**
* Thrown when Reddit's homeserver returns a 429 with a `retry_after_ms`
* longer than we're willing to block on. Caller (typically ChatService)
* is expected to record this as a cooldown and stop trying until the
* window expires.
*/
export class MatrixRateLimitError extends Error {
constructor(
message: string,
public retryAfterMs: number,
public errcode: string | null,
public redditCode: string | null,
) {
super(message);
this.name = 'MatrixRateLimitError';
}
}
export interface SyncOptions {
/** Pagination token from a previous /sync. Omit for initial sync. */
since?: string;
/** Long-poll timeout. Default 0 = return immediately. */
timeoutMs?: number;
/** Filter blob (JSON-encoded) — omitted by default. */
filter?: string;
}
export interface RoomSummary {
roomId: string;
/** Sorted member IDs other than ourselves; useful for DMs (1 entry). */
otherMembers: string[];
/** From summary.m.heroes or m.room.name event content. */
displayName: string | null;
unreadCount: number;
highlightCount: number;
lastEvent: MatrixTimelineEvent | null;
/** Raw timeline events from this sync — initial sync gives a small window. */
recentEvents: MatrixTimelineEvent[];
}
/**
* Cap on how long we'll sit and sleep waiting out a 429. Anything longer
* is surfaced as a `MatrixRateLimitError` so the main loop can back off
* cleanly instead of silently blocking for minutes.
*/
const LONG_RETRY_THRESHOLD_MS = 5_000;
export interface MatrixClientOptions {
/**
* Minimum interval (ms) between *outbound* HTTP requests, applied across
* all callers. Defaults to 30ms (~33 req/s) which keeps us comfortably
* under Reddit's homeserver rate limit even with several parallel workers.
*/
minRequestIntervalMs?: number;
/**
* Per-request HTTP timeout. Without this a stalled connection to the
* homeserver would hang the caller forever (see incident 2026-05-20).
* An aborted fetch is treated as a transient error and retried with
* the same backoff as a 5xx.
*/
requestTimeoutMs?: number;
/** Test seam: inject a custom fetch (e.g. one that hangs). */
fetchImpl?: typeof fetch;
}
/**
* Thin Matrix Client-Server v3 wrapper for the Reddit chat homeserver.
* No SDK dependency — just `fetch`. Talks the same protocol as
* matrix-js-sdk but only the few endpoints we need.
*/
export class MatrixClient {
private nextBatch: string | null = null;
private nextRequestSlot = 0;
private readonly minRequestIntervalMs: number;
private readonly requestTimeoutMs: number;
private readonly fetchImpl: typeof fetch;
constructor(
private creds: MatrixCredentials,
private logger: Logger,
options: MatrixClientOptions = {},
) {
this.minRequestIntervalMs = options.minRequestIntervalMs ?? 30;
this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
this.fetchImpl = options.fetchImpl ?? fetch;
}
/**
* Block until we're allowed to fire the next outbound HTTP request.
* Stamps `nextRequestSlot` atomically so concurrent acquirers get
* staggered, non-overlapping slots.
*/
private async acquireRequestSlot(): Promise<void> {
const now = Date.now();
const slot = Math.max(now, this.nextRequestSlot);
this.nextRequestSlot = slot + this.minRequestIntervalMs;
const wait = slot - now;
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
}
get userId(): string {
return this.creds.userId;
}
get homeserver(): string {
return this.creds.homeserver;
}
/** Replace the bearer (e.g. after a re-extract on 401). */
updateCredentials(creds: MatrixCredentials): void {
this.creds = creds;
this.nextBatch = null;
}
/**
* `fetch` with a hard client-side timeout. Returns the Response on success,
* or null if the request aborted — caller treats null as transient and
* retries with backoff.
*/
private async timedFetch(
url: string,
init: RequestInit,
): Promise<Response | null> {
try {
return await this.fetchImpl(url, {
...init,
signal: AbortSignal.timeout(this.requestTimeoutMs),
});
} catch (err) {
const name = (err as { name?: string }).name;
if (name === 'AbortError' || name === 'TimeoutError') {
this.logger.warn(
`[matrix] request to ${url} timed out after ${this.requestTimeoutMs}ms`,
);
return null;
}
throw err;
}
}
private async request<T>(
method: 'GET' | 'POST' | 'PUT',
path: string,
options: { query?: Record<string, string | number | undefined>; body?: unknown } = {},
): Promise<T> {
const url = new URL(this.creds.homeserver + path);
if (options.query) {
for (const [k, v] of Object.entries(options.query)) {
if (v !== undefined) url.searchParams.set(k, String(v));
}
}
const maxAttempts = 10;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
await this.acquireRequestSlot();
const res = await this.timedFetch(url.toString(), {
method,
headers: {
Authorization: `Bearer ${this.creds.accessToken}`,
'Content-Type': 'application/json',
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (res === null) {
// Aborted — retry with the same backoff as a 5xx.
if (attempt < maxAttempts) {
const backoff =
Math.min(2000, 200 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 150);
await new Promise((r) => setTimeout(r, backoff));
continue;
}
throw new Error(`Matrix ${method} ${path} timed out (exceeded ${maxAttempts} attempts)`);
}
if (res.status === 401 || res.status === 403) {
throw new MatrixAuthError(`Matrix ${method} ${path} → ${res.status} (token likely expired)`);
}
// Matrix-standard rate-limit response: { retry_after_ms } per spec.
if (res.status === 429) {
const body = (await res.json().catch(() => ({}))) as {
retry_after_ms?: number;
errcode?: string;
error?: string;
['com.reddit.error.code']?: string;
['com.reddit.error.msg']?: string;
};
const baseMs = body.retry_after_ms ?? 500;
// If the homeserver tells us to wait longer than this, don't block —
// bubble out so the caller can record a cooldown and skip sends.
// 5s covers a normal Matrix flood-control burst; anything bigger
// (e.g. the ~10 min invitation_limit_mature_account) is a real
// soft throttle we shouldn't sit on.
if (baseMs > LONG_RETRY_THRESHOLD_MS) {
const msg = `Matrix ${method} ${path} → 429 ${body['com.reddit.error.code'] ?? body.errcode ?? ''} retry_after=${baseMs}ms`;
throw new MatrixRateLimitError(
msg,
baseMs,
body.errcode ?? null,
body['com.reddit.error.code'] ?? null,
);
}
// Add tiny jitter so parallel workers don't wake in lockstep.
const waitMs = baseMs + Math.floor(Math.random() * 100);
if (attempt < maxAttempts) {
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
throw new Error(
`Matrix ${method} ${path} → 429 (exceeded ${maxAttempts} retries, last wait ${waitMs}ms)`,
);
}
// Transient upstream errors — Reddit's homeserver occasionally 502/504s
// under load. Retry with exponential backoff + jitter.
if (res.status >= 500 && res.status <= 599 && attempt < maxAttempts) {
const backoff = Math.min(2000, 200 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 150);
await new Promise((r) => setTimeout(r, backoff));
continue;
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Matrix ${method} ${path} → ${res.status}: ${text.slice(0, 300)}`);
}
return (await res.json()) as T;
}
// Unreachable, but TS doesn't know.
throw new Error(`Matrix ${method} ${path} exhausted retries`);
}
async whoami(): Promise<string> {
const res = await this.request<{ user_id: string }>('GET', '/_matrix/client/v3/account/whoami');
return res.user_id;
}
/**
* All rooms the user is currently joined to. Use this for backfill —
* `/sync` only returns rooms with recent activity.
*/
async getJoinedRooms(): Promise<string[]> {
const res = await this.request<{ joined_rooms: string[] }>(
'GET',
'/_matrix/client/v3/joined_rooms',
);
return res.joined_rooms;
}
/** Member map for a room (user_id → display name/avatar). */
async getJoinedMembers(
roomId: string,
): Promise<Record<string, { display_name?: string; avatar_url?: string }>> {
const res = await this.request<{
joined: Record<string, { display_name?: string; avatar_url?: string }>;
}>(
'GET',
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/joined_members`,
);
return res.joined;
}
/** Returns the room's m.room.name content if set, else null. */
async getRoomName(roomId: string): Promise<string | null> {
try {
const res = await this.request<{ name?: string }>(
'GET',
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name/`,
);
return res.name ?? null;
} catch (err) {
// 404 = no name event (normal for DMs)
if (err instanceof Error && err.message.includes('→ 404')) return null;
throw err;
}
}
async sync(options: SyncOptions = {}): Promise<MatrixSyncResponse> {
const since = options.since ?? this.nextBatch ?? undefined;
const res = await this.request<MatrixSyncResponse>('GET', '/_matrix/client/v3/sync', {
query: {
since,
timeout: options.timeoutMs ?? 0,
filter: options.filter,
},
});
this.nextBatch = res.next_batch;
return res;
}
/**
* Convenience: do a /sync and project the joined rooms into a flat list.
* For an initial sync (no `since`) Reddit returns each room with a small
* recent-event window — usually enough for "active chats" views.
*/
async listRooms(options: SyncOptions = {}): Promise<RoomSummary[]> {
const sync = await this.sync(options);
const joined = sync.rooms?.join ?? {};
const summaries: RoomSummary[] = [];
for (const [roomId, room] of Object.entries(joined)) {
summaries.push(this.summarizeRoom(roomId, room));
}
summaries.sort((a, b) => {
const at = a.lastEvent?.origin_server_ts ?? 0;
const bt = b.lastEvent?.origin_server_ts ?? 0;
return bt - at;
});
return summaries;
}
private summarizeRoom(roomId: string, room: MatrixJoinedRoom): RoomSummary {
const events = room.timeline?.events ?? [];
const lastMessage = [...events].reverse().find((e) => e.type === 'm.room.message') ?? null;
const lastEvent = events.length > 0 ? events[events.length - 1] : null;
const heroes = room.summary?.['m.heroes'] ?? [];
const otherMembers = heroes.filter((h) => h !== this.creds.userId);
let displayName: string | null = null;
const nameEvent = room.state?.events?.find((e) => e.type === 'm.room.name');
if (nameEvent && typeof nameEvent.content.name === 'string') {
displayName = nameEvent.content.name as string;
} else if (otherMembers.length > 0) {
displayName = otherMembers.map(stripMxid).join(', ');
}
return {
roomId,
otherMembers,
displayName,
unreadCount: room.unread_notifications?.notification_count ?? 0,
highlightCount: room.unread_notifications?.highlight_count ?? 0,
lastEvent: lastMessage ?? lastEvent,
recentEvents: events,
};
}
/**
* Paginate room history backwards. Pass the previous response's `end`
* token as `from` to keep walking.
*/
async getRoomMessages(
roomId: string,
options: { from?: string; limit?: number; dir?: 'b' | 'f' } = {},
): Promise<MatrixMessagesResponse> {
return this.request<MatrixMessagesResponse>(
'GET',
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages`,
{
query: {
dir: options.dir ?? 'b',
limit: options.limit ?? 50,
from: options.from,
},
},
);
}
/**
* Create a new Reddit DM room with the given recipient and return its
* room_id. Mirrors exactly what chat.reddit.com sends when you click
* "Start chat" in the UI:
*
* POST /_matrix/client/v3/createRoom
* { "preset": "reddit_dm", "invite": [mxid] }
*
* `reddit_dm` is a Reddit-specific preset that the homeserver expands
* server-side into the right power levels, history visibility, and
* `com.reddit.chat.type` state event.
*/
async createDirectRoom(recipientMxid: string): Promise<string> {
const res = await this.request<{ room_id: string }>('POST', '/_matrix/client/v3/createRoom', {
body: { preset: 'reddit_dm', invite: [recipientMxid] },
});
this.logger.debug(`[matrix] created DM room ${res.room_id} with ${recipientMxid}`);
return res.room_id;
}
/**
* One-shot: create a DM room with the recipient and send the opening
* message. Returns the new room_id and the event_id of the message.
*/
async sendInitialDirectMessage(
recipientMxid: string,
body: string,
): Promise<{ roomId: string; eventId: string }> {
const roomId = await this.createDirectRoom(recipientMxid);
const eventId = await this.sendTextMessage(roomId, body);
return { roomId, eventId };
}
/**
* Convert a Reddit t2 user id (e.g. "t2_abc123") into the Matrix user id
* Reddit's homeserver uses: "@t2_abc123:reddit.com".
*/
static t2ToMxid(t2Id: string): string {
const clean = t2Id.startsWith('t2_') ? t2Id : `t2_${t2Id}`;
return `@${clean}:reddit.com`;
}
/**
* Send a plain-text message. Returns the event_id.
* txnId must be unique per message to make sends idempotent on retry.
*/
async sendTextMessage(roomId: string, body: string, txnId?: string): Promise<string> {
const id = txnId ?? `rbot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const res = await this.request<{ event_id: string }>(
'PUT',
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(id)}`,
{
body: { msgtype: 'm.text', body },
},
);
this.logger.debug(`[matrix] sent message to ${roomId}: event_id=${res.event_id}`);
return res.event_id;
}
/**
* Convert an mxc:// URI to an HTTPS download URL on this homeserver.
* Reddit's media is served from the same Matrix server.
*
* Returns the *authenticated* v1 endpoint (Matrix v1.11+); use
* `downloadMedia()` to actually fetch with the right headers.
*/
mxcToHttp(mxc: string): string | null {
const parsed = parseMxc(mxc);
if (!parsed) return null;
return `${this.creds.homeserver}/_matrix/client/v1/media/download/${parsed.serverName}/${parsed.mediaId}`;
}
/**
* Download a media object referenced by an mxc:// URI.
*
* Tries the authenticated v1 endpoint first (current spec) and falls back
* to the unauthenticated v3 endpoint (legacy) on 404 — different Matrix
* server versions deploy these at different times.
*/
async downloadMedia(mxc: string): Promise<{ bytes: Buffer; contentType: string } | null> {
const parsed = parseMxc(mxc);
if (!parsed) return null;
const { serverName, mediaId } = parsed;
// Reddit's homeserver serves the legacy unauthenticated v3 endpoint;
// the modern authed v1 endpoint 404s. Try v3 first to save a round-trip
// per image; v1 stays as a fallback for when Reddit upgrades.
const candidates = [
{
url: `${this.creds.homeserver}/_matrix/media/v3/download/${serverName}/${mediaId}`,
authed: false,
},
{
url: `${this.creds.homeserver}/_matrix/client/v1/media/download/${serverName}/${mediaId}`,
authed: true,
},
];
for (const { url, authed } of candidates) {
const headers: Record<string, string> = {};
if (authed) headers['Authorization'] = `Bearer ${this.creds.accessToken}`;
const maxAttempts = 10;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
await this.acquireRequestSlot();
const res = await this.timedFetch(url, { headers });
if (res === null) {
if (attempt < maxAttempts) {
const backoff =
Math.min(2000, 200 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 150);
await new Promise((r) => setTimeout(r, backoff));
continue;
}
throw new Error(`Matrix media download ${url} timed out (exceeded ${maxAttempts} attempts)`);
}
if (res.status === 401 || res.status === 403) {
throw new MatrixAuthError(`Matrix media download → ${res.status} (token likely expired)`);
}
if (res.status === 429) {
const body = (await res.json().catch(() => ({}))) as { retry_after_ms?: number };
const waitMs = (body.retry_after_ms ?? 500) + Math.floor(Math.random() * 100);
if (attempt < maxAttempts) {
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
throw new Error(`Matrix media download → 429 after ${maxAttempts} retries`);
}
if (res.status === 404 || res.status === 405) break; // try next endpoint
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Matrix media download ${url} → ${res.status}: ${text.slice(0, 200)}`);
}
const arrayBuffer = await res.arrayBuffer();
return {
bytes: Buffer.from(arrayBuffer),
contentType: res.headers.get('content-type') ?? 'application/octet-stream',
};
}
}
return null;
}
}
function parseMxc(mxc: string): { serverName: string; mediaId: string } | null {
const m = mxc.match(/^mxc:\/\/([^/]+)\/(.+)$/);
if (!m) return null;
return { serverName: m[1], mediaId: m[2] };
}
/** "@t2_abc123:reddit.com" → "t2_abc123" */
function stripMxid(mxid: string): string {
const m = mxid.match(/^@([^:]+):/);
return m ? m[1] : mxid;
}
@@ -0,0 +1,44 @@
import type { Logger } from '../../../utils/logger.js';
import type { MediaDownloader, MediaDownloadResult } from '../../media.js';
import type { MatrixClient } from './client.js';
import { fetchWithTimeout } from '../../../utils/fetch-with-timeout.js';
/**
* `MediaDownloader` for the existing `MediaService` that knows how to fetch
* both raw HTTPS URLs (legacy chat snapshots, external links) and Matrix
* `mxc://` URIs. The latter go through `MatrixClient.downloadMedia` so they
* honour the same auth + rate-limiter machinery as the rest of the chat
* code path; nothing in the chat flow needs Playwright any more.
*/
export class MatrixMediaDownloader implements MediaDownloader {
constructor(
private matrix: MatrixClient,
private logger: Logger,
) {}
async fetch(url: string): Promise<MediaDownloadResult | null> {
try {
if (url.startsWith('mxc://')) {
const result = await this.matrix.downloadMedia(url);
if (!result) return null;
return { buffer: result.bytes, contentType: result.contentType };
}
// Reddit chat sometimes embeds direct https://matrix.redditspace.com
// URLs (e.g. /thumbnail/.../blur_*) — fall through to plain fetch.
const res = await fetchWithTimeout(url, { timeoutMs: 30_000 });
if (!res.ok) {
this.logger.warn(`Media download failed (${res.status}): ${url}`);
return null;
}
const ab = await res.arrayBuffer();
return {
buffer: Buffer.from(ab),
contentType: res.headers.get('content-type') ?? null,
};
} catch (error) {
this.logger.warn(`Media download error for ${url}:`, error);
return null;
}
}
}
+131
View File
@@ -0,0 +1,131 @@
import fs from 'fs';
import path from 'path';
import type { BrowserContext } from 'playwright';
import type { Logger } from '../../../utils/logger.js';
import type { MatrixCredentials } from './types.js';
import { fetchWithTimeout } from '../../../utils/fetch-with-timeout.js';
const DEFAULT_CACHE_PATH = path.resolve(process.cwd(), 'data/matrix-credentials.json');
/** Reddit chat homeserver. The bearer token is host-scoped to this origin. */
const MATRIX_HOST_RE = /matrix\.(redditspace|reddit)\.com$/i;
export interface TokenExtractorOptions {
/** Where to persist the captured token. */
cachePath?: string;
/** Max ms to wait for a Matrix-bound request to appear. */
timeoutMs?: number;
}
export async function loadCachedCredentials(
cachePath = DEFAULT_CACHE_PATH,
): Promise<MatrixCredentials | null> {
try {
if (!fs.existsSync(cachePath)) return null;
const raw = fs.readFileSync(cachePath, 'utf-8');
const parsed = JSON.parse(raw) as MatrixCredentials;
if (!parsed.accessToken || !parsed.homeserver || !parsed.userId) return null;
return parsed;
} catch {
return null;
}
}
export function saveCredentials(
creds: MatrixCredentials,
cachePath = DEFAULT_CACHE_PATH,
): void {
const dir = path.dirname(cachePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify(creds, null, 2), 'utf-8');
}
/**
* Sniffs the Matrix bearer token by opening chat.reddit.com in the supplied
* (already-logged-in) browser context and watching outbound requests for any
* call to the Matrix homeserver. The first request carrying an
* `Authorization: Bearer ...` header is captured.
*
* Requires the context to already hold a valid Reddit session — call this
* after BrowserClient.login() or with a persistent context that has cookies.
*/
export async function extractMatrixCredentials(
context: BrowserContext,
logger: Logger,
options: TokenExtractorOptions = {},
): Promise<MatrixCredentials> {
const timeoutMs = options.timeoutMs ?? 30_000;
const cachePath = options.cachePath ?? DEFAULT_CACHE_PATH;
const page = await context.newPage();
try {
let resolveCreds: (c: MatrixCredentials) => void;
let rejectCreds: (err: Error) => void;
const credsPromise = new Promise<MatrixCredentials>((resolve, reject) => {
resolveCreds = resolve;
rejectCreds = reject;
});
let done = false;
const onRequest = (request: import('playwright').Request) => {
if (done) return;
try {
const url = new URL(request.url());
if (!MATRIX_HOST_RE.test(url.hostname)) return;
const headers = request.headers();
const auth = headers['authorization'] || headers['Authorization'];
if (!auth || !auth.toLowerCase().startsWith('bearer ')) return;
const token = auth.slice('bearer '.length).trim();
if (!token) return;
const homeserver = `${url.protocol}//${url.host}`;
done = true;
logger.info(`[matrix] captured bearer token for ${homeserver}`);
resolveCreds({
homeserver,
accessToken: token,
userId: '',
capturedAt: Date.now(),
});
} catch {
// swallow malformed URLs
}
};
page.on('request', onRequest);
logger.info('[matrix] loading chat.reddit.com to capture access token...');
await page.goto('https://chat.reddit.com/', {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
const timeout = new Promise<MatrixCredentials>((_, reject) =>
setTimeout(
() => reject(new Error(`Matrix token capture timed out after ${timeoutMs}ms`)),
timeoutMs,
),
);
const captured = await Promise.race([credsPromise, timeout]);
// Resolve the user_id by calling /whoami so the caller knows who we are.
const whoamiRes = await fetchWithTimeout(
`${captured.homeserver}/_matrix/client/v3/account/whoami`,
{
headers: { Authorization: `Bearer ${captured.accessToken}` },
timeoutMs: 15_000,
},
);
if (whoamiRes.ok) {
const body = (await whoamiRes.json()) as { user_id?: string };
if (body.user_id) captured.userId = body.user_id;
} else {
logger.warn(`[matrix] whoami returned ${whoamiRes.status}; userId left blank`);
}
saveCredentials(captured, cachePath);
logger.info(`[matrix] credentials saved to ${cachePath} (user=${captured.userId || 'unknown'})`);
return captured;
} finally {
await page.close().catch(() => undefined);
}
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Minimal subset of Matrix Client-Server API response shapes that the
* Reddit chat client actually uses. Reddit runs its chat on a Matrix
* homeserver (typically matrix.redditspace.com), so the same API works.
*
* Spec: https://spec.matrix.org/v1.11/client-server-api/
*/
export interface MatrixCredentials {
/** Homeserver base URL, e.g. "https://matrix.redditspace.com" */
homeserver: string;
/** Bearer access token */
accessToken: string;
/** Matrix user ID, e.g. "@t2_abc123:reddit.com" */
userId: string;
/** When the credentials were captured (ms). Used for staleness checks. */
capturedAt: number;
}
export interface MatrixEventContent {
msgtype?: string;
body?: string;
/** mxc:// URI for image/file messages */
url?: string;
info?: {
mimetype?: string;
w?: number;
h?: number;
size?: number;
};
[k: string]: unknown;
}
export interface MatrixTimelineEvent {
event_id: string;
type: string;
sender: string;
origin_server_ts: number;
content: MatrixEventContent;
unsigned?: { age?: number };
}
export interface MatrixRoomTimeline {
events: MatrixTimelineEvent[];
limited?: boolean;
prev_batch?: string;
}
export interface MatrixRoomState {
events: MatrixTimelineEvent[];
}
export interface MatrixJoinedRoom {
timeline?: MatrixRoomTimeline;
state?: MatrixRoomState;
unread_notifications?: {
notification_count?: number;
highlight_count?: number;
};
summary?: {
'm.heroes'?: string[];
'm.joined_member_count'?: number;
'm.invited_member_count'?: number;
};
}
export interface MatrixSyncResponse {
next_batch: string;
rooms?: {
join?: Record<string, MatrixJoinedRoom>;
leave?: Record<string, unknown>;
invite?: Record<string, unknown>;
};
}
export interface MatrixMessagesResponse {
chunk: MatrixTimelineEvent[];
start: string;
end?: string;
}
export interface MatrixWhoamiResponse {
user_id: string;
device_id?: string;
}
+856
View File
@@ -0,0 +1,856 @@
import type { Logger } from '../../utils/logger.js';
import type { SuccessfulMessagesRepository } from '../../database/repositories.js';
import type { MediaService } from '../media.js';
import { ChatCache } from './cache.js';
import { MatrixClient, MatrixAuthError, MatrixRateLimitError } from './matrix/client.js';
import type { MatrixJoinedRoom, MatrixTimelineEvent } from './matrix/types.js';
import type { ActiveChat, ChatMessage, ScrapedChatMessage } from './types.js';
import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js';
const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000;
const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
export interface TokenRefresher {
/**
* Re-extract a fresh Matrix bearer token (e.g. by re-running the browser
* sniff against the persistent session) and apply it to the underlying
* client. Resolves when the client is ready to be retried.
*/
refresh(): Promise<void>;
}
/**
* Optional Reddit-API hook for resolving t2 IDs to real usernames. Without
* it, conversations show up as `t2_abc123` (stable but ugly). With it, the
* dashboard sees real names.
*/
export interface UsernameResolver {
/** Map t2 IDs (with or without `t2_` prefix) to Reddit usernames. */
resolveT2Ids(t2Ids: string[]): Promise<Record<string, string>>;
}
interface BuildChatOpts {
knownUsernames: Map<string, string>;
}
/**
* Public chat API. Internally a thin wrapper around `MatrixClient`:
*
* - `/sync` (delta cursor) drives `getActiveChats` / `refreshTopChats` —
* cheap, just-changed rooms only.
* - `/joined_rooms` + per-room `/messages` paginate every historical
* conversation when `fetchAllMessages` is called.
* - Sends use `PUT .../send/m.room.message/<txn>` directly — no UI.
*
* The public surface (`getActiveChats`, `sendChatMessage`, etc.) is
* preserved from the old Playwright implementation so callers (web routes,
* MediaSyncService) don't change.
*/
export type SyncPhase =
| 'starting'
| 'seeding'
| 'listing-rooms'
| 'resolving-usernames'
| 'loading-details'
| 'finalizing'
| 'done';
export interface SyncProgress {
kind: 'full' | 'delta';
phase: SyncPhase;
/** Number of items completed in the current phase (e.g. rooms processed). */
current: number;
/** Total items in the current phase, if known. */
total: number;
/** Optional label for the in-flight item (e.g. current room name). */
label?: string;
/** Wall-clock ms when the sync started — for elapsed-time display. */
startedAt: number;
}
export interface SyncStatus {
/** True if a sync is currently running. */
syncing: boolean;
/** Kind of sync currently running, if any. */
syncingKind: 'full' | 'delta' | null;
/** Wall-clock ms of the last successful full backfill, or null. */
lastFullSyncAt: number | null;
/** Wall-clock ms of the last delta sync, or null. */
lastDeltaSyncAt: number | null;
/** Has the bot ever completed a full sync on this machine? */
hasEverFullSynced: boolean;
/** Number of chats currently cached. */
cachedChats: number;
/** Live progress for the in-flight sync, if any. */
progress: SyncProgress | null;
}
/** `null` signals "sync ended" (clear progress UI). */
export type SyncProgressListener = (progress: SyncProgress | null) => void;
/** Fired when a chat we sent into first receives a reply from the other party. */
export type NewReplyListener = (info: {
username: string;
replySnippet: string;
}) => void;
/**
* Set when Reddit's homeserver tells us to stop creating new DMs for a while
* (e.g. the "Wow, you've created a lot of chats. Let's take a break." soft
* throttle, errcode `rate.invitation_limit_mature_account`).
*/
export interface InvitationCooldown {
/** Wall-clock ms when sends may resume. */
until: number;
/** Reddit-side error code, e.g. `rate.invitation_limit_mature_account`. */
redditCode: string | null;
/** Human-readable explanation suitable for the dashboard. */
reason: string;
/** Wall-clock ms when the cooldown was set. */
setAt: number;
}
export class ChatService {
private cache: ChatCache;
private queue: Promise<unknown> = Promise.resolve();
private readonly t2ToUsername = new Map<string, string>();
private syncingKind: 'full' | 'delta' | null = null;
private progress: SyncProgress | null = null;
private progressListener: SyncProgressListener | null = null;
private newReplyListener: NewReplyListener | null = null;
private invitationCooldown: InvitationCooldown | null = null;
constructor(
private matrix: MatrixClient,
private logger: Logger,
private successfulMessagesRepo?: SuccessfulMessagesRepository,
private mediaService?: MediaService,
private tokenRefresher?: TokenRefresher,
private usernameResolver?: UsernameResolver,
) {
this.cache = new ChatCache(logger);
}
// ------------ scheduling + auth helpers ------------
/** Serialize work so concurrent callers don't clobber cache state. */
private enqueue<T>(task: () => Promise<T>): Promise<T> {
const result = this.queue.then(() => task());
this.queue = result.catch(() => undefined);
return result;
}
/**
* Run a Matrix call; if it 401/403s, re-extract the bearer via the
* configured `TokenRefresher` and retry once. Single retry only — if the
* refresh itself fails, we propagate.
*/
private async withAuth<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (err) {
if (err instanceof MatrixAuthError && this.tokenRefresher) {
this.logger.warn('[chat] Matrix token rejected — refreshing via browser session');
await this.tokenRefresher.refresh();
return await fn();
}
throw err;
}
}
// ------------ public API ------------
async loadCache(): Promise<void> {
await this.cache.load();
for (const chat of this.cache.get()) {
const t2 = this.extractT2(chat);
if (t2 && chat.username && !chat.username.startsWith('t2_')) {
this.t2ToUsername.set(t2, chat.username);
}
}
}
async saveCacheExternal(): Promise<void> {
await this.cache.save();
}
async getCachedConversations(): Promise<ActiveChat[]> {
if (this.cache.get().length > 0) return this.cache.get();
await this.cache.load();
return this.cache.get();
}
/**
* Returns chats with activity in the last 96h. Uses the persisted
* `next_batch` cursor — first call is essentially the initial sync,
* subsequent calls are delta-only.
*/
async getActiveChats(): Promise<ActiveChat[]> {
return this.enqueue(async () => {
if (this.cache.isFresh()) return this.cache.get();
this.beginSync('delta');
try {
this.updateProgress({ phase: 'starting', current: 0, total: 0 });
const merged = await this.syncOnce();
const cutoff = Date.now() - ACTIVE_WINDOW_MS;
const active = merged.filter((c) => c.lastMessageTime >= cutoff);
this.cache.set(active);
await this.cache.save(merged);
this.logger.info(`[chat] sync → ${merged.length} known rooms, ${active.length} active`);
this.updateProgress({ phase: 'done', current: merged.length, total: merged.length });
return active;
} finally {
this.endSync();
}
});
}
/**
* Incremental refresh — calls /sync once and applies the deltas to the
* cache. The `limit` arg is preserved for API compatibility but Matrix
* already returns only rooms with new events, so it's a no-op.
*/
async refreshTopChats(_limit = 5): Promise<void> {
await this.enqueue(async () => {
this.beginSync('delta');
try {
this.updateProgress({ phase: 'starting', current: 0, total: 0 });
await this.syncOnce();
this.logger.info('[chat] incremental sync complete');
this.updateProgress({
phase: 'done',
current: this.cache.get().length,
total: this.cache.get().length,
});
} catch (error) {
this.logger.error('[chat] refreshTopChats failed:', error);
} finally {
this.endSync();
}
});
}
/**
* Full backfill — walks every room via /joined_rooms and paginates each
* one's full history. Slow but rare; intended for "rebuild the cache from
* scratch" or initial provisioning.
*/
async fetchAllMessages(): Promise<ActiveChat[]> {
return this.enqueue(async () => {
this.beginSync('full');
try {
this.logger.info('[chat] full backfill starting (via /joined_rooms)...');
const t0 = Date.now();
this.updateProgress({ phase: 'seeding', current: 0, total: 0 });
// Seed display-name hints from one /sync (heroes / m.room.name).
const seed = await this.withAuth(() => this.matrix.sync({ timeoutMs: 0 }));
this.cache.setNextBatch(seed.next_batch);
const seedJoined = seed.rooms?.join ?? {};
this.updateProgress({ phase: 'listing-rooms', current: 0, total: 0 });
const joinedIds = await this.withAuth(() => this.matrix.getJoinedRooms());
this.logger.info(`[chat] backfill: ${joinedIds.length} joined rooms`);
this.updateProgress({
phase: 'resolving-usernames',
current: 0,
total: joinedIds.length,
});
const knownUsernames = await this.resolveUsernamesForRooms(joinedIds);
const chats: ActiveChat[] = [];
for (const roomId of joinedIds) {
try {
const room = seedJoined[roomId];
const chat = this.roomToActiveChat(roomId, room, { knownUsernames });
chats.push(chat);
} catch (err) {
this.logger.warn(`[chat] backfill: failed to seed ${roomId}: ${(err as Error).message}`);
}
}
let done = 0;
for (const chat of chats) {
this.updateProgress({
phase: 'loading-details',
current: done,
total: chats.length,
label: chat.roomName || chat.username,
});
try {
await this.loadDetails(chat);
} catch (err) {
this.logger.error(`[chat] backfill: failed details for ${chat.roomName}`, err);
}
done++;
}
this.updateProgress({
phase: 'finalizing',
current: chats.length,
total: chats.length,
});
chats.sort((a, b) => b.lastMessageTime - a.lastMessageTime);
this.cache.set(chats);
this.cache.markFullSync();
await this.cache.save(chats);
this.logger.info(
`[chat] full backfill done in ${Math.round((Date.now() - t0) / 1000)}s — ${chats.length} chats`,
);
this.updateProgress({ phase: 'done', current: chats.length, total: chats.length });
return chats;
} catch (error) {
this.logger.error('[chat] fetchAllMessages failed:', error);
return this.cache.get();
} finally {
this.endSync();
}
});
}
/**
* Decide whether to run a full backfill or just a delta sync based on
* whether the bot has ever completed a full sync on this machine.
* Called once on bootstrap.
*/
async runStartupSync(): Promise<{ kind: 'full' | 'delta'; chats: number }> {
const { lastFullSyncAt } = this.cache.getMarkers();
if (!lastFullSyncAt || this.cache.get().length === 0) {
this.logger.info('[chat] startup: no prior full sync recorded → running full backfill');
const chats = await this.fetchAllMessages();
return { kind: 'full', chats: chats.length };
}
this.logger.info(
`[chat] startup: full sync was ${new Date(lastFullSyncAt).toISOString()} → running delta`,
);
const chats = await this.getActiveChats();
return { kind: 'delta', chats: chats.length };
}
/** Lightweight status for the dashboard's sync controls. */
getSyncStatus(): SyncStatus {
const markers = this.cache.getMarkers();
return {
syncing: this.syncingKind !== null,
syncingKind: this.syncingKind,
lastFullSyncAt: markers.lastFullSyncAt,
lastDeltaSyncAt: markers.lastDeltaSyncAt,
hasEverFullSynced: !!markers.lastFullSyncAt,
cachedChats: this.cache.get().length,
progress: this.progress,
};
}
/** Subscribe to live sync-progress updates. Single listener; replaces any prior one. */
setProgressListener(listener: SyncProgressListener | null): void {
this.progressListener = listener;
}
/** Subscribe to new-reply notifications (fires once per chat the first time it gets a reply). */
setNewReplyListener(listener: NewReplyListener | null): void {
this.newReplyListener = listener;
}
private beginSync(kind: 'full' | 'delta'): void {
this.syncingKind = kind;
this.progress = {
kind,
phase: 'starting',
current: 0,
total: 0,
startedAt: Date.now(),
};
this.emitProgress();
}
private updateProgress(patch: Partial<Omit<SyncProgress, 'kind' | 'startedAt'>>): void {
if (!this.progress) return;
this.progress = { ...this.progress, ...patch };
this.emitProgress();
}
private endSync(): void {
this.syncingKind = null;
this.progress = null;
if (this.progressListener) {
try {
this.progressListener(null);
} catch (err) {
this.logger.debug('progress listener error:', err);
}
}
}
private emitProgress(): void {
if (!this.progress || !this.progressListener) return;
try {
this.progressListener(this.progress);
} catch (err) {
this.logger.debug('progress listener error:', err);
}
}
/**
* Load full message history (+ media) for a single chat. Mutates
* `chat.messages`.
*/
async loadConversationDetails(chat: ActiveChat): Promise<void> {
return this.loadDetails(chat);
}
/**
* Send a reply into an existing room. Accepts either a full
* `https://chat.reddit.com/room/<roomId>` URL (web UI compatibility) or
* a raw Matrix room ID like `!abc:reddit.com`.
*/
async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise<boolean> {
const active = this.getInvitationCooldown();
if (active) {
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
this.logger.warn(
`[chat] skipping sendChatMessage — send cooldown active for ${secondsLeft}s (${active.redditCode ?? 'rate-limited'})`,
);
return false;
}
return this.enqueue(async () => {
try {
const roomId = ChatService.extractRoomId(chatUrlOrRoomId);
if (!roomId) {
this.logger.error(`[chat] cannot extract room ID from "${chatUrlOrRoomId}"`);
return false;
}
const eventId = await this.withAuth(() => this.matrix.sendTextMessage(roomId, message));
this.logger.info(`[chat] sent message to ${roomId} (event_id=${eventId})`);
const chat = this.cache.get().find((c) => c.roomId === roomId);
if (chat) {
chat.lastMessageSnippet = message.slice(0, 200);
chat.lastMessageTime = Date.now();
chat.isFromUs = true;
}
return true;
} catch (error) {
if (error instanceof MatrixRateLimitError) {
this.setInvitationCooldown(error);
return false;
}
this.logger.error('[chat] sendChatMessage failed:', error);
return false;
}
});
}
/**
* Start a brand-new DM with the given Reddit user and send the opening
* line. Returns the new chat (or null on failure).
*/
async startConversation(username: string, message: string): Promise<ActiveChat | null> {
const active = this.getInvitationCooldown();
if (active) {
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
this.logger.warn(
`[chat] skipping startConversation(${username}) — invitation cooldown active for ${secondsLeft}s (${active.redditCode ?? 'rate-limited'})`,
);
return null;
}
return this.enqueue(async () => {
try {
const t2Id = await this.resolveUsernameToT2(username);
if (!t2Id) {
this.logger.error(`[chat] cannot start conversation: unknown user "${username}"`);
return null;
}
const mxid = MatrixClient.t2ToMxid(t2Id);
const { roomId, eventId } = await this.withAuth(() =>
this.matrix.sendInitialDirectMessage(mxid, message),
);
this.logger.info(
`[chat] started conversation with ${username} (${t2Id}) → room=${roomId} event=${eventId}`,
);
this.t2ToUsername.set(t2Id.replace(/^t2_/, ''), username);
const chat: ActiveChat = {
username,
roomName: username,
lastMessageTime: Date.now(),
lastMessageSnippet: message.slice(0, 200),
isFromUs: true,
unread: false,
url: ROOM_URL_PREFIX + encodeURIComponent(roomId),
roomId,
messages: [
{ sender: 'Me', text: message, timestamp: Date.now(), isSelf: true },
],
originMessage: message,
};
const existing = this.cache.get();
const merged = [chat, ...existing.filter((c) => c.roomId !== roomId)];
this.cache.set(merged);
await this.cache.save(merged);
return chat;
} catch (error) {
if (error instanceof MatrixRateLimitError) {
this.setInvitationCooldown(error);
return null;
}
this.logger.error('[chat] startConversation failed:', error);
return null;
}
});
}
/**
* Matrix sends are authoritative — the event_id in the PUT response
* confirms delivery. Kept on the public API for compatibility with the
* old Playwright sender; always returns true.
*/
async verifyMessageSent(_recipient: string, _messageText: string): Promise<boolean> {
return true;
}
/**
* Legacy debug hook. There's no DOM to inspect on the Matrix backend;
* returns a small status payload so any UI still wired to this endpoint
* stops 500-ing.
*/
async debugInspectDom(_chatUrl: string): Promise<unknown> {
return {
backend: 'matrix',
note: 'DOM inspection is not available on the Matrix backend — chat data is sourced from the Reddit Matrix homeserver API directly.',
};
}
/** Public so bootstrap can swap in a fresh client after token refresh. */
setMatrixClient(client: MatrixClient): void {
this.matrix = client;
}
/**
* Active invitation cooldown (i.e. Reddit's "you've created a lot of chats,
* take a break" throttle), or null if we're clear to send. Auto-expires
* by wall clock — callers don't have to clear it manually.
*/
getInvitationCooldown(): InvitationCooldown | null {
if (!this.invitationCooldown) return null;
if (Date.now() >= this.invitationCooldown.until) {
this.invitationCooldown = null;
return null;
}
return this.invitationCooldown;
}
/** Record a cooldown after a `MatrixRateLimitError`. Test seam + reuse. */
setInvitationCooldown(error: MatrixRateLimitError): void {
const now = Date.now();
// Add a small safety margin so we don't probe again the very second
// the homeserver thinks the window expired.
const margin = 5_000;
this.invitationCooldown = {
until: now + error.retryAfterMs + margin,
redditCode: error.redditCode,
reason:
error.redditCode === 'rate.invitation_limit_mature_account'
? "Reddit is throttling new chat invitations (\"you've created a lot of chats, take a break\")."
: `Matrix rate-limited: ${error.errcode ?? '429'}`,
setAt: now,
};
const minutes = Math.ceil(error.retryAfterMs / 60_000);
this.logger.warn(
`[chat] invitation cooldown set: ${this.invitationCooldown.reason} (~${minutes}m, code=${error.redditCode ?? error.errcode ?? '429'})`,
);
}
// ------------ internals ------------
/** Single delta /sync that updates cache + nextBatch and returns the union. */
private async syncOnce(): Promise<ActiveChat[]> {
const sync = await this.withAuth(() =>
this.matrix.sync({ since: this.cache.getMarkers().nextBatch ?? undefined, timeoutMs: 0 }),
);
this.cache.setNextBatch(sync.next_batch);
const joined = sync.rooms?.join ?? {};
const existing = new Map(this.cache.get().map((c) => [c.roomId!, c] as const));
const knownUsernames = await this.resolveUsernamesForRooms(Object.keys(joined));
for (const [roomId, room] of Object.entries(joined)) {
const prev = existing.get(roomId);
const next = this.roomToActiveChat(roomId, room, { knownUsernames }, prev);
existing.set(roomId, next);
}
const merged = Array.from(existing.values()).sort(
(a, b) => b.lastMessageTime - a.lastMessageTime,
);
this.cache.set(merged);
this.cache.markDeltaSync();
await this.cache.save(merged);
return merged;
}
private async loadDetails(chat: ActiveChat): Promise<void> {
if (!chat.roomId) {
this.logger.warn(`[chat] no roomId for ${chat.roomName}, skipping details`);
return;
}
const events: MatrixTimelineEvent[] = [];
let from: string | undefined = undefined;
for (let i = 0; i < 50; i++) {
const res = await this.withAuth(() =>
this.matrix.getRoomMessages(chat.roomId!, { from, limit: 100, dir: 'b' }),
);
events.push(...res.chunk);
if (!res.end || res.end === from || res.chunk.length === 0) break;
from = res.end;
}
// Pagination yields newest-first; flip to chronological.
const messages = events.reverse().filter((e) => e.type === 'm.room.message');
const scraped: ScrapedChatMessage[] = messages.map((e) => {
const body = (e.content.body as string | undefined) ?? '';
const mxc = typeof e.content.url === 'string' ? (e.content.url as string) : null;
return {
sender: e.sender === this.matrix.userId ? 'Me' : this.shortenSender(e.sender),
text: body || (mxc ? '[Image]' : ''),
timestamp: e.origin_server_ts,
isSelf: e.sender === this.matrix.userId,
mediaUrls: mxc ? [mxc] : [],
};
});
// Backfill the recipient from message senders BEFORE attachMedia stamps
// it into media records. /sync's heroes + state.events miss inactive
// rooms; the senders of historical messages don't.
if (!chat.username || chat.username === 'unknown' || chat.username.startsWith('@')) {
const firstOther = messages.find((e) => e.sender !== this.matrix.userId);
if (firstOther) {
const t2 = firstOther.sender.match(/^@(t2_[^:]+):/)?.[1];
if (t2) {
const resolved =
this.t2ToUsername.get(t2.replace(/^t2_/, '')) ?? t2;
chat.username = resolved;
if (!chat.roomName || chat.roomName === 'unknown') chat.roomName = resolved;
}
}
}
chat.messages = this.mediaService
? await this.mediaService.attachMedia(chat, scraped)
: scraped.map(({ mediaUrls: _ignored, ...m }) => m as ChatMessage);
if (chat.messages.length === 0) {
this.logger.debug(`[chat] no messages in ${chat.roomName}`);
return;
}
chat.lastMessageSnippet = chat.messages[chat.messages.length - 1].text;
chat.lastMessageTime = chat.messages[chat.messages.length - 1].timestamp;
const firstSelf = chat.messages.find((m) => m.isSelf);
if (firstSelf) chat.originMessage = firstSelf.text;
const hasReply = chat.messages.some((m) => !m.isSelf);
if (firstSelf && hasReply && this.successfulMessagesRepo) {
if (!this.successfulMessagesRepo.existsByMessage(firstSelf.text)) {
const firstReply = chat.messages.find((m) => !m.isSelf);
try {
this.successfulMessagesRepo.insert({
sentMessageId: null,
originalMessage: firstSelf.text,
imageAnalysis: null,
postTitle: `Chat with ${chat.username}`,
responseSnippet: firstReply?.text.slice(0, 200) || null,
subreddit: null,
});
this.logger.info(`[chat] stored successful chat example from ${chat.username}`);
try {
this.newReplyListener?.({
username: chat.username,
replySnippet: firstReply?.text.slice(0, 200) ?? '',
});
} catch (err) {
this.logger.warn('[chat] newReplyListener threw:', err);
}
} catch (err) {
this.logger.warn('[chat] failed to store successful chat example:', err);
}
}
}
}
/**
* Project a Matrix room (from /sync) into an ActiveChat. If `prev` is
* supplied, fields it carries (messages, pendingReply, originMessage)
* are preserved.
*/
private roomToActiveChat(
roomId: string,
room: MatrixJoinedRoom | undefined,
opts: BuildChatOpts,
prev?: ActiveChat,
): ActiveChat {
const timeline = room?.timeline?.events ?? [];
const lastMessage = [...timeline].reverse().find((e) => e.type === 'm.room.message');
// Prefer summary.m.heroes (cheap), but fall back to scanning the room's
// member-join state events — Reddit often omits heroes for older rooms
// even though the recipient is in state.events as an m.room.member join.
const heroes = (room?.summary?.['m.heroes'] ?? []).filter((h) => h !== this.matrix.userId);
const memberJoins: string[] = [];
for (const ev of room?.state?.events ?? []) {
if (ev.type !== 'm.room.member') continue;
const stateKey = (ev as { state_key?: string }).state_key;
const membership = (ev.content as { membership?: string }).membership;
if (membership === 'join' && stateKey && stateKey !== this.matrix.userId) {
memberJoins.push(stateKey);
}
}
const candidateMxids = [...new Set([...heroes, ...memberJoins])];
const otherT2 = candidateMxids
.map((h) => h.match(/^@(t2_[^:]+):/)?.[1])
.filter((s): s is string => !!s);
let username = prev?.username ?? null;
if (!username || username.startsWith('t2_') || username.startsWith('@')) {
const resolved = otherT2
.map((id) => opts.knownUsernames.get(id.replace(/^t2_/, '')))
.find((x): x is string => !!x);
if (resolved) username = resolved;
else if (otherT2[0]) username = otherT2[0];
}
if (!username) username = prev?.username ?? 'unknown';
const nameEvent = room?.state?.events?.find((e) => e.type === 'm.room.name');
let roomName = prev?.roomName ?? username;
if (nameEvent && typeof nameEvent.content.name === 'string' && nameEvent.content.name) {
roomName = nameEvent.content.name as string;
} else if (!prev) {
roomName = username;
}
let lastMessageTime = prev?.lastMessageTime ?? 0;
let lastMessageSnippet = prev?.lastMessageSnippet ?? '';
let isFromUs = prev?.isFromUs ?? false;
if (lastMessage) {
lastMessageTime = lastMessage.origin_server_ts;
const body = (lastMessage.content.body as string | undefined) ?? '';
lastMessageSnippet = body || (lastMessage.content.url ? '[Image]' : '');
isFromUs = lastMessage.sender === this.matrix.userId;
}
const unread = (room?.unread_notifications?.notification_count ?? 0) > 0;
return {
...(prev ?? {}),
username,
roomName,
lastMessageTime,
lastMessageSnippet,
isFromUs,
unread,
url: ROOM_URL_PREFIX + encodeURIComponent(roomId),
roomId,
};
}
/** Returns the t2 id portion of a stored `t2_xxx` username. */
private extractT2(chat: ActiveChat): string | null {
const m = chat.username?.match(/^t2_(.+)$/);
return m ? m[1] : null;
}
/** Convert `@t2_abc:reddit.com` → `t2_abc`. */
private shortenSender(mxid: string): string {
const m = mxid.match(/^@([^:]+):/);
return m ? m[1] : mxid;
}
/**
* Best-effort batch resolution of t2 IDs to usernames. Uses the injected
* `UsernameResolver` (if any); failures are logged and ignored, with the
* t2 ID staying as the fallback display.
*/
private async resolveUsernamesForRooms(_roomIds: string[]): Promise<Map<string, string>> {
// We don't have the recipient mxids until rooms are projected, so this
// method returns the in-memory cache and lets `roomToActiveChat` fall
// back to the t2 ID. Resolution happens lazily via `enrichUsernames()`
// which the caller can invoke after building the chat list.
return new Map(this.t2ToUsername);
}
/**
* After a sync, look up real usernames for any t2-only entries in the
* cache. Best-effort; safe to await or fire-and-forget.
*/
async enrichUsernames(): Promise<void> {
if (!this.usernameResolver) return;
const needed: string[] = [];
for (const chat of this.cache.get()) {
const t2 = this.extractT2(chat);
if (t2 && !this.t2ToUsername.has(t2)) needed.push(`t2_${t2}`);
}
if (needed.length === 0) return;
try {
const got = await this.usernameResolver.resolveT2Ids(needed);
let updated = 0;
for (const [t2, name] of Object.entries(got)) {
const bare = t2.replace(/^t2_/, '');
this.t2ToUsername.set(bare, name);
for (const chat of this.cache.get()) {
if (chat.username === `t2_${bare}`) {
chat.username = name;
chat.roomName = chat.roomName?.startsWith('t2_') ? name : chat.roomName;
updated++;
}
}
}
if (updated > 0) {
this.logger.info(`[chat] enriched ${updated} chats with usernames`);
await this.cache.save();
}
} catch (err) {
this.logger.debug('[chat] enrichUsernames failed:', err);
}
}
/**
* Look up the t2 ID for a Reddit username via the standard about.json
* endpoint. Returns `t2_xxx` or null.
*/
private async resolveUsernameToT2(username: string): Promise<string | null> {
try {
const res = await fetchWithTimeout(
`https://www.reddit.com/user/${encodeURIComponent(username)}/about.json`,
{ headers: { 'User-Agent': 'reddit-bot-chat/1.0' }, timeoutMs: 15_000 },
);
if (!res.ok) {
this.logger.warn(`[chat] /user/${username}/about.json → ${res.status}`);
return null;
}
const json = (await res.json()) as { data?: { id?: string } };
const id = json.data?.id;
if (!id) return null;
return id.startsWith('t2_') ? id : `t2_${id}`;
} catch (err) {
this.logger.error(`[chat] failed to resolve username "${username}":`, err);
return null;
}
}
/** Parse a chat URL like `…/room/!abc%3Areddit.com` → `!abc:reddit.com`. */
static extractRoomId(chatUrlOrRoomId: string): string | null {
if (chatUrlOrRoomId.startsWith('!')) return chatUrlOrRoomId;
const m = chatUrlOrRoomId.match(/\/room\/([^/?#]+)/);
if (!m) return null;
try {
return decodeURIComponent(m[1]);
} catch {
return null;
}
}
}
+51
View File
@@ -0,0 +1,51 @@
export interface ChatMessage {
sender: string;
text: string;
timestamp: number;
isSelf: boolean;
media?: ChatMedia[];
}
export interface ChatMedia {
originalUrl: string;
localPath: string;
fileName: string;
}
export interface ActiveChat {
username: string;
roomName: string;
lastMessageTime: number;
lastMessageSnippet: string;
isFromUs: boolean;
unread: boolean;
url?: string;
roomId?: string;
messages?: ChatMessage[];
originMessage?: string;
}
export interface ChatCacheFile {
version: number;
savedAt: number;
chats: ActiveChat[];
/** Matrix `next_batch` cursor — present after any successful sync. */
nextBatch?: string | null;
/** Wall-clock timestamp of the last successful full backfill. Null/missing
* → the bot has never done a full sync on this machine, so startup
* should run one. */
lastFullSyncAt?: number | null;
/** Timestamp of the most recent delta sync. */
lastDeltaSyncAt?: number | null;
}
export type ScrapedChatMessage = Omit<ChatMessage, 'media'> & { mediaUrls: string[] };
export interface ScrapedRoom {
href: string;
ariaLabel: string | null;
roomName: string | null;
lastMessage: string | null;
timeText: string | null;
unread: boolean;
}
+4 -3
View File
@@ -57,9 +57,10 @@ export class MediaService {
constructor(
private downloader: MediaDownloader,
private logger: Logger
private logger: Logger,
mediaRoot?: string,
) {
this.mediaRoot = path.join(process.cwd(), 'data', 'media');
this.mediaRoot = mediaRoot ?? path.join(process.cwd(), 'data', 'media');
this.indexPath = path.join(this.mediaRoot, 'index.json');
}
@@ -84,7 +85,7 @@ export class MediaService {
async getMediaItems(): Promise<MediaRecord[]> {
await this.loadIndex();
return (this.index?.items || []).slice().sort((a, b) => b.createdAt - a.createdAt);
return (this.index?.items || []).slice().sort((a, b) => b.timestamp - a.timestamp);
}
private async downloadMessageMedia(chat: ActiveChat, message: ScrapedChatMessage): Promise<ChatMedia[]> {
+75 -13
View File
@@ -2,25 +2,32 @@ import type { RedditApiClient, RedditPost } from '../reddit/api.js';
import type { PostsRepository, MessagesRepository } from '../database/repositories.js';
import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js';
import { parseGenderTag } from '../utils/gender-tag.js';
export class MonitorService {
// Flips on each call so that when both pools have candidates the bot
// alternates between image and chat instead of always favouring whichever
// type the random shuffle put first.
private chatGoesFirst = false;
constructor(
private redditApi: RedditApiClient,
private postsRepo: PostsRepository,
private messagesRepo: MessagesRepository,
private config: Config,
private logger: Logger
private logger: Logger,
private delayBetweenFetchesMs: number = 1000,
) { }
async getUnprocessedPosts(): Promise<RedditPost[]> {
// Create a shuffled copy of subreddits to check
const subreddits = [...this.config.reddit.subreddits];
this.shuffleArray(subreddits);
const subreddits = this.buildCheckOrder();
const targetGenders = this.config.reddit.targetGenders;
for (const subreddit of subreddits) {
try {
this.logger.debug(`Checking r/${subreddit} for new posts...`);
const posts = await this.redditApi.getNewPosts(subreddit);
this.logger.debug(`Checking r/${subreddit.name} (${subreddit.type}) for new posts...`);
const posts = await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type);
// Filter for valid, unprocessed posts
const validPosts: RedditPost[] = [];
@@ -34,11 +41,28 @@ export class MonitorService {
continue;
}
// Chat subs almost always have a self-declared gender tag in the
// title/body (e.g. [25F], M4F). Drop posts that don't match the
// configured targetGenders here so they never enter the processor.
if (subreddit.type === 'chat' && targetGenders.length > 0) {
const tag = parseGenderTag(`${post.title}\n${post.selftext ?? ''}`);
if (!tag) {
this.logger.debug(`Skipping chat post ${post.id} - no gender tag in title/body`);
continue;
}
if (!targetGenders.includes(tag)) {
this.logger.debug(
`Skipping chat post ${post.id} - gender tag "${tag}" not in target list [${targetGenders.join(', ')}]`,
);
continue;
}
}
validPosts.push(post);
}
if (validPosts.length > 0) {
this.logger.info(`Found ${validPosts.length} new posts in r/${subreddit}`);
this.logger.info(`Found ${validPosts.length} new posts in r/${subreddit.name}`);
// Sort by creation time (newest first)
validPosts.sort((a, b) => b.created_utc - a.created_utc);
@@ -49,21 +73,59 @@ export class MonitorService {
return [validPosts[0]];
}
} catch (error) {
this.logger.error(`Failed to fetch posts from r/${subreddit}:`, error);
this.logger.error(`Failed to fetch posts from r/${subreddit.name}:`, error);
}
// Small delay between subreddit fetches to be nice to Reddit
await this.sleep(1000);
if (this.delayBetweenFetchesMs > 0) {
await this.sleep(this.delayBetweenFetchesMs);
}
}
return [];
}
private shuffleArray<T>(array: T[]): void {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
/**
* Build an interleaved check order that balances image and chat subs.
*
* Each pool is shuffled independently, then zipped together so a chat sub
* is checked roughly every other position even when there are far more
* image subs. `chatGoesFirst` flips per call so neither type is
* permanently advantaged when both pools have fresh posts.
*/
private buildCheckOrder(): Config['reddit']['subreddits'] {
const images = this.weightedShuffle(
this.config.reddit.subreddits.filter((s) => s.type === 'image'),
);
const chats = this.weightedShuffle(
this.config.reddit.subreddits.filter((s) => s.type === 'chat'),
);
const [a, b] = this.chatGoesFirst ? [chats, images] : [images, chats];
this.chatGoesFirst = !this.chatGoesFirst;
const out: Config['reddit']['subreddits'] = [];
const max = Math.max(a.length, b.length);
for (let i = 0; i < max; i++) {
if (i < a.length) out.push(a[i]);
if (i < b.length) out.push(b[i]);
}
return out;
}
// Weighted random ordering (Efraimidis–Spirakis): key = random^(1/weight),
// sorted descending. Heavier-weighted subs are more likely to land earlier
// each cycle, while every sub still has a non-zero chance of any position.
private weightedShuffle<T extends { weight?: number }>(items: T[]): T[] {
return items
.map((item) => {
const w = item.weight && item.weight > 0 ? item.weight : 1;
const r = Math.random();
const key = r === 0 ? -Infinity : Math.log(r) / w;
return { item, key };
})
.sort((a, b) => b.key - a.key)
.map(({ item }) => item);
}
private sleep(ms: number): Promise<void> {
+100 -13
View File
@@ -3,7 +3,8 @@ import type { OpenRouterClient, DetectedGender } from '../ai/openrouter.js';
import type { Logger } from '../utils/logger.js';
import type { Config } from '../config/index.js';
import type { StrategySelector, SelectedStrategy } from './strategy-selector.js';
import { downloadImage, bufferToBase64, isValidImageMimeType } from '../utils/image.js';
import { downloadImage, bufferToBase64, isValidImageMimeType, type DownloadedImage } from '../utils/image.js';
import { parseGenderTag } from '../utils/gender-tag.js';
export interface ProcessedPost {
post: RedditPost;
@@ -13,16 +14,33 @@ export interface ProcessedPost {
strategyId: string | null;
}
export interface SkipResult {
skipReason: string;
}
export type ProcessResult = ProcessedPost | SkipResult;
export function isSkip(result: ProcessResult): result is SkipResult {
return 'skipReason' in result;
}
export type ImageFetcher = (url: string, userAgent: string) => Promise<DownloadedImage>;
export class ProcessorService {
private successfulExamples: string[] = [];
private strategySelector: StrategySelector | null = null;
private chatStrategySelector: StrategySelector | null = null;
private fetchImage: ImageFetcher;
constructor(
private redditApi: RedditApiClient,
private aiClient: OpenRouterClient,
private config: Config,
private logger: Logger
) {}
private logger: Logger,
fetchImage?: ImageFetcher,
) {
this.fetchImage = fetchImage ?? downloadImage;
}
setStrategySelector(selector: StrategySelector): void {
this.strategySelector = selector;
@@ -31,6 +49,18 @@ export class ProcessorService {
}
}
setChatStrategySelector(selector: StrategySelector): void {
this.chatStrategySelector = selector;
if (selector.hasStrategies()) {
this.logger.info(`Chat strategy A/B testing enabled with ${this.config.chatStrategies.variants.length} variants`);
}
}
private getSubredditType(name: string): 'image' | 'chat' {
const entry = this.config.reddit.subreddits.find((s) => s.name.toLowerCase() === name.toLowerCase());
return entry?.type ?? 'image';
}
setSuccessfulExamples(examples: string[]): void {
this.successfulExamples = examples;
if (examples.length > 0) {
@@ -38,12 +68,16 @@ export class ProcessorService {
}
}
async processPost(post: RedditPost): Promise<ProcessedPost | null> {
async processPost(post: RedditPost): Promise<ProcessResult> {
if (this.getSubredditType(post.subreddit) === 'chat') {
return this.processChatPost(post);
}
const imageUrl = this.redditApi.getImageUrl(post);
if (!imageUrl) {
this.logger.warn(`No image URL found for post ${post.id}`);
return null;
return { skipReason: 'no image URL on post' };
}
this.logger.info(`Processing post: "${post.title.slice(0, 50)}..." by u/${post.author}`);
@@ -51,22 +85,23 @@ export class ProcessorService {
// Download image
let imageData;
try {
imageData = await downloadImage(imageUrl, this.config.reddit.userAgent);
imageData = await this.fetchImage(imageUrl, this.config.reddit.userAgent);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to download image from ${imageUrl}:`, error);
return null;
return { skipReason: `image download failed: ${msg}` };
}
// Validate image type
if (!isValidImageMimeType(imageData.mimeType)) {
this.logger.warn(`Invalid image type ${imageData.mimeType} for post ${post.id}`);
return null;
return { skipReason: `unsupported image type (${imageData.mimeType})` };
}
// Check image size (skip very small images like thumbnails)
if (imageData.buffer.length < 5000) {
this.logger.warn(`Image too small (${imageData.buffer.length} bytes) for post ${post.id}`);
return null;
return { skipReason: `image too small (${imageData.buffer.length} bytes)` };
}
// Convert to base64
@@ -81,7 +116,9 @@ export class ProcessorService {
this.logger.info(
`Skipping post ${post.id} - detected gender "${detectedGender}" not in target list [${targetGenders.join(', ')}]`
);
return null;
return {
skipReason: `detected gender "${detectedGender}" not in target list [${targetGenders.join(', ')}]`,
};
}
this.logger.debug(`Gender check passed: ${detectedGender}`);
@@ -111,14 +148,16 @@ export class ProcessorService {
} catch (error) {
if (error instanceof Error && error.message.startsWith('AI_REFUSAL')) {
this.logger.warn(`AI refused to analyze post ${post.id} by u/${post.author} - skipping`);
return null;
return { skipReason: 'AI refused to analyze image' };
}
throw error;
}
if (!message || message.length < 10) {
this.logger.warn(`Generated message too short or empty for post ${post.id}`);
return null;
this.logger.warn(
`Generated message too short or empty for post ${post.id} (${message?.length ?? 0} chars): ${JSON.stringify(message?.slice(0, 200) ?? '')}`,
);
return { skipReason: `generated message too short (${message?.length ?? 0} chars)` };
}
return {
@@ -129,4 +168,52 @@ export class ProcessorService {
strategyId: selectedStrategy?.id || null,
};
}
private async processChatPost(post: RedditPost): Promise<ProcessResult> {
this.logger.info(`Processing chat post: "${post.title.slice(0, 50)}..." by u/${post.author}`);
// Gender pre-filtering for chat subs happens in MonitorService — by the
// time a chat post reaches the processor it's already passed the filter.
// We still parse the tag here so it can be threaded into the opener prompt.
const haystack = `${post.title}\n${post.selftext ?? ''}`;
const detectedGender = parseGenderTag(haystack);
let selectedStrategy: SelectedStrategy | null = null;
if (this.chatStrategySelector) {
selectedStrategy = this.chatStrategySelector.selectStrategy();
if (selectedStrategy) {
this.logger.info(`Using chat strategy "${selectedStrategy.id}" (${selectedStrategy.reason})`);
}
}
let message: string;
try {
message = await this.aiClient.generateChatOpener(
post.title,
post.selftext ?? '',
post.author,
detectedGender ?? 'unknown',
this.successfulExamples,
selectedStrategy?.promptOverride,
);
} catch (error) {
this.logger.error(`Chat opener generation failed for post ${post.id}:`, error);
throw error;
}
if (!message || message.length < 10) {
this.logger.warn(
`Generated chat opener too short or empty for post ${post.id} (${message?.length ?? 0} chars): ${JSON.stringify(message?.slice(0, 200) ?? '')}`,
);
return { skipReason: `generated chat opener too short (${message?.length ?? 0} chars)` };
}
return {
post,
imageAnalysis: `[chat] ${post.title}${post.selftext ? ` — ${post.selftext.slice(0, 200)}` : ''}`,
detectedGender: detectedGender ?? 'unknown',
generatedMessage: message,
strategyId: selectedStrategy?.id || null,
};
}
}
+6 -4
View File
@@ -16,11 +16,13 @@ export class StrategySelector {
constructor(
private config: Config,
private messagesRepo: MessagesRepository,
private logger: Logger
private logger: Logger,
section: 'strategies' | 'chatStrategies' = 'strategies',
) {
this.variants = config.strategies.variants;
this.explorationRate = config.strategies.explorationRate;
this.minSamples = config.strategies.minSamples;
const cfg = config[section];
this.variants = cfg.variants;
this.explorationRate = cfg.explorationRate;
this.minSamples = cfg.minSamples;
}
selectStrategy(): SelectedStrategy | null {
+18
View File
@@ -0,0 +1,18 @@
/**
* `fetch` with a hard client-side timeout. Node's global fetch otherwise
* inherits no timeout — a stalled connection will hang the caller forever,
* which has wedged the main loop more than once (incidents on 2026-05-20).
*
* On timeout, throws an error whose `.name === 'TimeoutError'`. Callers that
* want to differentiate timeout-vs-other failures can branch on that.
*/
export async function fetchWithTimeout(
input: string | URL | Request,
init: RequestInit & { timeoutMs?: number } = {},
): Promise<Response> {
const { timeoutMs = 30_000, ...rest } = init;
return fetch(input, {
...rest,
signal: AbortSignal.timeout(timeoutMs),
});
}
+39
View File
@@ -0,0 +1,39 @@
import type { DetectedGender } from '../ai/openrouter.js';
/**
* Best-effort extraction of the poster's self-declared gender from a Reddit
* chat-sub post (title + selftext). Used by chat-only subreddits like
* /r/SextingRush where posts almost always carry a tag such as `[25F]`,
* `(M, 30)`, `F4M`, `25/f`, etc.
*
* Returns null when no tag is found — callers should skip those posts rather
* than guess.
*/
export function parseGenderTag(text: string): DetectedGender | null {
if (!text) return null;
const haystack = text.toLowerCase();
// X4Y tags (e.g. "f4m", "m4f", "f4mf"). The first letter is the poster.
const x4y = haystack.match(/\b([mf])4[mfa+]+\b/);
if (x4y) return x4y[1] === 'f' ? 'female' : 'male';
// Bracketed / parenthesised / bare tags with an age + gender letter in
// either order, optionally separated by a slash, space, or comma.
// Matches: [25F], (F25), 25f, f25, 25/f, f/25, [F, 25], (m 30), 25-f.
const ageGender =
haystack.match(/\[\s*(\d{2})\s*[\s,/\-]*\s*([mf])\s*\]/) ||
haystack.match(/\[\s*([mf])\s*[\s,/\-]*\s*(\d{2})\s*\]/) ||
haystack.match(/\(\s*(\d{2})\s*[\s,/\-]*\s*([mf])\s*\)/) ||
haystack.match(/\(\s*([mf])\s*[\s,/\-]*\s*(\d{2})\s*\)/) ||
haystack.match(/(?:^|[^a-z])(\d{2})\s*[\/\-]\s*([mf])(?:[^a-z]|$)/) ||
haystack.match(/(?:^|[^a-z])([mf])\s*[\/\-]\s*(\d{2})(?:[^a-z]|$)/) ||
haystack.match(/(?:^|[^a-z])(\d{2})([mf])(?:[^a-z]|$)/) ||
haystack.match(/(?:^|[^a-z])([mf])(\d{2})(?:[^a-z]|$)/);
if (ageGender) {
const letter = /[mf]/.test(ageGender[1]) ? ageGender[1] : ageGender[2];
return letter === 'f' ? 'female' : 'male';
}
return null;
}
+17 -1
View File
@@ -2,6 +2,22 @@ import winston from 'winston';
export type Logger = winston.Logger;
function safeStringify(value: unknown): string {
const seen = new WeakSet<object>();
try {
return JSON.stringify(value, (_key, val) => {
if (typeof val === 'object' && val !== null) {
if (seen.has(val)) return '[Circular]';
seen.add(val);
}
if (typeof val === 'bigint') return val.toString();
return val;
});
} catch (err) {
return `[Unserializable: ${(err as Error).message}]`;
}
}
export function createLogger(): Logger {
return winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
@@ -15,7 +31,7 @@ export function createLogger(): Logger {
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ level, message, timestamp, ...meta }) => {
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
const metaStr = Object.keys(meta).length ? ` ${safeStringify(meta)}` : '';
return `${timestamp} [${level}]: ${message}${metaStr}`;
})
),
+9 -4
View File
@@ -2,17 +2,22 @@ import PQueue from 'p-queue';
import type { Config } from '../config/index.js';
import type { Logger } from './logger.js';
const DEFAULT_HOUR_WINDOW_MS = 3600000;
export class RateLimiter {
private queue: PQueue;
private lastMessageTime: number = 0;
private messagesThisHour: number = 0;
private hourStartTime: number = Date.now();
private readonly hourWindowMs: number;
constructor(
private config: Config,
private logger: Logger
private logger: Logger,
hourWindowMs: number = DEFAULT_HOUR_WINDOW_MS,
) {
this.queue = new PQueue({ concurrency: 1 });
this.hourWindowMs = hourWindowMs;
}
async throttle<T>(fn: () => Promise<T>, shouldCount: (result: T) => boolean = () => true): Promise<T> {
@@ -20,7 +25,7 @@ export class RateLimiter {
const now = Date.now();
// Reset hourly counter if needed
if (now - this.hourStartTime > 3600000) {
if (now - this.hourStartTime > this.hourWindowMs) {
this.messagesThisHour = 0;
this.hourStartTime = now;
this.logger.debug('Hourly rate limit counter reset');
@@ -28,7 +33,7 @@ export class RateLimiter {
// Check hourly limit
if (this.messagesThisHour >= this.config.rateLimit.maxMessagesPerHour) {
const waitTime = 3600000 - (now - this.hourStartTime);
const waitTime = this.hourWindowMs - (now - this.hourStartTime);
this.logger.info(`Hourly limit reached, waiting ${Math.round(waitTime / 60000)} minutes`);
await this.sleep(waitTime);
this.messagesThisHour = 0;
@@ -94,7 +99,7 @@ export class RateLimiter {
}
// Calculate when the hourly counter resets
const hourlyResetAt = this.hourStartTime + 3600000;
const hourlyResetAt = this.hourStartTime + this.hourWindowMs;
// Check if hourly limit is reached
const isHourlyLimitReached = this.messagesThisHour >= maxMessages;
+18 -5
View File
@@ -7,16 +7,29 @@ export async function withRetry<T>(
retries?: number;
logger?: Logger;
operationName?: string;
/** Initial retry delay in ms. Set to 0 in tests to skip the backoff wait. */
minTimeout?: number;
maxTimeout?: number;
factor?: number;
randomize?: boolean;
} = {}
): Promise<T> {
const { retries = 3, logger, operationName = 'operation' } = options;
const {
retries = 3,
logger,
operationName = 'operation',
minTimeout = 5000,
maxTimeout = 60000,
factor = 2,
randomize = true,
} = options;
return pRetry(fn, {
retries,
minTimeout: 5000,
maxTimeout: 60000,
factor: 2,
randomize: true,
minTimeout,
maxTimeout,
factor,
randomize,
onFailedAttempt: (context) => {
const errorMessage = context.error instanceof Error ? context.error.message : String(context.error);
logger?.warn(
+44
View File
@@ -0,0 +1,44 @@
import type { ActivityItem } from './types.js';
import type { ActivityLogRepository } from '../database/repositories.js';
const MAX_ITEMS = 200;
/**
* Bounded log of bot actions surfaced on the dashboard.
* Hydrates from SQLite on construction and persists every push,
* so the feed survives restarts.
*/
export class ActivityLog {
private items: ActivityItem[] = [];
constructor(private repo: ActivityLogRepository) {
const rows = repo.getRecent(MAX_ITEMS);
this.items = rows
.map((r) => ({
timestamp: r.timestamp,
type: r.type as ActivityItem['type'],
message: r.message,
recipient: r.recipient ?? undefined,
imageUrl: r.imageUrl ?? undefined,
}))
.reverse();
}
push(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void {
const item: ActivityItem = { timestamp: Date.now(), type, message, recipient, imageUrl };
this.items.push(item);
if (this.items.length > MAX_ITEMS) {
this.items = this.items.slice(-MAX_ITEMS);
}
this.repo.insert(item);
this.repo.prune(MAX_ITEMS);
}
recent(limit: number): ActivityItem[] {
return this.items.slice(-limit).reverse();
}
totalProcessed(): number {
return this.repo.countSentSkipped();
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { Logger } from '../utils/logger.js';
import type { Config } from '../config/index.js';
import type {
PostsRepository,
MessagesRepository,
SuccessfulMessagesRepository,
} from '../database/repositories.js';
import type { ChatService } from '../services/chat.js';
import type { MediaService } from '../services/media.js';
import type { MediaSyncService } from '../services/media-sync.js';
import type { ResponseCheckerService } from '../services/response-checker.js';
import type { RateLimiter } from '../utils/rate-limiter.js';
import type { OpenRouterClient } from '../ai/openrouter.js';
import type { StrategySelector } from '../services/strategy-selector.js';
import type { BrowserClient } from '../browser/client.js';
import type { ActivityLog } from './activity-log.js';
import type { LogBroadcaster } from './log-broadcaster.js';
import type { RuntimeConfig } from './types.js';
/** Bag of everything the route modules might need. */
export interface WebContext {
logger: Logger;
config: Config;
runtimeConfig: RuntimeConfig;
activity: ActivityLog;
broadcaster: LogBroadcaster;
repos: {
posts: PostsRepository;
messages: MessagesRepository;
successful: SuccessfulMessagesRepository;
};
services: {
chat?: ChatService;
media?: MediaService;
mediaSync?: MediaSyncService;
responseChecker?: ResponseCheckerService;
rateLimiter?: RateLimiter;
ai?: OpenRouterClient;
strategySelector?: StrategySelector;
browser?: BrowserClient;
};
/** Bot pause/resume toggle read by the main loop. */
getBotRunning(): boolean;
setBotRunning(running: boolean): void;
/** Mirrored count for the dashboard's `messagesThisHour` widget. */
getMessagesThisHour(): number;
/**
* Callback invoked when the dashboard mutates `runtimeConfig`.
* Wrapped in a getter so callers see the latest value — it can be installed
* after the WebContext is constructed.
*/
getOnConfigChange(): ((config: RuntimeConfig) => void) | null;
}
+80
View File
@@ -0,0 +1,80 @@
import type { Request, Response } from 'express';
import type { LogEntry } from './types.js';
const MAX_LOGS = 500;
const INITIAL_LOGS_ON_CONNECT = 50;
/**
* Holds the recent realtime log buffer and fans out new entries to every
* connected SSE client. Owns the `/api/logs/stream` and `/api/logs` handlers.
*/
export class LogBroadcaster {
private entries: LogEntry[] = [];
private clients = new Set<Response>();
push(level: LogEntry['level'], message: string, data?: unknown): void {
const entry: LogEntry = { timestamp: Date.now(), level, message, data };
this.entries.push(entry);
if (this.entries.length > MAX_LOGS) {
this.entries = this.entries.slice(-MAX_LOGS);
}
this.fanout(JSON.stringify({ type: 'log', ...entry }));
}
/**
* Broadcast a non-log event (e.g. sync progress) to all connected clients.
* Unlike `push`, these events are not buffered or replayed on reconnect —
* they reflect transient state.
*/
broadcastEvent(type: string, payload: Record<string, unknown>): void {
this.fanout(JSON.stringify({ type, ...payload }));
}
private fanout(payload: string): void {
for (const client of this.clients) {
try {
client.write(`data: ${payload}\n\n`);
} catch {
this.clients.delete(client);
}
}
}
/** Express handler for SSE stream. */
stream = (req: Request, res: Response): void => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.write(`data: ${JSON.stringify({ type: 'connected', timestamp: Date.now() })}\n\n`);
this.clients.add(res);
for (const log of this.entries.slice(-INITIAL_LOGS_ON_CONNECT)) {
res.write(`data: ${JSON.stringify({ type: 'log', ...log })}\n\n`);
}
req.on('close', () => {
this.clients.delete(res);
});
};
/** Express handler for `/api/logs?limit=N`. */
recent = (req: Request, res: Response): void => {
const limit = parseInt(req.query.limit as string) || 100;
res.json(this.entries.slice(-limit));
};
closeAll(): void {
for (const client of this.clients) {
try {
client.end();
} catch {
// ignore
}
}
this.clients.clear();
}
}
+33
View File
@@ -0,0 +1,33 @@
import { Router } from 'express';
import type { WebContext } from '../context.js';
export function createAnalyticsRouter(ctx: WebContext): Router {
const router = Router();
router.get('/timers', (_req, res) => {
if (!ctx.services.rateLimiter) {
res.status(503).json({ error: 'Rate limiter not initialized' });
return;
}
res.json(ctx.services.rateLimiter.getTimers());
});
router.get('/analytics/by-subreddit', (_req, res) => {
res.json(ctx.repos.messages.getSubredditStats());
});
router.get('/analytics/by-strategy', (_req, res) => {
if (ctx.services.strategySelector) {
res.json(ctx.services.strategySelector.getStats());
} else {
res.json(ctx.repos.messages.getStrategyStats());
}
});
router.get('/analytics/trend', (req, res) => {
const days = parseInt(req.query.days as string) || 30;
res.json(ctx.repos.messages.getResponseRateTrend(days));
});
return router;
}
+104
View File
@@ -0,0 +1,104 @@
import { Router } from 'express';
import type { WebContext } from '../context.js';
const ACTIVE_WINDOW_MS = 48 * 60 * 60 * 1000;
export function createChatsRouter(ctx: WebContext): Router {
const router = Router();
router.get('/chats', async (_req, res) => {
if (!ctx.services.chat) {
res.json([]);
return;
}
try {
const all = await ctx.services.chat.getCachedConversations();
const cutoff = Date.now() - ACTIVE_WINDOW_MS;
res.json(all.filter((c) => c.lastMessageTime >= cutoff));
} catch (error) {
ctx.logger.error('Failed to fetch chats:', error);
res.json([]);
}
});
/** Status snapshot for the dashboard's sync controls. */
router.get('/chats/sync', (_req, res) => {
if (!ctx.services.chat) {
res.status(503).json({ error: 'Chat service not initialized' });
return;
}
res.json(ctx.services.chat.getSyncStatus());
});
/**
* Delta sync — pulls only events since the last cursor. Fast (~seconds).
* Use this for routine refreshes.
*/
router.post('/chats/sync/delta', async (_req, res) => {
if (!ctx.services.chat) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
const status = ctx.services.chat.getSyncStatus();
if (status.syncing) {
res.json({ success: false, reason: `Already running ${status.syncingKind} sync` });
return;
}
try {
ctx.broadcaster.push('info', 'Delta sync triggered from dashboard');
// Run asynchronously — status endpoint reports progress.
ctx.services.chat
.refreshTopChats()
.catch((err) => ctx.logger.error('Delta sync failed:', err));
res.json({ success: true, kind: 'delta' });
} catch (error) {
ctx.logger.error('Delta sync trigger failed:', error);
res.status(500).json({ error: String(error) });
}
});
/**
* Full sync — walks every joined room and rebuilds the cache from
* scratch. Slow (~1–2 min for hundreds of rooms). Use after a long
* absence, recovery from corruption, or to pick up rooms that don't
* appear in /sync.
*/
router.post('/chats/sync/full', async (_req, res) => {
if (!ctx.services.chat) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
const status = ctx.services.chat.getSyncStatus();
if (status.syncing) {
res.json({ success: false, reason: `Already running ${status.syncingKind} sync` });
return;
}
try {
ctx.broadcaster.push('info', 'Full sync triggered from dashboard');
ctx.services.chat
.fetchAllMessages()
.catch((err) => ctx.logger.error('Full sync failed:', err));
res.json({ success: true, kind: 'full' });
} catch (error) {
ctx.logger.error('Full sync trigger failed:', error);
res.status(500).json({ error: String(error) });
}
});
/** Back-compat alias for the old "fetch-all" endpoint. */
router.post('/chats/fetch-all', async (_req, res) => {
if (!ctx.services.chat) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const chats = await ctx.services.chat.fetchAllMessages();
res.json({ success: true, count: chats.length });
} catch (error) {
ctx.logger.error('Failed to fetch all chats:', error);
res.status(500).json({ error: String(error) });
}
});
return router;
}
+61
View File
@@ -0,0 +1,61 @@
import { Router } from 'express';
import type { WebContext } from '../context.js';
export function createConfigRouter(ctx: WebContext): Router {
const router = Router();
router.get('/config', (_req, res) => {
res.json({
rateLimit: ctx.runtimeConfig.rateLimit,
browser: { dryRun: ctx.runtimeConfig.browser.dryRun },
});
});
router.put('/config', (req, res) => {
try {
const updates = req.body;
if (updates.rateLimit) {
if (typeof updates.rateLimit.minDelayBetweenMessagesMs === 'number') {
ctx.runtimeConfig.rateLimit.minDelayBetweenMessagesMs = Math.max(
0,
updates.rateLimit.minDelayBetweenMessagesMs,
);
}
if (typeof updates.rateLimit.maxMessagesPerHour === 'number') {
ctx.runtimeConfig.rateLimit.maxMessagesPerHour = Math.max(
1,
updates.rateLimit.maxMessagesPerHour,
);
}
if (typeof updates.rateLimit.jitterMs === 'number') {
ctx.runtimeConfig.rateLimit.jitterMs = Math.max(0, updates.rateLimit.jitterMs);
}
}
if (updates.browser) {
if (typeof updates.browser.dryRun === 'boolean') {
ctx.runtimeConfig.browser.dryRun = updates.browser.dryRun;
}
}
// Mirror into the loaded config so getStats reflects new values.
ctx.config.rateLimit.minDelayBetweenMessagesMs = ctx.runtimeConfig.rateLimit.minDelayBetweenMessagesMs;
ctx.config.rateLimit.maxMessagesPerHour = ctx.runtimeConfig.rateLimit.maxMessagesPerHour;
ctx.config.rateLimit.jitterMs = ctx.runtimeConfig.rateLimit.jitterMs;
ctx.config.browser.dryRun = ctx.runtimeConfig.browser.dryRun;
ctx.getOnConfigChange()?.(ctx.runtimeConfig);
ctx.activity.push('info', 'Config updated via dashboard');
ctx.broadcaster.push('info', 'Configuration updated via dashboard', updates);
res.json({ success: true, config: ctx.runtimeConfig });
} catch (error) {
ctx.logger.error('Failed to update config:', error);
res.status(400).json({ error: String(error) });
}
});
return router;
}
+22
View File
@@ -0,0 +1,22 @@
import { Router } from 'express';
import type { WebContext } from '../context.js';
export function createControlRouter(ctx: WebContext): Router {
const router = Router();
router.post('/start', (_req, res) => {
ctx.setBotRunning(true);
ctx.activity.push('sent', 'Bot started by user');
ctx.logger.info('Bot started via web UI');
res.json({ success: true, running: true });
});
router.post('/stop', (_req, res) => {
ctx.setBotRunning(false);
ctx.activity.push('skipped', 'Bot stopped by user');
ctx.logger.info('Bot stopped via web UI');
res.json({ success: true, running: false });
});
return router;
}
+242
View File
@@ -0,0 +1,242 @@
import { Router } from 'express';
import type { ActiveChat } from '../../services/chat.js';
import type { WebContext } from '../context.js';
export function createConversationsRouter(ctx: WebContext): Router {
const router = Router();
async function findConversation(id: string): Promise<ActiveChat | undefined> {
if (!ctx.services.chat) return undefined;
const conversations = await ctx.services.chat.getCachedConversations();
return conversations.find(
(c) => c.roomId === id || c.username === id || c.roomName === id,
);
}
router.get('/conversations', async (_req, res) => {
if (!ctx.services.chat) {
res.json([]);
return;
}
try {
res.json(await ctx.services.chat.getCachedConversations());
} catch (error) {
ctx.logger.error('Failed to fetch conversations:', error);
res.json([]);
}
});
router.get('/conversations/:id', async (req, res) => {
if (!ctx.services.chat) {
res.status(404).json({ error: 'Chat service not initialized' });
return;
}
try {
// Pull in any new activity from the homeserver before we read the cache,
// so a stale "waiting for them to reply" view picks up the reply they
// already sent. Cheap (~one /sync call) and queued behind any in-flight sync.
try {
await ctx.services.chat.refreshTopChats();
} catch (err) {
ctx.logger.warn('Pre-open delta sync failed:', err);
}
const conversation = await findConversation(decodeURIComponent(req.params.id));
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
// Delta sync only populates room summaries — full message history is
// fetched on demand the first time a conversation is opened, and
// re-fetched when the summary's last-activity timestamp is newer than
// anything we have cached (i.e. they've replied since we last loaded).
const cachedLastTs = conversation.messages?.length
? conversation.messages[conversation.messages.length - 1].timestamp
: 0;
const hasNewerActivity = (conversation.lastMessageTime ?? 0) > cachedLastTs;
if (!conversation.messages || conversation.messages.length === 0 || hasNewerActivity) {
try {
await ctx.services.chat.loadConversationDetails(conversation);
await ctx.services.chat.saveCacheExternal();
} catch (err) {
ctx.logger.warn(`Lazy-load failed for conversation ${conversation.roomId}:`, err);
}
}
// Attach the post that triggered this outreach so the modal can link to it.
let triggeringPost: {
id: string;
subreddit?: string;
title?: string;
url: string;
sentAt: number;
} | null = null;
try {
const sent = ctx.repos.messages.getLatestByRecipient(conversation.username);
if (sent) {
const url = sent.subreddit
? `https://www.reddit.com/r/${sent.subreddit}/comments/${sent.postId}/`
: `https://redd.it/${sent.postId}`;
triggeringPost = {
id: sent.postId,
subreddit: sent.subreddit,
title: sent.postTitle,
url,
sentAt: sent.sentAt,
};
}
} catch (err) {
ctx.logger.warn(`Failed to look up triggering post for ${conversation.username}:`, err);
}
res.json({ ...conversation, triggeringPost });
} catch (error) {
ctx.logger.error('Failed to fetch conversation:', error);
res.status(500).json({ error: String(error) });
}
});
router.post('/conversations/:id/suggest-reply', async (req, res) => {
if (!ctx.services.chat) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
if (!ctx.services.ai) {
res.status(500).json({ error: 'AI client not initialized' });
return;
}
try {
const conversation = await findConversation(decodeURIComponent(req.params.id));
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
if (!conversation.messages || conversation.messages.length === 0) {
res.status(400).json({ error: 'No messages in conversation to generate a reply from' });
return;
}
if (!conversation.messages.some((m) => !m.isSelf)) {
res.status(400).json({ error: 'No reply from user yet - nothing to respond to' });
return;
}
const suggestedReply = await ctx.services.ai.generateReply(
conversation.messages.map((m) => ({ sender: m.sender, text: m.text, isSelf: m.isSelf })),
conversation.username,
);
res.json({ suggestedReply });
} catch (error) {
ctx.logger.error('Failed to generate suggested reply:', error);
res.status(500).json({ error: String(error) });
}
});
router.post('/conversations/:id/suggest-media-ask', async (req, res) => {
if (!ctx.services.chat) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
if (!ctx.services.ai) {
res.status(500).json({ error: 'AI client not initialized' });
return;
}
try {
const conversation = await findConversation(decodeURIComponent(req.params.id));
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
if (!conversation.messages || conversation.messages.length === 0) {
res.status(400).json({ error: 'No messages in conversation to generate from' });
return;
}
if (!conversation.messages.some((m) => !m.isSelf)) {
res.status(400).json({ error: 'No reply from user yet - nothing to respond to' });
return;
}
const suggestedReply = await ctx.services.ai.generateMediaAsk(
conversation.messages.map((m) => ({ sender: m.sender, text: m.text, isSelf: m.isSelf })),
conversation.username,
);
res.json({ suggestedReply });
} catch (error) {
ctx.logger.error('Failed to generate media-ask message:', error);
res.status(500).json({ error: String(error) });
}
});
router.post('/conversations/:id/send-reply', async (req, res) => {
if (!ctx.services.chat) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const { message } = req.body;
if (!message || typeof message !== 'string' || message.trim().length === 0) {
res.status(400).json({ error: 'Message is required' });
return;
}
const conversation = await findConversation(decodeURIComponent(req.params.id));
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
if (!conversation.url) {
res.status(400).json({ error: 'No chat URL for this conversation' });
return;
}
const success = await ctx.services.chat.sendChatMessage(conversation.url, message.trim());
if (success) {
ctx.activity.push('sent', `Sent chat reply to u/${conversation.username}`, conversation.username);
ctx.broadcaster.push('info', `Chat reply sent to u/${conversation.username}`);
res.json({ success: true });
ctx.services.chat.refreshTopChats(5).catch((err) =>
ctx.logger.error('Failed to refresh chats after send-reply:', err),
);
} else {
res.status(500).json({ error: 'Failed to send chat message' });
}
} catch (error) {
ctx.logger.error('Failed to send chat reply:', error);
res.status(500).json({ error: String(error) });
}
});
/**
* Start a brand-new DM with a Reddit user and send the opening message.
* Body: { username: string, message: string }
*/
router.post('/conversations/start', async (req, res) => {
if (!ctx.services.chat) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const { username, message } = req.body ?? {};
if (!username || typeof username !== 'string') {
res.status(400).json({ error: 'username is required' });
return;
}
if (!message || typeof message !== 'string' || message.trim().length === 0) {
res.status(400).json({ error: 'message is required' });
return;
}
const chat = await ctx.services.chat.startConversation(username, message.trim());
if (!chat) {
res.status(500).json({ error: 'Failed to start conversation (unknown user or send failed)' });
return;
}
ctx.activity.push('sent', `Started chat with u/${chat.username}`, chat.username);
ctx.broadcaster.push('info', `Started new chat with u/${chat.username}`);
res.json({ success: true, chat });
} catch (error) {
ctx.logger.error('Failed to start conversation:', error);
res.status(500).json({ error: String(error) });
}
});
return router;
}
+42
View File
@@ -0,0 +1,42 @@
import { Router } from 'express';
import type { WebContext } from '../context.js';
export function createDebugRouter(ctx: WebContext): Router {
const router = Router();
router.post('/debug/inspect-dom', async (req, res) => {
if (!ctx.services.chat) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const chatUrl = req.body?.url;
if (!chatUrl) {
res.status(400).json({ error: 'url is required' });
return;
}
const result = await ctx.services.chat.debugInspectDom(chatUrl);
res.json(result);
} catch (error) {
ctx.logger.error('DOM inspection failed:', error);
res.status(500).json({ error: String(error) });
}
});
router.post('/debug/check-responses', async (_req, res) => {
if (!ctx.services.responseChecker) {
res.status(500).json({ error: 'Response checker not initialized' });
return;
}
try {
ctx.logger.info('Manual response check triggered via API');
const results = await ctx.services.responseChecker.checkForResponses();
res.json({ success: true, results });
} catch (error) {
ctx.logger.error('Manual response check failed:', error);
res.status(500).json({ error: String(error) });
}
});
return router;
}
+54
View File
@@ -0,0 +1,54 @@
import { Router } from 'express';
import type { WebContext } from '../context.js';
export function createLoginRouter(ctx: WebContext): Router {
const router = Router();
// Info the dashboard uses to decide whether to show the VNC iframe and
// which port to point it at.
router.get('/login/info', (_req, res) => {
const novncPort = parseInt(process.env.NOVNC_PORT ?? '6080', 10);
res.json({
vncEnabled: Boolean(process.env.DISPLAY),
novncPort,
passwordRequired: Boolean(process.env.VNC_PASSWORD),
});
});
router.get('/login/status', async (_req, res) => {
const browser = ctx.services.browser;
if (!browser) {
res.status(503).json({ ready: false, error: 'browser not initialised' });
return;
}
try {
const loggedIn = await browser.isSessionValid();
res.json({ ready: true, loggedIn });
} catch (error) {
ctx.logger.error('Failed to check login status:', error);
res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' });
}
});
// Navigate the headed Chromium running under Xvfb to reddit.com/login so
// the human watching the noVNC stream can complete the flow.
router.post('/login/start', async (_req, res) => {
const browser = ctx.services.browser;
if (!browser) {
res.status(503).json({ ok: false, error: 'browser not initialised' });
return;
}
try {
const page = await browser.getPage();
await page.bringToFront().catch(() => undefined);
await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' });
ctx.logger.info('Opened Reddit login page for human completion via VNC');
res.json({ ok: true });
} catch (error) {
ctx.logger.error('Failed to open login page:', error);
res.status(500).json({ ok: false, error: 'navigation failed' });
}
});
return router;
}
+9
View File
@@ -0,0 +1,9 @@
import { Router } from 'express';
import type { WebContext } from '../context.js';
export function createLogsRouter(ctx: WebContext): Router {
const router = Router();
router.get('/logs/stream', ctx.broadcaster.stream);
router.get('/logs', ctx.broadcaster.recent);
return router;
}
+88
View File
@@ -0,0 +1,88 @@
import { Router } from 'express';
import type { WebContext } from '../context.js';
export function createMediaRouter(ctx: WebContext): Router {
const router = Router();
router.get('/media', async (_req, res) => {
if (!ctx.services.media) {
res.json([]);
return;
}
try {
const items = await ctx.services.media.getMediaItems();
// Build a roomId → current chat lookup so we can override stale
// stored usernames (e.g. items captured before username enrichment ran)
// and surface the recipient even for rooms that were originally
// ingested as `unknown`.
const liveChats = ctx.services.chat
? await ctx.services.chat.getCachedConversations()
: [];
const byRoom = new Map(liveChats.map((c) => [c.roomId ?? '', c] as const));
res.json(items.map((item) => {
const live = item.chatRoomId ? byRoom.get(item.chatRoomId) : undefined;
const liveName = live?.username && live.username !== 'unknown' ? live.username : null;
const username = liveName ?? item.chatUsername ?? null;
return {
...item,
chatUsername: username,
urlPath: `/media/${encodeURIComponent(item.relativePath)}`,
};
}));
} catch (error) {
ctx.logger.error('Failed to fetch media index:', error);
res.status(500).json({ error: String(error) });
}
});
router.get('/media/sync', (_req, res) => {
if (!ctx.services.mediaSync) {
res.status(503).json({ error: 'Media sync service not initialized' });
return;
}
res.json(ctx.services.mediaSync.getStatus());
});
router.post('/media/sync', async (req, res) => {
if (!ctx.services.mediaSync) {
res.status(503).json({ error: 'Media sync service not initialized' });
return;
}
try {
const full = req.body?.full === true;
const started = await ctx.services.mediaSync.triggerSync(full);
if (!started) {
res.json({ success: false, reason: 'Sync already in progress' });
return;
}
ctx.broadcaster.push('info', `Media sync triggered from dashboard (${full ? 'full' : 'incremental'})`);
res.json({ success: true });
} catch (error) {
ctx.logger.error('Media sync trigger failed:', error);
res.status(500).json({ error: String(error) });
}
});
router.post('/media/sync/full', async (_req, res) => {
if (!ctx.services.mediaSync) {
res.status(503).json({ error: 'Media sync service not initialized' });
return;
}
try {
const started = await ctx.services.mediaSync.triggerSync(true);
if (!started) {
res.json({ success: false, reason: 'Sync already in progress' });
return;
}
ctx.broadcaster.push('info', 'Full media sync triggered from dashboard (all conversations)');
res.json({ success: true });
} catch (error) {
ctx.logger.error('Full media sync trigger failed:', error);
res.status(500).json({ error: String(error) });
}
});
return router;
}
+52
View File
@@ -0,0 +1,52 @@
import { Router } from 'express';
import type { WebContext } from '../context.js';
import type { BotStats } from '../types.js';
export function createStatsRouter(ctx: WebContext): Router {
const router = Router();
router.get('/stats', (_req, res) => {
const messages = ctx.repos.messages.getRecent(1000);
const totalSent = messages.length;
const totalResponses = messages.filter((m) => m.gotResponse).length;
const cooldown = ctx.services.chat?.getInvitationCooldown() ?? null;
const stats: BotStats = {
isRunning: ctx.getBotRunning(),
dryRunMode: ctx.config.browser.dryRun,
totalProcessed: ctx.activity.totalProcessed(),
totalMessagesSent: totalSent,
totalResponses,
responseRate: totalSent > 0 ? Math.round((totalResponses / totalSent) * 100) : 0,
messagesThisHour: ctx.getMessagesThisHour(),
maxMessagesPerHour: ctx.config.rateLimit.maxMessagesPerHour,
subreddits: ctx.config.reddit.subreddits.map((s) => ({ name: s.name, type: s.type })),
targetGenders: ctx.config.reddit.targetGenders,
recentActivity: ctx.activity.recent(10),
invitationCooldown: cooldown
? { until: cooldown.until, reason: cooldown.reason, redditCode: cooldown.redditCode }
: null,
};
res.json(stats);
});
router.get('/activity', (_req, res) => {
res.json(ctx.activity.recent(50));
});
router.get('/posts/by-author/:username', (req, res) => {
res.json(ctx.repos.posts.getByAuthor(req.params.username));
});
router.get('/messages', (_req, res) => {
res.json(ctx.repos.messages.getRecent(20));
});
router.get('/successful', (_req, res) => {
res.json(ctx.repos.successful.getRecent(10));
});
return router;
}
+97 -690
View File
@@ -1,765 +1,181 @@
import express from 'express';
import path from 'path';
import type { Server } from 'http';
import type { Response } from 'express';
import type { Logger } from '../utils/logger.js';
import type { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from '../database/repositories.js';
import type { Config } from '../config/index.js';
import type { ChatService, ActiveChat } from '../services/chat.js';
import type {
PostsRepository,
MessagesRepository,
SuccessfulMessagesRepository,
ActivityLogRepository,
} from '../database/repositories.js';
import type { ChatService } from '../services/chat.js';
import type { MediaService } from '../services/media.js';
import type { MediaSyncService } from '../services/media-sync.js';
import type { ResponseCheckerService } from '../services/response-checker.js';
import type { RateLimiter } from '../utils/rate-limiter.js';
import type { OpenRouterClient } from '../ai/openrouter.js';
import type { AutoReplyService } from '../services/auto-reply.js';
import type { StrategySelector } from '../services/strategy-selector.js';
import type { BrowserClient } from '../browser/client.js';
import { ActivityLog } from './activity-log.js';
import { LogBroadcaster } from './log-broadcaster.js';
import type { WebContext } from './context.js';
import type { ActivityItem, LogEntry, RuntimeConfig } from './types.js';
import { createStatsRouter } from './routes/stats.js';
import { createChatsRouter } from './routes/chats.js';
import { createConversationsRouter } from './routes/conversations.js';
import { createMediaRouter } from './routes/media.js';
import { createControlRouter } from './routes/control.js';
import { createDebugRouter } from './routes/debug.js';
import { createLogsRouter } from './routes/logs.js';
import { createAnalyticsRouter } from './routes/analytics.js';
import { createConfigRouter } from './routes/config.js';
import { createLoginRouter } from './routes/login.js';
export interface BotStats {
isRunning: boolean;
dryRunMode: boolean;
totalProcessed: number;
totalMessagesSent: number;
totalResponses: number;
responseRate: number;
messagesThisHour: number;
maxMessagesPerHour: number;
subreddits: string[];
targetGenders: string[];
recentActivity: ActivityItem[];
}
export interface ActivityItem {
timestamp: number;
type: 'sent' | 'skipped' | 'response' | 'error' | 'info' | 'debug';
message: string;
recipient?: string;
imageUrl?: string;
}
export interface LogEntry {
timestamp: number;
level: 'info' | 'warn' | 'error' | 'debug';
message: string;
data?: unknown;
}
export interface RuntimeConfig {
rateLimit: {
minDelayBetweenMessagesMs: number;
maxMessagesPerHour: number;
jitterMs: number;
};
browser: {
dryRun: boolean;
};
}
export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js';
/**
* Thin Express shell. Owns the server lifecycle, the activity log,
* the SSE log broadcaster, and a `WebContext` that route modules read from.
*
* Service references (chat, media, etc.) are still injected via `set*`
* methods because `index.ts` builds them after the WebServer to keep
* setup ordering identical to the pre-refactor code.
*/
export class WebServer {
private app: express.Application;
private server: Server | null = null;
private activityLog: ActivityItem[] = [];
private realtimeLog: LogEntry[] = [];
private sseClients: Set<Response> = new Set();
private botRunning = false; // Start stopped
private activity: ActivityLog;
private broadcaster = new LogBroadcaster();
private botRunning = false;
private messagesThisHour = 0;
private chatService: ChatService | null = null;
private mediaService: MediaService | null = null;
private mediaSyncService: MediaSyncService | null = null;
private responseChecker: ResponseCheckerService | null = null;
private rateLimiter: RateLimiter | null = null;
private aiClient: OpenRouterClient | null = null;
private autoReplyService: AutoReplyService | null = null;
private strategySelector: StrategySelector | null = null;
private runtimeConfig: RuntimeConfig;
private onConfigChange: ((config: RuntimeConfig) => void) | null = null;
private services: WebContext['services'] = {};
constructor(
private port: number,
private config: Config,
private postsRepo: PostsRepository,
private messagesRepo: MessagesRepository,
private successfulMessagesRepo: SuccessfulMessagesRepository,
private logger: Logger
private activityLogRepo: ActivityLogRepository,
private logger: Logger,
) {
this.app = express();
// Initialize runtime config from loaded config
this.activity = new ActivityLog(activityLogRepo);
this.runtimeConfig = {
rateLimit: {
minDelayBetweenMessagesMs: config.rateLimit.minDelayBetweenMessagesMs,
maxMessagesPerHour: config.rateLimit.maxMessagesPerHour,
jitterMs: config.rateLimit.jitterMs,
},
browser: {
dryRun: config.browser.dryRun,
},
browser: { dryRun: config.browser.dryRun },
};
this.app = express();
this.setupRoutes();
}
private context(): WebContext {
return {
logger: this.logger,
config: this.config,
runtimeConfig: this.runtimeConfig,
activity: this.activity,
broadcaster: this.broadcaster,
repos: {
posts: this.postsRepo,
messages: this.messagesRepo,
successful: this.successfulMessagesRepo,
},
services: this.services,
getBotRunning: () => this.botRunning,
setBotRunning: (running) => {
this.botRunning = running;
},
getMessagesThisHour: () => this.messagesThisHour,
getOnConfigChange: () => this.onConfigChange,
};
}
private setupRoutes(): void {
this.app.use('/media', express.static(path.join(process.cwd(), 'data', 'media')));
// Serve static files
this.app.use(express.static(path.join(process.cwd(), 'public')));
this.app.use(express.json());
// API: Get stats
this.app.get('/api/stats', (req, res) => {
const stats = this.getStats();
res.json(stats);
});
const ctx = this.context();
this.app.use('/api', createStatsRouter(ctx));
this.app.use('/api', createChatsRouter(ctx));
this.app.use('/api', createConversationsRouter(ctx));
this.app.use('/api', createMediaRouter(ctx));
this.app.use('/api', createControlRouter(ctx));
this.app.use('/api', createDebugRouter(ctx));
this.app.use('/api', createLogsRouter(ctx));
this.app.use('/api', createAnalyticsRouter(ctx));
this.app.use('/api', createConfigRouter(ctx));
this.app.use('/api', createLoginRouter(ctx));
// API: Get recent activity
this.app.get('/api/activity', (req, res) => {
res.json(this.activityLog.slice(-50).reverse());
});
// API: Look up posts by author username
this.app.get('/api/posts/by-author/:username', (req, res) => {
const posts = this.postsRepo.getByAuthor(req.params.username);
res.json(posts);
});
// API: Get recent messages
this.app.get('/api/messages', (req, res) => {
const messages = this.messagesRepo.getRecent(20);
res.json(messages);
});
// API: Get successful messages (ones that got responses)
this.app.get('/api/successful', (req, res) => {
const successful = this.successfulMessagesRepo.getRecent(10);
res.json(successful);
});
// API: Get active chats (from cache - last 48 hours)
// Does NOT fetch from Reddit - use /api/chats/fetch-all to refresh from Reddit
this.app.get('/api/chats', async (req, res) => {
if (!this.chatService) {
res.json([]);
return;
}
try {
const allChats = await this.chatService.getCachedConversations();
// Filter to last 48 hours for "active" view
const cutoffTime = Date.now() - (48 * 60 * 60 * 1000);
const activeChats = allChats.filter(chat => chat.lastMessageTime >= cutoffTime);
res.json(activeChats);
} catch (error) {
this.logger.error('Failed to fetch chats:', error);
res.json([]);
}
});
// API: Fetch all chats/messages and refresh cache
this.app.post('/api/chats/fetch-all', async (req, res) => {
if (!this.chatService) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const chats = await this.chatService.fetchAllMessages();
res.json({ success: true, count: chats.length });
} catch (error) {
this.logger.error('Failed to fetch all chats:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Get cached conversations with full message history
this.app.get('/api/conversations', async (req, res) => {
if (!this.chatService) {
res.json([]);
return;
}
try {
const conversations = await this.chatService.getCachedConversations();
res.json(conversations);
} catch (error) {
this.logger.error('Failed to fetch conversations:', error);
res.json([]);
}
});
// API: Get a single cached conversation by room ID or username
this.app.get('/api/conversations/:id', async (req, res) => {
if (!this.chatService) {
res.status(404).json({ error: 'Chat service not initialized' });
return;
}
try {
const conversations = await this.chatService.getCachedConversations();
const id = decodeURIComponent(req.params.id);
const conversation = conversations.find(
(c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id
);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
res.json(conversation);
} catch (error) {
this.logger.error('Failed to fetch conversation:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Suggest a reply for a conversation using AI
this.app.post('/api/conversations/:id/suggest-reply', async (req, res) => {
if (!this.chatService) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
if (!this.aiClient) {
res.status(500).json({ error: 'AI client not initialized' });
return;
}
try {
const conversations = await this.chatService.getCachedConversations();
const id = decodeURIComponent(req.params.id);
const conversation = conversations.find(
(c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id
);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
if (!conversation.messages || conversation.messages.length === 0) {
res.status(400).json({ error: 'No messages in conversation to generate a reply from' });
return;
}
const hasReply = conversation.messages.some(m => !m.isSelf);
if (!hasReply) {
res.status(400).json({ error: 'No reply from user yet - nothing to respond to' });
return;
}
const suggestedReply = await this.aiClient.generateReply(
conversation.messages.map(m => ({ sender: m.sender, text: m.text, isSelf: m.isSelf })),
conversation.username
);
res.json({ suggestedReply });
} catch (error) {
this.logger.error('Failed to generate suggested reply:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Send a reply in a chat conversation
this.app.post('/api/conversations/:id/send-reply', async (req, res) => {
if (!this.chatService) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const { message } = req.body;
if (!message || typeof message !== 'string' || message.trim().length === 0) {
res.status(400).json({ error: 'Message is required' });
return;
}
const conversations = await this.chatService.getCachedConversations();
const id = decodeURIComponent(req.params.id);
const conversation = conversations.find(
(c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id
);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
if (!conversation.url) {
res.status(400).json({ error: 'No chat URL for this conversation' });
return;
}
const success = await this.chatService.sendChatMessage(conversation.url, message.trim());
if (success) {
this.logActivity('sent', `Sent chat reply to u/${conversation.username}`, conversation.username);
this.pushLog('info', `Chat reply sent to u/${conversation.username}`);
res.json({ success: true });
// Refresh chats in background so cache reflects the sent message
this.chatService.refreshTopChats(5).catch(err =>
this.logger.error('Failed to refresh chats after send-reply:', err)
);
} else {
res.status(500).json({ error: 'Failed to send chat message' });
}
} catch (error) {
this.logger.error('Failed to send chat reply:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Approve a pending auto-reply
this.app.post('/api/conversations/:id/approve-reply', async (req, res) => {
if (!this.chatService) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const conversations = await this.chatService.getCachedConversations();
const id = decodeURIComponent(req.params.id);
const conversation = conversations.find(
(c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id
);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
if (conversation.replyStatus !== 'pending' || !conversation.pendingReply) {
res.status(400).json({ error: 'No pending reply to approve' });
return;
}
if (!conversation.url) {
res.status(400).json({ error: 'No chat URL for this conversation' });
return;
}
const message = conversation.pendingReply;
const success = await this.chatService.sendChatMessage(conversation.url, message);
if (success) {
conversation.replyStatus = 'sent';
conversation.pendingReply = undefined;
await this.chatService.saveCacheExternal();
this.logActivity('sent', `Approved & sent auto-reply to u/${conversation.username}`, conversation.username);
this.pushLog('info', `Auto-reply approved and sent to u/${conversation.username}`);
res.json({ success: true });
// Refresh chats in background so cache reflects the sent message
this.chatService.refreshTopChats(5).catch(err =>
this.logger.error('Failed to refresh chats after approve-reply:', err)
);
} else {
res.status(500).json({ error: 'Failed to send message' });
}
} catch (error) {
this.logger.error('Failed to approve reply:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Reject a pending auto-reply
this.app.post('/api/conversations/:id/reject-reply', async (req, res) => {
if (!this.chatService) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const conversations = await this.chatService.getCachedConversations();
const id = decodeURIComponent(req.params.id);
const conversation = conversations.find(
(c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id
);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
if (conversation.replyStatus !== 'pending') {
res.status(400).json({ error: 'No pending reply to reject' });
return;
}
conversation.pendingReply = undefined;
conversation.replyStatus = undefined;
// Clear processed state so it can be re-evaluated later if needed
if (this.autoReplyService) {
this.autoReplyService.clearProcessedState(conversation.username);
}
await this.chatService.saveCacheExternal();
this.logActivity('info', `Rejected auto-reply for u/${conversation.username}`, conversation.username);
this.pushLog('info', `Auto-reply rejected for u/${conversation.username}`);
res.json({ success: true });
} catch (error) {
this.logger.error('Failed to reject reply:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Dismiss upsell detection flag
this.app.post('/api/conversations/:id/dismiss-upsell', async (req, res) => {
if (!this.chatService) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const conversations = await this.chatService.getCachedConversations();
const id = decodeURIComponent(req.params.id);
const conversation = conversations.find(
(c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id
);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
conversation.replyStatus = undefined;
conversation.pendingReply = undefined;
await this.chatService.saveCacheExternal();
res.json({ success: true });
} catch (error) {
this.logger.error('Failed to dismiss upsell flag:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Get downloaded chat media
this.app.get('/api/media', async (req, res) => {
if (!this.mediaService) {
res.json([]);
return;
}
try {
const items = await this.mediaService.getMediaItems();
res.json(items.map((item) => ({
...item,
urlPath: `/media/${encodeURIComponent(item.relativePath)}`,
})));
} catch (error) {
this.logger.error('Failed to fetch media index:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Get media sync status
this.app.get('/api/media/sync', (req, res) => {
if (!this.mediaSyncService) {
res.status(503).json({ error: 'Media sync service not initialized' });
return;
}
res.json(this.mediaSyncService.getStatus());
});
// API: Trigger media sync from dashboard
this.app.post('/api/media/sync', async (req, res) => {
if (!this.mediaSyncService) {
res.status(503).json({ error: 'Media sync service not initialized' });
return;
}
try {
const full = req.body?.full === true;
const started = await this.mediaSyncService.triggerSync(full);
if (!started) {
res.json({ success: false, reason: 'Sync already in progress' });
return;
}
this.pushLog('info', `Media sync triggered from dashboard (${full ? 'full' : 'incremental'})`);
res.json({ success: true });
} catch (error) {
this.logger.error('Media sync trigger failed:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Full media sync - walks ALL conversations
this.app.post('/api/media/sync/full', async (req, res) => {
if (!this.mediaSyncService) {
res.status(503).json({ error: 'Media sync service not initialized' });
return;
}
try {
const started = await this.mediaSyncService.triggerSync(true);
if (!started) {
res.json({ success: false, reason: 'Sync already in progress' });
return;
}
this.pushLog('info', 'Full media sync triggered from dashboard (all conversations)');
res.json({ success: true });
} catch (error) {
this.logger.error('Full media sync trigger failed:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Start bot
this.app.post('/api/start', (req, res) => {
this.botRunning = true;
this.logActivity('sent', 'Bot started by user');
this.logger.info('Bot started via web UI');
res.json({ success: true, running: true });
});
// API: Stop bot
this.app.post('/api/stop', (req, res) => {
this.botRunning = false;
this.logActivity('skipped', 'Bot stopped by user');
this.logger.info('Bot stopped via web UI');
res.json({ success: true, running: false });
});
// API: Debug inspect chat DOM
this.app.post('/api/debug/inspect-dom', async (req, res) => {
if (!this.chatService) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const chatUrl = req.body?.url;
if (!chatUrl) {
res.status(400).json({ error: 'url is required' });
return;
}
const result = await this.chatService.debugInspectDom(chatUrl);
res.json(result);
} catch (error) {
this.logger.error('DOM inspection failed:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Debug check responses
this.app.post('/api/debug/check-responses', async (req, res) => {
if (!this.responseChecker) {
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) });
}
});
// API: SSE endpoint for realtime logs
this.app.get('/api/logs/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
// Send initial connection message
res.write(`data: ${JSON.stringify({ type: 'connected', timestamp: Date.now() })}\n\n`);
// Add client to SSE set
this.sseClients.add(res);
// Send recent logs as initial data
this.realtimeLog.slice(-50).forEach(log => {
res.write(`data: ${JSON.stringify({ type: 'log', ...log })}\n\n`);
});
// Remove client on disconnect
req.on('close', () => {
this.sseClients.delete(res);
});
});
// API: Get recent logs (for initial load)
this.app.get('/api/logs', (req, res) => {
const limit = parseInt(req.query.limit as string) || 100;
res.json(this.realtimeLog.slice(-limit));
});
// API: Get active timers
this.app.get('/api/timers', (req, res) => {
if (!this.rateLimiter) {
res.status(503).json({ error: 'Rate limiter not initialized' });
return;
}
res.json(this.rateLimiter.getTimers());
});
// API: Analytics - response rate by subreddit
this.app.get('/api/analytics/by-subreddit', (req, res) => {
res.json(this.messagesRepo.getSubredditStats());
});
// API: Analytics - response rate by strategy
this.app.get('/api/analytics/by-strategy', (req, res) => {
if (this.strategySelector) {
res.json(this.strategySelector.getStats());
} else {
res.json(this.messagesRepo.getStrategyStats());
}
});
// API: Analytics - daily response rate trend
this.app.get('/api/analytics/trend', (req, res) => {
const days = parseInt(req.query.days as string) || 30;
res.json(this.messagesRepo.getResponseRateTrend(days));
});
// API: Get runtime config
this.app.get('/api/config', (req, res) => {
res.json({
rateLimit: this.runtimeConfig.rateLimit,
browser: {
dryRun: this.runtimeConfig.browser.dryRun,
},
});
});
// API: Update runtime config
this.app.put('/api/config', (req, res) => {
try {
const updates = req.body;
// Validate and apply rate limit updates
if (updates.rateLimit) {
if (typeof updates.rateLimit.minDelayBetweenMessagesMs === 'number') {
this.runtimeConfig.rateLimit.minDelayBetweenMessagesMs = Math.max(0, updates.rateLimit.minDelayBetweenMessagesMs);
}
if (typeof updates.rateLimit.maxMessagesPerHour === 'number') {
this.runtimeConfig.rateLimit.maxMessagesPerHour = Math.max(1, updates.rateLimit.maxMessagesPerHour);
}
if (typeof updates.rateLimit.jitterMs === 'number') {
this.runtimeConfig.rateLimit.jitterMs = Math.max(0, updates.rateLimit.jitterMs);
}
}
// Validate and apply browser updates
if (updates.browser) {
if (typeof updates.browser.dryRun === 'boolean') {
this.runtimeConfig.browser.dryRun = updates.browser.dryRun;
}
}
// Also update the original config object so getStats reflects new values
this.config.rateLimit.minDelayBetweenMessagesMs = this.runtimeConfig.rateLimit.minDelayBetweenMessagesMs;
this.config.rateLimit.maxMessagesPerHour = this.runtimeConfig.rateLimit.maxMessagesPerHour;
this.config.rateLimit.jitterMs = this.runtimeConfig.rateLimit.jitterMs;
this.config.browser.dryRun = this.runtimeConfig.browser.dryRun;
// Notify listeners of config change
if (this.onConfigChange) {
this.onConfigChange(this.runtimeConfig);
}
this.logActivity('info', `Config updated via dashboard`);
this.pushLog('info', 'Configuration updated via dashboard', updates);
res.json({ success: true, config: this.runtimeConfig });
} catch (error) {
this.logger.error('Failed to update config:', error);
res.status(400).json({ error: String(error) });
}
});
// Serve dashboard for all other routes
this.app.get(/.*/, (req, res) => {
// SPA catch-all.
this.app.get(/.*/, (_req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
});
}
// ---------- public API used by the bootstrapper ----------
isRunning(): boolean {
return this.botRunning;
}
setChatService(chatService: ChatService): void {
this.chatService = chatService;
this.services.chat = chatService;
}
setMediaService(mediaService: MediaService): void {
this.mediaService = mediaService;
this.services.media = mediaService;
}
setMediaSyncService(mediaSyncService: MediaSyncService): void {
this.mediaSyncService = mediaSyncService;
this.services.mediaSync = mediaSyncService;
}
setResponseChecker(responseChecker: ResponseCheckerService): void {
this.responseChecker = responseChecker;
this.services.responseChecker = responseChecker;
}
setRateLimiter(rateLimiter: RateLimiter): void {
this.rateLimiter = rateLimiter;
this.services.rateLimiter = rateLimiter;
}
setAiClient(aiClient: OpenRouterClient): void {
this.aiClient = aiClient;
this.services.ai = aiClient;
}
setStrategySelector(strategySelector: StrategySelector): void {
this.strategySelector = strategySelector;
this.services.strategySelector = strategySelector;
}
setAutoReplyService(autoReplyService: AutoReplyService): void {
this.autoReplyService = autoReplyService;
}
private getStats(): BotStats {
const messages = this.messagesRepo.getRecent(1000);
const totalSent = messages.length;
const totalResponses = messages.filter((m) => m.gotResponse).length;
return {
isRunning: this.botRunning,
dryRunMode: this.config.browser.dryRun,
totalProcessed: this.getTotalProcessed(),
totalMessagesSent: totalSent,
totalResponses,
responseRate: totalSent > 0 ? Math.round((totalResponses / totalSent) * 100) : 0,
messagesThisHour: this.messagesThisHour,
maxMessagesPerHour: this.config.rateLimit.maxMessagesPerHour,
subreddits: this.config.reddit.subreddits,
targetGenders: this.config.reddit.targetGenders,
recentActivity: this.activityLog.slice(-10).reverse(),
};
}
private getTotalProcessed(): number {
// This is a simple count - in production you'd query the DB
return this.activityLog.filter((a) => a.type === 'sent' || a.type === 'skipped').length;
setBrowserClient(browserClient: BrowserClient): void {
this.services.browser = browserClient;
}
logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void {
const item: ActivityItem = {
timestamp: Date.now(),
type,
message,
recipient,
imageUrl,
};
this.activityLog.push(item);
// Keep only last 200 items
if (this.activityLog.length > 200) {
this.activityLog = this.activityLog.slice(-200);
}
this.activity.push(type, message, recipient, imageUrl);
}
/**
* Push a log entry to all connected SSE clients
*/
pushLog(level: LogEntry['level'], message: string, data?: unknown): void {
const entry: LogEntry = {
timestamp: Date.now(),
level,
message,
data,
};
this.broadcaster.push(level, message, data);
}
this.realtimeLog.push(entry);
// Keep only last 500 log entries
if (this.realtimeLog.length > 500) {
this.realtimeLog = this.realtimeLog.slice(-500);
}
// Broadcast to all SSE clients
const eventData = JSON.stringify({ type: 'log', ...entry });
this.sseClients.forEach(client => {
try {
client.write(`data: ${eventData}\n\n`);
} catch {
// Client disconnected, will be cleaned up on close event
this.sseClients.delete(client);
}
});
/** Fan a sync-progress frame out over the existing log SSE stream. */
broadcastSyncProgress(progress: Record<string, unknown> | null): void {
this.broadcaster.broadcastEvent('sync-progress', { progress });
}
/**
* Get the current runtime configuration
*/
getRuntimeConfig(): RuntimeConfig {
return this.runtimeConfig;
}
/**
* Set a callback to be notified when config changes
*/
setOnConfigChange(callback: (config: RuntimeConfig) => void): void {
this.onConfigChange = callback;
}
@@ -771,21 +187,12 @@ export class WebServer {
start(): void {
this.server = this.app.listen(this.port, () => {
this.logger.info(`Web dashboard running at http://localhost:${this.port}`);
this.pushLog('info', `Web dashboard started on port ${this.port}`);
this.broadcaster.push('info', `Web dashboard started on port ${this.port}`);
});
}
stop(): void {
// Close all SSE connections
this.sseClients.forEach(client => {
try {
client.end();
} catch {
// Ignore errors when closing
}
});
this.sseClients.clear();
this.broadcaster.closeAll();
if (this.server) {
this.server.close();
this.server = null;
+45
View File
@@ -0,0 +1,45 @@
export interface BotStats {
isRunning: boolean;
dryRunMode: boolean;
totalProcessed: number;
totalMessagesSent: number;
totalResponses: number;
responseRate: number;
messagesThisHour: number;
maxMessagesPerHour: number;
subreddits: { name: string; type: 'image' | 'chat' }[];
targetGenders: string[];
recentActivity: ActivityItem[];
/** Non-null when Reddit has throttled new DM invitations. */
invitationCooldown: {
until: number;
reason: string;
redditCode: string | null;
} | null;
}
export interface ActivityItem {
timestamp: number;
type: 'sent' | 'skipped' | 'response' | 'error' | 'info' | 'debug';
message: string;
recipient?: string;
imageUrl?: string;
}
export interface LogEntry {
timestamp: number;
level: 'info' | 'warn' | 'error' | 'debug';
message: string;
data?: unknown;
}
export interface RuntimeConfig {
rateLimit: {
minDelayBetweenMessagesMs: number;
maxMessagesPerHour: number;
jitterMs: number;
};
browser: {
dryRun: boolean;
};
}