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:
1 parent
dfa0580859
commit
633bb0ec04
84 files changed
+19278
-3888
No files matched your search
+211
-27
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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(/&/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;
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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}`;
|
||||
})
|
||||
),
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user