docs: add agent codebase guide
This commit is contained in:
1 parent
742c06d0c4
commit
c630303b47
12 files changed
+881
-4483
No files matched your search
+85
-872
@@ -1,892 +1,105 @@
|
||||
# Data Flow Documentation
|
||||
# Runtime flows
|
||||
|
||||
Complete documentation of data flows through the RedditBot system.
|
||||
## Candidate funnel
|
||||
|
||||
---
|
||||
The production multi-account path is three independent loops backed by `candidate_queue`.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Post Processing Flow](#post-processing-flow)
|
||||
3. [Response Monitoring Flow](#response-monitoring-flow)
|
||||
4. [Chat Interaction Flow](#chat-interaction-flow)
|
||||
5. [Strategy Selection Flow](#strategy-selection-flow)
|
||||
6. [Media Sync Flow](#media-sync-flow)
|
||||
7. [Auto-Reply Flow](#auto-reply-flow)
|
||||
8. [Dashboard Data Flow](#dashboard-data-flow)
|
||||
9. [Error Recovery Flows](#error-recovery-flows)
|
||||
10. [Data Lifecycle](#data-lifecycle)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
RedditBot processes data through several interconnected pipelines:
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||
│ Reddit API │────►│ Monitor │────►│ Processor │
|
||||
│ (Posts) │ │ Service │ │ Service │
|
||||
└─────────────┘ └──────────────┘ └──────┬──────┘
|
||||
│
|
||||
┌──────────────┐ ▼
|
||||
│ Response │ ┌──────────────┐
|
||||
│ Checker │◄────│ OpenRouter │
|
||||
└──────────────┘ │ (AI) │
|
||||
└──────────────┘
|
||||
│
|
||||
┌──────────────┐ ▼
|
||||
│ Chat │ ┌──────────────┐
|
||||
│ Service │◄────│ Message │
|
||||
└──────────────┘ │ Sender │
|
||||
└──────────────┘
|
||||
│
|
||||
┌──────────────┐ ▼
|
||||
│ Media │ ┌──────────────┐
|
||||
│ Service │◄────│ SQLite DB │
|
||||
└──────────────┘ └──────────────┘
|
||||
│
|
||||
┌──────────────┐ │
|
||||
│ Dashboard │◄──────────┘
|
||||
│ (Express) │
|
||||
└──────────────┘
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> discovered: listing intake
|
||||
discovered --> screening: leased by screening worker
|
||||
screening --> ready: eligibility and preparation pass
|
||||
screening --> retry_wait: transient failure
|
||||
screening --> rejected: terminal eligibility or AI failure
|
||||
ready --> sending: leased to available account
|
||||
sending --> sent: Matrix confirms conversation
|
||||
sending --> retry_wait: account cooldown or transient failure
|
||||
sending --> rejected: terminal delivery result
|
||||
retry_wait --> discovered: screening retry due
|
||||
retry_wait --> ready: delivery retry due
|
||||
discovered --> expired: outside configured recent window
|
||||
ready --> expired: outside configured recent window
|
||||
```
|
||||
|
||||
---
|
||||
### 1. Discovery
|
||||
|
||||
## Post Processing Flow
|
||||
The discovery loop rotates through enabled subreddits and authenticated runtimes. A runtime fetches Reddit `/new` data using the process-shared `RedditRequestCoordinator`. Intake deduplicates post IDs and authors, excludes processed/contacted history, stores the full post snapshot, source type, weight, and timestamps, and logs exact intake counts.
|
||||
|
||||
The primary data flow for discovering, analyzing, and messaging about new posts.
|
||||
The queue has a bounded active size. Screening also pauses at configured `reddit.maxReadyCandidates`, and due delivery retries are promoted only while that ready capacity exists. Reddit request pacing, not a tight application poll, is the primary limiter.
|
||||
|
||||
### Stage 1: Discovery
|
||||
### 2. Screening and preparation
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ MonitorService │
|
||||
│ │
|
||||
│ 1. Shuffle subreddit order │
|
||||
│ 2. For each subreddit: │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ RedditApi.fetchPosts(subreddit) │ │
|
||||
│ │ │ │
|
||||
│ │ HTTP: GET /r/{subreddit}/new.json?limit=100 │ │
|
||||
│ │ │ │
|
||||
│ │ Response: { │ │
|
||||
│ │ data: { │ │
|
||||
│ │ children: [ │ │
|
||||
│ │ { │ │
|
||||
│ │ data: { │ │
|
||||
│ │ id: "abc123", │ │
|
||||
│ │ subreddit: "gonewild", │ │
|
||||
│ │ author: "user123", │ │
|
||||
│ │ title: "Summer vibes", │ │
|
||||
│ │ url: "https://...", │ │
|
||||
│ │ preview: { reddits: [...] }, │ │
|
||||
│ │ thumbnail: "https://...", │ │
|
||||
│ │ post_hint: "image", │ │
|
||||
│ │ over_18: true │ │
|
||||
│ │ } │ │
|
||||
│ │ } │ │
|
||||
│ │ ] │ │
|
||||
│ │ } │ │
|
||||
│ │ } │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 3. Filter posts: │
|
||||
│ ├─► post_hint === "image" │
|
||||
│ ├─► author exists and is not deleted │
|
||||
│ ├─► image_url can be extracted │
|
||||
│ └─► Author not in seller/spam list │
|
||||
│ │
|
||||
│ 4. Cross-reference with DB: │
|
||||
│ ├─► PostsRepository.getById(postId) → skip if exists │
|
||||
│ └─► MessagesRepository.getByAuthor(author) → skip if sent │
|
||||
│ │
|
||||
│ 5. Return newest valid post │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
A screening worker leases one discovered candidate. `MonitorService.screenCandidate()` applies cheap rules before expensive/networked rules:
|
||||
|
||||
1. Global processed-post and recipient-history checks.
|
||||
2. For chat sources, self-declared gender and adult age parsing.
|
||||
3. Seller evidence from the post, profile/about, and recent activity.
|
||||
4. Source-specific preparation in `ProcessorService`.
|
||||
|
||||
For image posts, preparation downloads and validates the image, detects target gender, performs model analysis, selects an image strategy, and generates an opener. For chat posts, it selects a chat strategy and generates from the post text. Both paths apply outbound safety checks and return either a prepared immutable result or an explicit skip reason.
|
||||
|
||||
Terminal results become rejected candidates. Rate limits and transient failures become retryable candidates. Prepared candidates become ready; the persisted snapshot means a different account can deliver without repeating AI work.
|
||||
|
||||
### 3. Dispatch
|
||||
|
||||
An account is eligible for new outreach only when its runtime is running, both identities verify, it has no actionable reply backlog, there is no invitation cooldown, and its rate limiter permits a send. Available accounts are ordered by their current hourly send count.
|
||||
|
||||
Matrix cooldowns retain the operation that triggered them. A room-creation or invitation limit pauses new outreach but does not suppress replies in existing rooms; a limit returned while sending into an existing room pauses those sends as well.
|
||||
|
||||
The dispatch worker leases a ready candidate for an account, materializes the processed post in that account's repository, applies the account rate limiter, checks dry-run, and atomically claims the recipient globally. `ChatService.startConversationDetailed()` returns one of:
|
||||
|
||||
- `sent`: persist the message and outcome metadata, commit recipient history, update activity, and mark the candidate sent;
|
||||
- `terminal`: release the recipient reservation and reject the candidate;
|
||||
- `retryable`: release/defer so another healthy account can claim it soon.
|
||||
|
||||
Only Matrix confirmation makes a send successful. A healthy HTTP process, room identifier lookup, or attempted request does not.
|
||||
|
||||
## Reddit request and cooldown flow
|
||||
|
||||
Every runtime shares one `RedditRequestCoordinator`, because Reddit can enforce limits by origin as well as account. JSON requests are serialized with a minimum interval; failed requests consume spacing too. A 429 extends the global cooldown using the server delay plus a buffer. Broad 403 behavior can also result in coordinated backoff. A listing 404 is treated as terminal for that subreddit and persisted as `enabled=false`.
|
||||
|
||||
Do not add local retries around this layer without preserving the shared schedule. Per-account bursts defeat the coordinator.
|
||||
|
||||
## Reply-first conversation automation
|
||||
|
||||
Each account runtime refreshes recent chats and processes actionable unread conversations oldest-first before it can accept new outreach.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
IN["Unread inbound Matrix event"] --> VERIFIED{"Confirmed after a recorded opener?"}
|
||||
VERIFIED -- no --> IGNORE["Ignore as unverified"]
|
||||
VERIFIED -- yes --> CLAIM{"Durable event claim exists?"}
|
||||
CLAIM -- sent --> IGNORE
|
||||
CLAIM -- pending --> RECON["Reconcile against Matrix history"]
|
||||
CLAIM -- no --> STATE["Load or snapshot conversation policy"]
|
||||
STATE --> STOP{"Manual, seller, image, boundary, unsafe, or turn limit?"}
|
||||
STOP -- yes --> HANDOFF["Persist terminal/human-required outcome"]
|
||||
STOP -- no --> GEN["Generate policy-specific reply"]
|
||||
GEN --> SAFE{"Outbound checks pass?"}
|
||||
SAFE -- no --> HANDOFF
|
||||
SAFE -- yes --> PERSIST["Claim event and persist intended state"]
|
||||
PERSIST --> SEND["Rate-limited Matrix send"]
|
||||
SEND --> MARK["Mark claim sent and record latency"]
|
||||
```
|
||||
|
||||
### Stage 2: Image Processing
|
||||
The inbound event key, intended reply, claim status, and conversation state make retries idempotent. If the process dies after Matrix accepted a reply, reconciliation finds the outbound event and marks the old claim sent instead of sending it again.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ProcessorService │
|
||||
│ │
|
||||
│ 1. Download Image │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ ImageService.downloadImage(url) │ │
|
||||
│ │ │ │
|
||||
│ │ HTTP: GET {image_url} │ │
|
||||
│ │ │ │
|
||||
│ │ Response: Binary image data │ │
|
||||
│ │ │ │
|
||||
│ │ Validation: │ │
|
||||
│ │ ├─► MIME type: image/jpeg | png | webp │ │
|
||||
│ │ ├─► File size: < 10MB │ │
|
||||
│ │ └─► Not empty/broken │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 2. Convert to base64 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Buffer.toString('base64') │ │
|
||||
│ │ │ │
|
||||
│ │ Result: "data:image/jpeg;base64,/9j/4AAQSkZJR..." │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
## Chat and media synchronization
|
||||
|
||||
### Stage 3: Gender Detection (Pre-Filter)
|
||||
`ChatService` communicates with Reddit's Matrix homeserver, maintains an account-scoped SQLite-backed cache, resolves `t2_` identities to Reddit usernames, and emits sync progress/new-reply callbacks. Startup chooses full or delta sync from persisted cursor state. Operators can explicitly run tracked sync jobs through the versioned API.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OpenRouterService │
|
||||
│ │
|
||||
│ 1. detectGenderOnly(imageBase64) │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ HTTP: POST https://openrouter.ai/api/v1/chat/ │ │
|
||||
│ │ completions │ │
|
||||
│ │ │ │
|
||||
│ │ Request: { │ │
|
||||
│ │ model: "fast-model", │ │
|
||||
│ │ messages: [ │ │
|
||||
│ │ { │ │
|
||||
│ │ role: "user", │ │
|
||||
│ │ content: [ │ │
|
||||
│ │ { type: "text", text: "Is this F or M?" }, │ │
|
||||
│ │ { type: "image_url", image_url: {...} } │ │
|
||||
│ │ ] │ │
|
||||
│ │ } │ │
|
||||
│ │ ] │ │
|
||||
│ │ } │ │
|
||||
│ │ │ │
|
||||
│ │ Response: { │ │
|
||||
│ │ choices: [{ message: { content: "F" } }] │ │
|
||||
│ │ } │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 3. Compare with config.targetGender │
|
||||
│ ├─► Match: continue to full analysis │
|
||||
│ └─► No match: SKIP post (log: "Gender mismatch, skipping") │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
`MediaService` resolves Matrix media, downloads it into the persistent data directory, and writes an account-scoped media index. `MediaSyncService` schedules incremental work only while the account is authenticated. Private media is served behind the same authorization boundary as the dashboard and APIs.
|
||||
|
||||
### Stage 4: Strategy Selection
|
||||
## Dashboard data and events
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ StrategySelector │
|
||||
│ │
|
||||
│ 1. Get strategy statistics from DB │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ MessagesRepository.getStrategyStats() │ │
|
||||
│ │ │ │
|
||||
│ │ Result: { │ │
|
||||
│ │ variant_a: { sent: 50, responded: 15, rate: 0.30 },│
|
||||
│ │ variant_b: { sent: 30, responded: 12, rate: 0.40 },│
|
||||
│ │ variant_c: { sent: 5, responded: 1, rate: 0.20 } │
|
||||
│ │ } │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 2. Apply exploration/exploit algorithm │
|
||||
│ ├─► Random roll < explorationRate → pick random variant │
|
||||
│ └─► Otherwise → pick highest response rate (min samples) │
|
||||
│ │
|
||||
│ 3. Return selected variant name │
|
||||
│ Example: "variant_b" │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
Routes assemble snapshots from repositories and injected services. `LogBroadcaster`/the realtime event hub publishes typed events for bot state, sends, failures, conversations, replies, sync, sessions, rate limits, and logs. The React client validates incoming events, invalidates matching TanStack Query keys, and reconnects with replay support. Cached screens remain visible but stale during a disconnect.
|
||||
|
||||
### Stage 5: Full AI Analysis
|
||||
## Failure classification
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OpenRouterService │
|
||||
│ │
|
||||
│ 1. analyzeImage(imageBase64) │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ HTTP: POST https://openrouter.ai/api/v1/chat/ │ │
|
||||
│ │ completions │ │
|
||||
│ │ │ │
|
||||
│ │ Request: { │ │
|
||||
│ │ model: "primary-model", │ │
|
||||
│ │ messages: [ │ │
|
||||
│ │ { │ │
|
||||
│ │ role: "system", │ │
|
||||
│ │ content: config.openrouter.systemPrompt │ │
|
||||
│ │ }, │ │
|
||||
│ │ { │ │
|
||||
│ │ role: "user", │ │
|
||||
│ │ content: [ │ │
|
||||
│ │ { type: "text", text: "Describe this image" },│
|
||||
│ │ { type: "image_url", image_url: {...} } │ │
|
||||
│ │ ] │ │
|
||||
│ │ } │ │
|
||||
│ │ ] │ │
|
||||
│ │ } │ │
|
||||
│ │ │ │
|
||||
│ │ Response: { │ │
|
||||
│ │ choices: [{ │ │
|
||||
│ │ message: { │ │
|
||||
│ │ content: '{"gender":"F","description":"..."}' │ │
|
||||
│ │ } │ │
|
||||
│ │ }] │ │
|
||||
│ │ } │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 2. Check for refusal │
|
||||
│ ├─► If refused → try fallback models │
|
||||
│ └─► If all failed → mark post with error, skip │
|
||||
│ │
|
||||
│ 3. Parse and return ImageAnalysis │
|
||||
│ { gender: "F", description: "Young woman, blonde hair..." }│
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
Use three categories consistently:
|
||||
|
||||
### Stage 6: Message Generation
|
||||
- Terminal: the candidate/action is unsuitable or impossible; persist a reason and do not retry automatically.
|
||||
- Retryable: service limits, temporary auth/network errors, or account cooldown; preserve the candidate and retry time.
|
||||
- Human-required: policy or safety cannot continue autonomously; stop automation and surface an attention item.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OpenRouterService │
|
||||
│ │
|
||||
│ 1. Retrieve few-shot examples │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ SuccessfulMessagesRepository.getExamples( │ │
|
||||
│ │ subreddit, count: 3 │ │
|
||||
│ │ ) │ │
|
||||
│ │ │ │
|
||||
│ │ Result: [ │ │
|
||||
│ │ { │ │
|
||||
│ │ original_message: "Hey, you look amazing...", │ │
|
||||
│ │ response_snippet: "Thank you so much!", │ │
|
||||
│ │ subreddit: "gonewild" │ │
|
||||
│ │ }, │ │
|
||||
│ │ // ... more examples │ │
|
||||
│ │ ] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 2. Generate message │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ HTTP: POST https://openrouter.ai/api/v1/chat/ │ │
|
||||
│ │ completions │ │
|
||||
│ │ │ │
|
||||
│ │ Request: { │ │
|
||||
│ │ model: "primary-model", │ │
|
||||
│ │ messages: [ │ │
|
||||
│ │ { role: "system", content: messagePrompt }, │ │
|
||||
│ │ { role: "user", content: [ │ │
|
||||
│ │ `Image: ${imageAnalysis.description}`, │ │
|
||||
│ │ `Post title: ${postTitle}`, │ │
|
||||
│ │ `Strategy: ${selectedVariant}`, │ │
|
||||
│ │ `Examples: ${JSON.stringify(examples)}` │ │
|
||||
│ │ ] │ │
|
||||
│ │ } │ │
|
||||
│ │ ] │ │
|
||||
│ │ } │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 3. Return generated message │
|
||||
│ "Hey, I came across your post and..." │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Stage 7: DM Sending
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ MessagePage │
|
||||
│ │
|
||||
│ 1. Navigate to compose page │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ page.goto("https://www.reddit.com/compose?to= │ │
|
||||
│ │ user123") │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 2. Fill recipient field │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ page.evaluate(() => { │ │
|
||||
│ │ const input = document.querySelector( │ │
|
||||
│ │ 'faceplate-autocomplete input' │ │
|
||||
│ │ ); │ │
|
||||
│ │ input.value = "user123"; │ │
|
||||
│ │ input.dispatchEvent(new Event('input', │ │
|
||||
│ │ { bubbles: true })); │ │
|
||||
│ │ }); │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 3. Fill subject field ("Stunning") │
|
||||
│ 4. Fill body field (generated message) │
|
||||
│ 5. Click send button │
|
||||
│ 6. Verify success (check for confirmation) │
|
||||
│ │
|
||||
│ Result: true (sent) or false (failed) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Stage 8: Database Recording
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Database │
|
||||
│ │
|
||||
│ 1. INSERT INTO processed_posts │
|
||||
│ VALUES ( │
|
||||
│ 'abc123', -- post_id │
|
||||
│ 'gonewild', -- subreddit │
|
||||
│ 'user123', -- author │
|
||||
│ 'Summer vibes', -- title │
|
||||
│ 'https://...', -- image_url │
|
||||
│ '{"gender":"F",...}', -- image_analysis │
|
||||
│ 'Hey, I came across...', -- generated_message │
|
||||
│ 1716234567, -- processed_at │
|
||||
│ 1, -- message_sent │
|
||||
│ NULL -- error │
|
||||
│ ); │
|
||||
│ │
|
||||
│ 2. INSERT INTO sent_messages │
|
||||
│ VALUES ( │
|
||||
│ NULL, -- id (auto) │
|
||||
│ 'abc123', -- post_id │
|
||||
│ 'user123', -- recipient │
|
||||
│ 'Stunning', -- subject │
|
||||
│ 'Hey, I came across...', -- message │
|
||||
│ 1716234567, -- sent_at │
|
||||
│ 'variant_b', -- strategy_id │
|
||||
│ 0, - - got_response │
|
||||
│ NULL -- response_checked_at │
|
||||
│ ); │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response Monitoring Flow
|
||||
|
||||
How the bot detects and records user replies to sent DMs.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ResponseCheckerService │
|
||||
│ │
|
||||
│ 1. Navigate to inbox │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ page.goto("https://www.reddit.com/message/inbox") │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 2. Parse inbox items │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Extract from page: │ │
|
||||
│ │ [ │ │
|
||||
│ │ { │ │
|
||||
│ │ from: "user456", │ │
|
||||
│ │ subject: "Re: Stunning", │ │
|
||||
│ │ snippet: "Thank you so much! 😊", │ │
|
||||
│ │ timestamp: 1716234000, │ │
|
||||
│ │ link: "/message/xyz789" │ │
|
||||
│ │ }, │ │
|
||||
│ │ // ... more messages │ │
|
||||
│ │ ] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 3. Match responses to sent messages │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ For each inbox item: │ │
|
||||
│ │ ├─► MessagesRepository.getByRecipient(from) │ │
|
||||
│ │ ├─► Find unmatched message within time window │ │
|
||||
│ │ ├─► Update: got_response = 1 │ │
|
||||
│ │ ├─► Update: response_checked_at = now │ │
|
||||
│ │ └─► Store in successful_messages │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 4. Update strategy statistics │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ MessagesRepository.updateStrategyStats( │ │
|
||||
│ │ strategy_id, got_response: true │ │
|
||||
│ │ ) │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 5. Log results │
|
||||
│ └─► "Found 3 responses, updated 2 strategies" │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chat Interaction Flow
|
||||
|
||||
How the bot interacts with Reddit chat conversations.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ChatService │
|
||||
│ │
|
||||
│ ──── PHASE 1: FETCH CHATS ────────────────────────────────── │
|
||||
│ │
|
||||
│ 1. Navigate to chat page │
|
||||
│ page.goto("https://chat.reddit.com") │
|
||||
│ │
|
||||
│ 2. Traverse shadow DOM for conversations │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ page.evaluate(() => { │ │
|
||||
│ │ // JS injection to pierce shadow DOM │ │
|
||||
│ │ const roots = document.querySelectorAll( │ │
|
||||
│ │ 'faceplate-chat-list' │ │
|
||||
│ │ ); │ │
|
||||
│ │ const chats = []; │ │
|
||||
│ │ roots.forEach(root => { │ │
|
||||
│ │ const shadow = root.shadowRoot; │ │
|
||||
│ │ const items = shadow.querySelectorAll( │ │
|
||||
│ │ '.chat-item' │ │
|
||||
│ │ ); │ │
|
||||
│ │ items.forEach(item => { │ │
|
||||
│ │ chats.push({ │ │
|
||||
│ │ id: item.dataset.chatId, │ │
|
||||
│ │ participant: item.querySelector( │ │
|
||||
│ │ '.username' │ │
|
||||
│ │ ).textContent, │ │
|
||||
│ │ lastMessage: item.querySelector( │ │
|
||||
│ │ '.message' │ │
|
||||
│ │ ).textContent, │ │
|
||||
│ │ timestamp: item.dataset.timestamp │ │
|
||||
│ │ }); │ │
|
||||
│ │ }); │ │
|
||||
│ │ }); │ │
|
||||
│ │ return chats; │ │
|
||||
│ │ }); │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 3. Cache results │
|
||||
│ └─► Save to memory + disk JSON │
|
||||
│ │
|
||||
│ ──── PHASE 2: LOAD CONVERSATION ──────────────────────────── │
|
||||
│ │
|
||||
│ 1. Check cache │
|
||||
│ ├─► If cached and fresh: return from memory │
|
||||
│ └─► If not: fetch from disk or Reddit │
|
||||
│ │
|
||||
│ 2. Fetch full history (pagination) │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ For each page of messages: │ │
|
||||
│ │ ├─► Navigate to chat view │ │
|
||||
│ │ ├─► Extract messages from shadow DOM │ │
|
||||
│ │ ├─► Scroll to load more (infinite scroll) │ │
|
||||
│ │ └─► Repeat until all loaded │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 3. Return conversation object │
|
||||
│ { │
|
||||
│ chatId: "abc123", │
|
||||
│ participants: ["user456", "bot"], │
|
||||
│ messages: [ │
|
||||
│ { author: "user456", text: "Hey!", timestamp: ... }, │
|
||||
│ { author: "bot", text: "Hi there!", timestamp: ... }, │
|
||||
│ // ... more messages │
|
||||
│ ], │
|
||||
│ lastUpdated: 1716234567 │
|
||||
│ } │
|
||||
│ │
|
||||
│ ──── PHASE 3: SEND MESSAGE ───────────────────────────────── │
|
||||
│ │
|
||||
│ 1. Navigate to chat view │
|
||||
│ 2. Fill message input (shadow DOM) │
|
||||
│ 3. Apply typing delay (human-like) │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ const typingDelay = random(500, 2000); │ │
|
||||
│ │ await page.waitForTimeout(typingDelay); │ │
|
||||
│ │ │ │
|
||||
│ │ // Simulate typing character by character │ │
|
||||
│ │ for (const char of message) { │ │
|
||||
│ │ input.value += char; │ │
|
||||
│ │ await page.waitForTimeout(random(30, 100)); │ │
|
||||
│ │ } │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ 4. Submit message │
|
||||
│ 5. Verify delivery (check for sent indicator) │
|
||||
│ │
|
||||
│ Result: true (delivered) or false (failed) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Strategy Selection Flow
|
||||
|
||||
How A/B test variants are selected and updated.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ StrategySelector │
|
||||
│ │
|
||||
│ SELECT STRATEGY │
|
||||
│ ──────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Input: subreddit, author │
|
||||
│ │
|
||||
│ 1. Get stats from DB │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ SELECT strategy_id, │ │
|
||||
│ │ COUNT(*) as sent, │ │
|
||||
│ │ SUM(got_response) as responded │ │
|
||||
│ │ FROM sent_messages │ │
|
||||
│ │ GROUP BY strategy_id │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 2. Calculate response rates │
|
||||
│ variant_a: 15/50 = 30% │
|
||||
│ variant_b: 12/30 = 40% │
|
||||
│ variant_c: 1/5 = 20% │
|
||||
│ │
|
||||
│ 3. Filter by minimum samples │
|
||||
│ minSamples = 10 │
|
||||
│ variant_c excluded (only 5 sends) │
|
||||
│ │
|
||||
│ 4. Explore vs Exploit │
|
||||
│ random() < explorationRate (e.g., 0.15)? │
|
||||
│ ├─► Yes: Random pick from enabled variants │
|
||||
│ └─► No: Pick highest rate (variant_b at 40%) │
|
||||
│ │
|
||||
│ Output: selected variant name │
|
||||
│ │
|
||||
│ ──── UPDATE STATS ────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Input: strategy_id, got_response (boolean) │
|
||||
│ │
|
||||
│ 1. Record in sent_messages │
|
||||
│ UPDATE sent_messages │
|
||||
│ SET got_response = 1, │
|
||||
│ response_checked_at = now │
|
||||
│ WHERE id = ? │
|
||||
│ │
|
||||
│ 2. If successful: store in successful_messages │
|
||||
│ INSERT INTO successful_messages │
|
||||
│ VALUES (original_message, image_analysis, post_title, │
|
||||
│ response_snippet, subreddit, now) │
|
||||
│ │
|
||||
│ 3. Update strategy statistics │
|
||||
│ (reflected in next selectStrategy call) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Media Sync Flow
|
||||
|
||||
How media files are downloaded and synchronized from chat conversations.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ MediaSyncService │
|
||||
│ │
|
||||
│ ──── TRIGGER ──────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Scheduled: every N minutes (config.mediaSync.intervalMinutes) │
|
||||
│ Manual: POST /api/media/sync (incremental) │
|
||||
│ Manual: POST /api/media/sync/full (full) │
|
||||
│ │
|
||||
│ ──── INCREMENTAL SYNC ────────────────────────────────────── │
|
||||
│ │
|
||||
│ 1. Get top 10 conversations from ChatService │
|
||||
│ 2. For each conversation: │
|
||||
│ ├─► Check if media already downloaded (index lookup) │
|
||||
│ ├─► If not: download media │
|
||||
│ │ └─► MediaService.downloadMedia(chatId) │
|
||||
│ └─► Update index │
|
||||
│ │
|
||||
│ ──── FULL SYNC ────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 1. Get ALL conversations from ChatService │
|
||||
│ 2. For each conversation: │
|
||||
│ ├─► Download any missing media │
|
||||
│ └─► Update index │
|
||||
│ │
|
||||
│ ──── MEDIA DOWNLOAD (MediaService) ────────────────────────── │
|
||||
│ │
|
||||
│ 1. Extract media URLs from conversation messages │
|
||||
│ 2. For each URL: │
|
||||
│ ├─► Check if Matrix thumbnail │
|
||||
│ │ └─► Resolve to full resolution URL │
|
||||
│ ├─► Download via Playwright │
|
||||
│ ├─► Compute SHA-1 hash │
|
||||
│ ├─► Check if already exists (dedup) │
|
||||
│ ├─► Save to data/media/{hash}.{ext} │
|
||||
│ └─► Update index JSON │
|
||||
│ │
|
||||
│ Output: Array of saved file paths │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Auto-Reply Flow
|
||||
|
||||
How the bot automatically replies to chat messages.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ AutoReplyService │
|
||||
│ │
|
||||
│ Scheduled: every N minutes (config.autoReply.checkInterval) │
|
||||
│ │
|
||||
│ 1. Get cached conversations │
|
||||
│ 2. For each conversation: │
|
||||
│ │ │
|
||||
│ ├─► Check for new/unread messages │
|
||||
│ │ └─► If no new messages: continue │
|
||||
│ │ │
|
||||
│ ├─► Detect upsell/spam keywords │
|
||||
│ │ ├─► "onlyfans", "link in bio", "check my..." │
|
||||
│ │ ├─► If detected: flag conversation, skip reply │
|
||||
│ │ └─► If not: continue │
|
||||
│ │ │
|
||||
│ ├─► Generate AI reply suggestion │
|
||||
│ │ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ │ OpenRouter.generateReply(conversationHistory) │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ Request: { │ │
|
||||
│ │ │ messages: [ │ │
|
||||
│ │ │ { role: "system", content: replyPrompt }, │ │
|
||||
│ │ │ { role: "user", content: conversation } │ │
|
||||
│ │ │ ] │ │
|
||||
│ │ │ } │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ Response: "Thanks for reaching out! 😊" │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ Reply: "Thanks for reaching out! 😊" │ │
|
||||
│ │ └─────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ├─► If autoSend = true: │
|
||||
│ │ └─► ChatService.sendMessage(chatId, reply) │
|
||||
│ │ └─► Log: "Auto-sent reply to {user}" │
|
||||
│ │ │
|
||||
│ └─► If autoSend = false: │
|
||||
│ └─► Mark as pending (visible in dashboard) │
|
||||
│ └─► User can approve/reject via dashboard │
|
||||
│ │
|
||||
│ Output: Summary of actions taken │
|
||||
│ "Checked 15 conversations: 3 new messages, 1 reply sent" │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Data Flow
|
||||
|
||||
How data flows from services to the React operator console.
|
||||
|
||||
### Current React model
|
||||
|
||||
1. Feature-local API clients request authoritative snapshots from `/api/*`.
|
||||
2. Shared Zod schemas in `packages/api-contracts` validate HTTP responses and
|
||||
realtime envelopes at runtime.
|
||||
3. TanStack Query caches snapshots, preserves them during background refresh,
|
||||
and owns mutation pending/error/success state.
|
||||
4. Operations opens `/api/events/stream`. Each typed event invalidates the
|
||||
affected query keys, such as stats, timers, conversations, media, or
|
||||
operations activity.
|
||||
5. Browser `EventSource` reconnects automatically. The client marks cached
|
||||
state stale while reconnecting instead of blanking the UI.
|
||||
|
||||
```text
|
||||
services/repositories -> Express /api snapshots -> Zod -> TanStack Query -> React routes
|
||||
| ^
|
||||
+-> realtime event hub -> /api/events/stream --------+
|
||||
```
|
||||
|
||||
### Retired legacy model
|
||||
|
||||
The historical diagram below is retained for migration context only.
|
||||
`public/index.html` has been removed and is no longer served.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ WebServer (Express) │
|
||||
│ │
|
||||
│ ──── REST API ─────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ GET /api/stats │
|
||||
│ ├─► Read from: │
|
||||
│ │ ├─► Bot running state (in-memory) │
|
||||
│ │ ├─► PostsRepository.countProcessed() │
|
||||
│ │ ├─► MessagesRepository.countSent() │
|
||||
│ │ ├─► MessagesRepository.countResponded() │
|
||||
│ │ └─► Calculate response rate │
|
||||
│ └─► Return: { running, processed, sent, responded, rate } │
|
||||
│ │
|
||||
│ GET /api/messages │
|
||||
│ └─► MessagesRepository.getRecent(limit: 50) │
|
||||
│ │
|
||||
│ GET /api/analytics/by-strategy │
|
||||
│ └─► MessagesRepository.getStrategyStats() │
|
||||
│ │
|
||||
│ PUT /api/config │
|
||||
│ ├─► Validate new config values │
|
||||
│ ├─► Update in-memory config │
|
||||
│ └─► Return updated config │
|
||||
│ │
|
||||
│ ──── LEGACY SSE LOG STREAMING ─────────────────────────────── │
|
||||
│ │
|
||||
│ GET /api/logs/stream │
|
||||
│ ├─► Client connects │
|
||||
│ ├─► Send any pending log entries │
|
||||
│ ├─► Subscribe to logger buffer │
|
||||
│ ├─► Push new entries as they arrive │
|
||||
│ │ └─► Event: log │
|
||||
│ │ └─► Data: { level, message, timestamp } │
|
||||
│ └─► On disconnect: unsubscribe │
|
||||
│ │
|
||||
│ ──── RETIRED LEGACY IMPLEMENTATION ────────────────────────── │
|
||||
│ │
|
||||
│ JavaScript polls API endpoints: │
|
||||
│ ├─► /api/stats every 5 seconds │
|
||||
│ ├─► /api/messages every 10 seconds │
|
||||
│ ├─► /api/timers every 2 seconds │
|
||||
│ └─► SSE /api/logs/stream (persistent connection) │
|
||||
│ │
|
||||
│ Updates DOM in real-time: │
|
||||
│ ├─► Stats cards (processed, sent, responded, rate) │
|
||||
│ ├─► Activity feed (scrolling list) │
|
||||
│ ├─► Message viewer (click to expand) │
|
||||
│ ├─► Chat browser (conversation list) │
|
||||
│ ├─► Media gallery (image grid) │
|
||||
│ └─► Rate limiter timers (countdown) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Recovery Flows
|
||||
|
||||
### AI Refusal Recovery
|
||||
|
||||
```
|
||||
1. OpenRouter.analyzeImage() returns refusal
|
||||
│
|
||||
├─► Detect refusal via heuristic patterns
|
||||
│ ├─► "cannot analyze"
|
||||
│ ├─► "against policy"
|
||||
│ └─► "unable to process"
|
||||
│
|
||||
├─► Try next model in fallback chain
|
||||
│ ├─► OpenRouter.fallbackModels[0]
|
||||
│ ├─► OpenRouter.fallbackModels[1]
|
||||
│ └─► ...
|
||||
│
|
||||
└─► If all models refuse:
|
||||
├─► Mark post as processed with error
|
||||
├─► Log: "All models refused to analyze post {id}"
|
||||
└─► Continue to next post
|
||||
```
|
||||
|
||||
### Network Error Recovery
|
||||
|
||||
```
|
||||
1. RedditApi.fetchPosts() throws error
|
||||
│
|
||||
└─► withRetry() wrapper handles recovery:
|
||||
├─► Retry 1: wait 5s
|
||||
├─► Retry 2: wait 15s
|
||||
├─► Retry 3: wait 45s
|
||||
└─► After 3 failures:
|
||||
├─► Log error
|
||||
├─► Skip this subreddit
|
||||
└─► Continue to next subreddit
|
||||
```
|
||||
|
||||
### Account Session Recovery
|
||||
|
||||
```
|
||||
1. Reddit or Matrix authentication is rejected
|
||||
│
|
||||
├─► Invalidate the shared account-auth gate
|
||||
├─► Stop account-bound schedulers
|
||||
└─► Wait for a fresh combined dump from the Chrome extension
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Lifecycle
|
||||
|
||||
### Post Data
|
||||
|
||||
```
|
||||
Discovered (MonitorService)
|
||||
│
|
||||
├─► Stored in processed_posts (pending)
|
||||
│
|
||||
├─► Processed (ProcessorService)
|
||||
│ ├─► image_analysis updated
|
||||
│ ├─► generated_message updated
|
||||
│ └─► message_sent updated
|
||||
│
|
||||
└─► Archived (retention policy TBD)
|
||||
└─► Kept indefinitely for analytics
|
||||
```
|
||||
|
||||
### Message Data
|
||||
|
||||
```
|
||||
Generated (ProcessorService)
|
||||
│
|
||||
├─► Stored in sent_messages (got_response = 0)
|
||||
│
|
||||
├─► Response checked (ResponseCheckerService)
|
||||
│ ├─► got_response = 1
|
||||
│ └─► response_checked_at set
|
||||
│
|
||||
├─► If responded:
|
||||
│ └─► Copied to successful_messages (for learning)
|
||||
│
|
||||
└─► Retained indefinitely for analytics
|
||||
```
|
||||
|
||||
### Learning Data
|
||||
|
||||
```
|
||||
Successful pair created (ResponseCheckerService)
|
||||
│
|
||||
├─► Stored in successful_messages
|
||||
│ ├─► original_message
|
||||
│ ├─► image_analysis
|
||||
│ ├─► post_title
|
||||
│ ├─► response_snippet
|
||||
│ └─► subreddit (for weighted selection)
|
||||
│
|
||||
├─► Retrieved for few-shot learning
|
||||
│ └─► Included in OpenRouter.generateMessage() prompt
|
||||
│
|
||||
└─► Retained indefinitely (grows bot intelligence)
|
||||
```
|
||||
|
||||
### Chat Data
|
||||
|
||||
```
|
||||
Conversation discovered (ChatService)
|
||||
│
|
||||
├─► Cached in memory
|
||||
├─► Persisted to disk JSON
|
||||
│
|
||||
├─► Messages added as they arrive
|
||||
│
|
||||
├─► Media downloaded (MediaService)
|
||||
│ └─► Stored in data/media/
|
||||
│
|
||||
└─► Retained until manually cleared
|
||||
└─► No automatic cleanup (TBD)
|
||||
```
|
||||
|
||||
### Media Data
|
||||
|
||||
```
|
||||
Media URL discovered
|
||||
│
|
||||
├─► Downloaded (MediaService)
|
||||
│ ├─► Matrix thumbnails resolved
|
||||
│ ├─► SHA-1 hash computed
|
||||
│ └─► Saved to data/media/{hash}.{ext}
|
||||
│
|
||||
├─► Indexed in data/media/index.json
|
||||
│
|
||||
├─► Deduplicated (hash check before download)
|
||||
│
|
||||
└─► Retained until manually cleared
|
||||
└─► No automatic cleanup (TBD)
|
||||
```
|
||||
Avoid converting errors into generic skips. Operational diagnosis depends on preserving which stage failed and why.
|
||||
Reference in new issue
Block a user