Files
RedditBot/docs/services.md
T
jjbrunton 633bb0ec04 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.
2026-05-21 01:32:10 +01:00

855 lines
21 KiB
Markdown

# Service Catalog
Detailed documentation for each service in the RedditBot codebase.
---
## Table of Contents
1. [MonitorService](#monitorservice)
2. [ProcessorService](#processorservice)
3. [StrategySelector](#strategypicker)
4. [ResponseChecker](#responsechecker)
5. [ChatService](#chatservice)
6. [MediaService](#mediaservice)
7. [MediaSyncService](#mediasyncservice)
8. [AutoReplyService](#autoreplyservice)
9. [RedditApi](#redditapi)
10. [OpenRouterService](#openrouterservice)
11. [BrowserClient](#browserclient)
12. [MessagePage](#messagepage)
13. [RateLimiter](#ratelimiter)
---
## MonitorService
**File**: `src/services/monitor.ts`
**Lines**: 72
**Responsibility**: Discover new image posts across configured subreddits
### Purpose
Polls configured subreddits at regular intervals, discovers new image posts, filters out already-processed posts and previously-messaged users, and returns the single newest valid post for processing.
### Public Methods
```typescript
class MonitorService {
constructor(
private redditApi: RedditApi,
private postsRepo: PostsRepository,
private messagesRepo: MessagesRepository,
private config: Config
)
/**
* Discover the next post to process.
* Returns null if no valid posts are available.
*/
discoverNextPost(): Promise<Post | null>
}
```
### Algorithm
1. Shuffle subreddit order (randomize polling order each cycle)
2. For each subreddit:
- Fetch new posts via `RedditApi.fetchPosts()`
- Filter: image posts only
- Filter: author not deleted
- Filter: post not already in `processed_posts`
- Filter: author not previously messaged (or meets re-message criteria)
3. Return the newest valid post across all subreddits
4. If no valid posts found, return `null`
### Dependencies
- `RedditApi` - Fetch posts from Reddit
- `PostsRepository` - Check for already-processed posts
- `MessagesRepository` - Check for previously-messaged users
- `Config` - Subreddit list, poll interval, target gender
### Error Handling
- Skips subreddits that return errors (continues to next)
- Logs errors at `warn` level
- Returns `null` on any unrecoverable error
---
## ProcessorService
**File**: `src/services/processor.ts`
**Lines**: 132
**Responsibility**: Core processing pipeline for discovered posts
### Purpose
Orchestrates the full processing pipeline for a single discovered post: image download, gender detection, AI analysis, strategy selection, message generation, and DM sending.
### Public Methods
```typescript
class ProcessorService {
constructor(
private config: Config,
private redditApi: RedditApi,
private openrouter: OpenRouterService,
private strategySelector: StrategySelector,
private messageSender: MessagePage,
private postsRepo: PostsRepository,
private messagesRepo: MessagesRepository,
private successfulRepo: SuccessfulMessagesRepository,
private logger: Logger
)
/**
* Process a single discovered post through the full pipeline.
*/
processPost(post: Post): Promise<void>
}
```
### Pipeline Steps
```
1. Download image
└─► ImageService.downloadImage(url)
└─► Validate MIME type (image/jpeg, image/png, image/webp)
└─► Validate file size
└─► Convert to base64
2. Quick gender detection (cost-saving pre-filter)
└─► OpenRouter.detectGenderOnly(image)
└─► Skip if gender doesn't match targetGender
3. Select A/B strategy variant
└─► StrategySelector.selectStrategy(subreddit, author)
4. Full AI analysis
└─► OpenRouter.analyzeImage(image)
└─► Detect gender, describe image
└─► Handle refusal (mark post, try next)
5. Generate message
└─► OpenRouter.generateMessage(analysis, title, examples, strategy)
└─► Few-shot examples from SuccessfulMessagesRepository
6. Send DM
└─► MessagePage.sendDM(author, "Stunning", message)
└─► Handle failure (log, don't mark as sent)
7. Record in database
└─► Mark post as processed
└─► Mark message_sent = 1 if successful
└─► Store generated_message for reference
```
### Error Handling
- AI refusal: Post is marked as processed with error, skipped
- Image download failure: Post is marked with error, skipped
- DM send failure: Post is marked as not sent, logged
- All errors logged with context (post ID, subreddit, author)
### Cost Optimization
- Quick gender detection costs less than full analysis
- Non-matching genders are skipped before expensive analysis
- AI refusal detection prevents wasted tokens on rejected prompts
---
## StrategySelector
**File**: `src/services/strategy-selector.ts`
**Lines**: 101
**Responsibility**: A/B testing with explore/exploit algorithm
### Purpose
Selects message variants (strategies) for each post using an explore/exploit algorithm based on historical response rates. Optimizes for maximum response rate over time while continuing to explore under-tested variants.
### Public Methods
```typescript
class StrategySelector {
constructor(
private config: Config,
private messagesRepo: MessagesRepository
)
/**
* Select the best strategy variant for a given subreddit/author.
*/
selectStrategy(subreddit: string, author: string): string
}
```
### Algorithm
1. **Get strategy stats** from `MessagesRepository`:
- Response count per variant
- Sent count per variant
- Response rate per variant
2. **Apply minimum sample threshold**:
- If a variant has fewer than `minSamples` sends, treat it as untested
- Untested variants are excluded from exploitation
3. **Explore vs Exploit decision**:
- With probability `explorationRate`, select a random variant (explore)
- Otherwise, select the variant with the highest response rate (exploit)
4. **Fallback**:
- If no variants have enough data, select randomly from enabled variants
### Configuration
```yaml
strategyVariants:
- name: "variant_a"
prompt: "Compliment-based message..."
enabled: true
- name: "variant_b"
prompt: "Question-based message..."
enabled: true
```
### Dependencies
- `Config` - Strategy variants, exploration rate, min samples
- `MessagesRepository` - Response rate statistics
---
## ResponseChecker
**File**: `src/services/response-checker.ts`
**Lines**: 179
**Responsibility**: Monitor inbox for user replies
### Purpose
Background service that navigates the Reddit inbox via Playwright, identifies responses to bot-sent messages, updates the database, and stores successful message/response pairs for few-shot learning.
### Public Methods
```typescript
class ResponseCheckerService {
constructor(
private browser: BrowserClient,
private messagesRepo: MessagesRepository,
private successfulRepo: SuccessfulMessagesRepository,
private logger: Logger
)
/**
* Check inbox for responses to sent messages.
*/
checkResponses(): Promise<void>
}
```
### Algorithm
1. Navigate to `/message/inbox` via Playwright
2. Parse inbox items (messages, conversations)
3. For each response item:
- Extract recipient username and message text
- Match to `sent_messages` by recipient + timestamp proximity
- Update `got_response = 1` and `response_checked_at`
- Store the message/response pair in `successful_messages`
4. Update strategy statistics (response rates)
### Dependencies
- `BrowserClient` - Playwright browser context
- `MessagesRepository` - Update response flags
- `SuccessfulMessagesRepository` - Store learning pairs
- `Logger` - Activity logging
---
## ChatService
**File**: `src/services/chat.ts`
**Lines**: 1123+
**Responsibility**: Reddit chat scraping, messaging, and conversation management
### Purpose
The most complex service in the codebase. Manages Reddit chat interactions including scraping active conversations, loading conversation history, sending messages with human-like delays, detecting upsell/spam, and managing conversation caches.
### Public Methods
```typescript
class ChatService {
constructor(
private browser: BrowserClient,
private config: Config,
private logger: Logger
)
/** Fetch active conversations from Reddit */
fetchChats(): Promise<ChatSummary[]>
/** Load full conversation history */
loadConversation(chatId: string): Promise<Conversation>
/** Send a message in a conversation */
sendMessage(chatId: string, text: string): Promise<boolean>
/** Get cached conversations */
getCachedConversations(): Conversation[]
/** Check for new messages in conversations */
checkForUpdates(): Promise<Conversation[]>
/** Detect upsell/spam in a message */
detectUpsell(message: string): boolean
}
```
### Shadow DOM Handling
Reddit uses custom `faceplate-*` web components with shadow DOM. Standard CSS selectors cannot pierce these boundaries. The service uses JavaScript injection via `page.evaluate()`:
```javascript
// Traverse shadow roots to find chat elements
function traverseShadowRoots(root) {
const elements = root.querySelectorAll('.some-class');
const shadowChildren = root.querySelectorAll('*::shadow, ::deep');
// ... recursive traversal
}
```
### Conversation Cache
Conversations are cached in two layers:
1. **In-memory**: Fast access for active conversations
2. **Disk JSON**: Persistence across restarts at `data/chats/`
Cache structure:
```json
{
"chatId": "abc123",
"participants": ["user1", "bot"],
"messages": [
{ "author": "user1", "text": "Hey!", "timestamp": 1234567890 }
],
"lastUpdated": 1234567890,
"hasNewMessages": false
}
```
### Human-Like Messaging
Messages are sent with:
- Typing delay simulation (random delay before "typing" starts)
- Variable typing speed
- Random delays between messages
- Delivery verification
### Known Issues
- **Hardcoded username**: `myUsername = 'Upset_Marsupial1006'` is hardcoded in JS evaluation scripts. Should be parameterized from `Config`.
- **Size**: 1123+ lines suggests this should be split into smaller services.
### Dependencies
- `BrowserClient` - Playwright browser context
- `Config` - Rate limits, username (should be), check intervals
- `Logger` - Activity logging
---
## MediaService
**File**: `src/services/media.ts`
**Lines**: 254
**Responsibility**: Media download, caching, and indexing
### Purpose
Downloads images from chat conversations, resolves Matrix blurred thumbnails to full resolution, stores files locally, and maintains a deduplicated index.
### Public Methods
```typescript
class MediaService {
constructor(
private config: Config,
private browser: BrowserClient,
private logger: Logger
)
/** Download media from a conversation */
downloadMedia(chatId: string): Promise<string[]>
/** Get media index */
getIndex(): MediaIndex
/** Get a specific media file path */
getPath(hash: string): string | null
}
```
### Deduplication
Files are deduplicated using SHA-1 hashing:
```
SHA-1(image_bytes) → data/media/{hash}.{ext}
```
The index maps hashes to file paths:
```json
{
"abc123def456": "abc123def456.jpg",
"789xyz012abc": "789xyz012abc.png"
}
```
### Matrix Thumbnail Resolution
Reddit's Matrix system uses blurred placeholder thumbnails. The service resolves these to full resolution by:
1. Detecting Matrix thumbnail URLs
2. Extracting the full-resolution URL from the response
3. Downloading the full-resolution image
### Dependencies
- `Config` - Media directory path
- `BrowserClient` - Playwright for Matrix resolution
- `Logger` - Download activity
---
## MediaSyncService
**File**: `src/services/media-sync.ts`
**Lines**: 119
**Responsibility**: Periodic background media synchronization
### Purpose
Runs periodic background syncs to download media from chat conversations. Supports both incremental (top 10 chats) and full (all conversations) sync modes.
### Public Methods
```typescript
class MediaSyncService {
constructor(
private config: Config,
private mediaService: MediaService,
private chatService: ChatService,
private logger: Logger
)
/** Start periodic sync */
start(): void
/** Stop periodic sync */
stop(): void
/** Trigger incremental sync */
syncIncremental(): Promise<void>
/** Trigger full sync */
syncFull(): Promise<void>
/** Get sync status */
getStatus(): SyncStatus
}
```
### Sync Modes
| Mode | Description | Use Case |
|---|---|---|
| Incremental | Top 10 most recent conversations | Regular background sync |
| Full | All conversations | Initial setup, catch-up |
### Dependencies
- `Config` - Sync interval, enabled flag
- `MediaService` - Actual download logic
- `ChatService` - Conversation list
- `Logger` - Sync progress
---
## AutoReplyService
**File**: `src/services/auto-reply.ts`
**Lines**: 199
**Responsibility**: Automated reply to chat conversations
### Purpose
Monitors cached conversations for unread messages, detects upsell/spam, generates AI reply suggestions, and either auto-sends or marks as pending for manual approval.
### Public Methods
```typescript
class AutoReplyService {
constructor(
private config: Config,
private chatService: ChatService,
private openrouter: OpenRouterService,
private logger: Logger
)
/** Start auto-reply checking */
start(): void
/** Stop auto-reply checking */
stop(): void
/** Check for unread messages and generate replies */
checkAndReply(): Promise<void>
}
```
### Workflow
```
1. Check cached conversations for unread messages
│
├─► Skip if no new messages
│
├─► Detect upsell/spam keywords
│ └─► Flag conversation, skip reply
│
├─► Generate AI reply suggestion
│ └─► OpenRouter.generateReply(conversation history)
│
├─► If autoSend: send reply immediately
│ └─► ChatService.sendMessage()
│
└─► If !autoSend: mark as pending
└─► Dashboard shows pending replies for approval
```
### Dependencies
- `Config` - Enabled flag, autoSend flag, check interval
- `ChatService` - Conversation access, message sending
- `OpenRouterService` - Reply generation
- `Logger` - Activity logging
---
## RedditApi
**File**: `src/reddit/api.ts`
**Lines**: 233
**Responsibility**: Reddit API client
### Purpose
Axios-based client for interacting with Reddit's public API. Handles post fetching, user profile checking, and authenticated requests using Playwright browser cookies.
### Public Methods
```typescript
class RedditApi {
constructor(
private browser: BrowserClient,
private logger: Logger
)
/** Fetch new posts from a subreddit */
fetchPosts(subreddit: string, limit?: number): Promise<Post[]>
/** Check if a user is a seller/spam */
checkUserProfile(username: string): Promise<UserProfile>
/** Make authenticated request using browser cookies */
authenticatedRequest(url: string): Promise<any>
}
```
### Image URL Resolution
Fallback chain for extracting image URLs from post data:
```
1. post.preview.reddits[0].url (Reddit CDN preview)
2. post.url (i.redd.it direct)
3. post.thumbnail (if not self/spam)
4. Scan post.selftext for imgur/image links
```
### Seller/Spam Detection
Checks user profile for keywords:
- "seller"
- "onlyfans"
- "link in bio"
- Other configured `sellerKeywords`
### Dependencies
- `BrowserClient` - Browser cookies for authentication
- `Logger` - Request logging
---
## OpenRouterService
**File**: `src/ai/openrouter.ts`
**Lines**: 307
**Responsibility**: OpenRouter AI client
### Purpose
Client for the OpenRouter API (OpenAI-compatible SDK). Provides image analysis, message generation, reply suggestions, and upsell detection.
### Public Methods
```typescript
class OpenRouterService {
constructor(private config: Config, private logger: Logger)
/** Full image analysis with model fallback */
analyzeImage(imageBase64: string): Promise<ImageAnalysis>
/** Quick gender detection (cost-saving) */
detectGenderOnly(imageBase64: string): Promise<string>
/** Generate DM message */
generateMessage(
imageAnalysis: ImageAnalysis,
postTitle: string,
strategy: string
): Promise<string>
/** Generate chat reply */
generateReply(conversationHistory: Message[]): Promise<string>
/** Detect upsell/spam keywords */
detectUpsell(message: string): boolean
}
```
### Model Fallback Chain
```
Primary model (config.openrouter.model)
│
└─► If refusal detected:
│
└─► Try each fallback model (config.openrouter.fallbackModels)
│
└─► If all fail: throw error
```
### Refusal Detection
Heuristic patterns to detect when AI models refuse to process prompts:
```typescript
const refusalPatterns = [
/cannot.*analyze/i,
/unable.*process/i,
/against.*policy/i,
/cannot.*view/i,
// ...
];
```
### Few-Shot Learning
`generateMessage()` retrieves successful message/response pairs from the database and includes them as examples in the prompt:
```
"Here are examples of messages that got responses:
- Example 1: {original_message} → {response_snippet}
- Example 2: ...
Generate a similar message for this image..."
```
### Dependencies
- `Config` - API key, model selection, prompts
- `MessagesRepository` - Successful examples for few-shot learning
---
## BrowserClient
**File**: `src/browser/client.ts`
**Lines**: 302
**Responsibility**: Playwright persistent context manager
### Purpose
Manages the Playwright browser session with persistent cookies/storage. Handles automated login, session verification, and 2FA/captcha detection.
### Public Methods
```typescript
class BrowserClient {
constructor(private config: Config, private logger: Logger)
/** Initialize browser context */
initialize(): Promise<void>
/** Verify browser session is active */
verifySession(): Promise<boolean>
/** Perform automated login */
login(): Promise<void>
/** Get page context for operations */
getPage(): Promise<Page>
/** Close browser */
close(): Promise<void>
}
```
### Login Flow
```
1. Navigate to Reddit login page
2. Fill username and password
3. Submit form
4. Check for 2FA prompt
└─► If present: wait for manual input (with timeout)
5. Check for captcha
└─► If present: log warning, wait
6. Verify login success:
└─► Check for user menu indicator
└─► Check for profile avatar
└─► Fallback: API request to /api/me
7. Save cookies to persistent directory
8. On failure: capture debug screenshot
```
### Session Persistence
- Cookies and storage are saved to `config.browser.userDataDir`
- Subsequent launches restore the session automatically
- Session expires when Reddit cookies expire (requires re-login)
### Dependencies
- `Config` - Credentials, headless mode, user data dir
- `Logger` - Login progress, errors
---
## MessagePage
**File**: `src/browser/message-page.ts`
**Lines**: 560
**Responsibility**: Reddit DM compose page automation
### Purpose
Automates the Reddit DM compose page: filling recipient/subject/body fields, handling shadow DOM components, submitting messages, and verifying success.
### Public Methods
```typescript
class MessagePage {
constructor(
private browser: BrowserClient,
private config: Config,
private logger: Logger
)
/** Send a DM to a recipient */
sendDM(recipient: string, subject: string, body: string): Promise<boolean>
/** Check if recipient field shows an error */
hasRecipientError(page: Page): Promise<boolean>
}
```
### Shadow DOM Handling
Reddit's compose form uses `faceplate-*` web components:
```javascript
// Fill shadow DOM input fields
page.evaluate(({ selector, value }) => {
const shadowInput = document.querySelector(`${selector} input`);
if (shadowInput) {
shadowInput.value = value;
shadowInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}, { selector: 'faceplate-textarea[name="body"]', value: body });
```
### Dry Run Mode
When `config.dryRun` is enabled:
- All fields are filled as normal
- Submit button is NOT clicked
- Message is logged but not sent
- Returns `true` (simulates success)
### Dependencies
- `BrowserClient` - Playwright page access
- `Config` - Dry run mode
- `Logger` - Send activity
---
## RateLimiter
**File**: `src/utils/rate-limiter.ts`
**Lines**: 114
**Responsibility**: Token-bucket rate limiting
### Purpose
Enforces rate limits on bot actions (primarily message sending) to avoid detection and bans. Uses a combination of minimum delay, random jitter, and hourly caps.
### Public Methods
```typescript
class RateLimiter {
constructor(config: RateLimitConfig)
/** Wait until a slot is available */
waitForSlot(): Promise<void>
/** Get remaining time until next slot */
getTimeUntilSlot(): number
/** Get current hourly count */
getHourlyCount(): number
/** Reset hourly counter */
resetHourlyCounter(): void
}
```
### Rate Limiting Strategy
```
1. Check hourly cap:
└─► If maxMessagesPerHour reached: wait until hour rolls over
2. Enforce minimum delay:
└─► lastActionTime + minDelayMs + random(0, jitterMs)
3. Track action timestamp
4. Increment hourly counter
```
### Dependencies
- `Config` - Rate limit settings
- `p-queue` - Concurrency control